Merge pull request #2463 from robintown/rest-of-the-layouts
Implement most of the remaining layout changes
This commit is contained in:
@@ -22,7 +22,6 @@ limitations under the License.
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-inline: var(--inline-content-inset);
|
padding-inline: var(--inline-content-inset);
|
||||||
padding-block-end: var(--cpd-space-4x);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export interface SpotlightTileModel {
|
|||||||
export type TileModel = GridTileModel | SpotlightTileModel;
|
export type TileModel = GridTileModel | SpotlightTileModel;
|
||||||
|
|
||||||
export interface CallLayoutOutputs<Model> {
|
export interface CallLayoutOutputs<Model> {
|
||||||
|
/**
|
||||||
|
* Whether the scrolling layer of the layout should appear on top.
|
||||||
|
*/
|
||||||
|
scrollingOnTop: boolean;
|
||||||
/**
|
/**
|
||||||
* The visually fixed (non-scrolling) layer of the layout.
|
* The visually fixed (non-scrolling) layer of the layout.
|
||||||
*/
|
*/
|
||||||
@@ -122,7 +126,7 @@ export function arrangeTiles(
|
|||||||
);
|
);
|
||||||
let rows = Math.ceil(tileCount / columns);
|
let rows = Math.ceil(tileCount / columns);
|
||||||
|
|
||||||
let tileWidth = (width - (columns - 1) * gap) / columns;
|
let tileWidth = (width - (columns + 1) * gap) / columns;
|
||||||
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||||
|
|
||||||
// Impose a minimum width and height on the tiles
|
// Impose a minimum width and height on the tiles
|
||||||
@@ -133,7 +137,7 @@ export function arrangeTiles(
|
|||||||
// c = (W + g) / (w + g).
|
// c = (W + g) / (w + g).
|
||||||
columns = Math.floor((width + gap) / (tileMinWidth + gap));
|
columns = Math.floor((width + gap) / (tileMinWidth + gap));
|
||||||
rows = Math.ceil(tileCount / columns);
|
rows = Math.ceil(tileCount / columns);
|
||||||
tileWidth = (width - (columns - 1) * gap) / columns;
|
tileWidth = (width - (columns + 1) * gap) / columns;
|
||||||
tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||||
}
|
}
|
||||||
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;
|
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ limitations under the License.
|
|||||||
|
|
||||||
.fixed,
|
.fixed,
|
||||||
.scrolling {
|
.scrolling {
|
||||||
margin-inline: var(--inline-content-inset);
|
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ limitations under the License.
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 404px;
|
inline-size: 404px;
|
||||||
block-size: 233px;
|
block-size: 233px;
|
||||||
inset: -12px;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed > .slot[data-block-alignment="start"] {
|
.fixed > .slot[data-block-alignment="start"] {
|
||||||
|
|||||||
@@ -36,10 +36,16 @@ interface GridCSSProperties extends CSSProperties {
|
|||||||
"--height": string;
|
"--height": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "grid" layout, in which all participants are shown
|
||||||
|
* together in a scrolling grid.
|
||||||
|
*/
|
||||||
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||||
minBounds,
|
minBounds,
|
||||||
spotlightAlignment,
|
spotlightAlignment,
|
||||||
}) => ({
|
}) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||||
// lives
|
// lives
|
||||||
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
|
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.layer {
|
.layer {
|
||||||
margin-inline: var(--inline-content-inset);
|
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -43,7 +42,6 @@ limitations under the License.
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 404px;
|
inline-size: 404px;
|
||||||
block-size: 233px;
|
block-size: 233px;
|
||||||
inset: -12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot[data-block-alignment="start"] {
|
.slot[data-block-alignment="start"] {
|
||||||
|
|||||||
@@ -19,63 +19,23 @@ import { useObservableEagerState } from "observable-hooks";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
||||||
import {
|
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
|
||||||
CallLayout,
|
|
||||||
GridTileModel,
|
|
||||||
SpotlightTileModel,
|
|
||||||
arrangeTiles,
|
|
||||||
} from "./CallLayout";
|
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import styles from "./OneOnOneLayout.module.css";
|
import styles from "./OneOnOneLayout.module.css";
|
||||||
import { DragCallback } from "./Grid";
|
import { DragCallback } from "./Grid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "one-on-one" layout, in which the remote participant
|
||||||
|
* is shown at maximum size, overlaid by a small view of the local participant.
|
||||||
|
*/
|
||||||
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||||
minBounds,
|
minBounds,
|
||||||
spotlightAlignment,
|
|
||||||
pipAlignment,
|
pipAlignment,
|
||||||
}) => ({
|
}) => ({
|
||||||
fixed: forwardRef(function OneOnOneLayoutFixed({ model, Slot }, ref) {
|
scrollingOnTop: false,
|
||||||
const { width, height } = useObservableEagerState(minBounds);
|
|
||||||
const spotlightAlignmentValue = useObservableEagerState(spotlightAlignment);
|
|
||||||
|
|
||||||
const [generation] = useReactiveState<number>(
|
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
|
||||||
(prev) => (prev === undefined ? 0 : prev + 1),
|
return <div ref={ref} data-generation={0} />;
|
||||||
[width, height, model.spotlight === undefined, spotlightAlignmentValue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const spotlightTileModel: SpotlightTileModel | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
model.spotlight && {
|
|
||||||
type: "spotlight",
|
|
||||||
vms: model.spotlight,
|
|
||||||
maximised: false,
|
|
||||||
},
|
|
||||||
[model.spotlight],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDragSpotlight: DragCallback = useCallback(
|
|
||||||
({ xRatio, yRatio }) =>
|
|
||||||
spotlightAlignment.next({
|
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
|
||||||
inline: xRatio < 0.5 ? "start" : "end",
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} data-generation={generation} className={styles.layer}>
|
|
||||||
{spotlightTileModel && (
|
|
||||||
<Slot
|
|
||||||
className={classNames(styles.slot, styles.spotlight)}
|
|
||||||
id="spotlight"
|
|
||||||
model={spotlightTileModel}
|
|
||||||
onDrag={onDragSpotlight}
|
|
||||||
data-block-alignment={spotlightAlignmentValue.block}
|
|
||||||
data-inline-alignment={spotlightAlignmentValue.inline}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
||||||
|
|||||||
47
src/grid/SpotlightExpandedLayout.module.css
Normal file
47
src/grid/SpotlightExpandedLayout.module.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 180px;
|
||||||
|
block-size: 135px;
|
||||||
|
inset: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
103
src/grid/SpotlightExpandedLayout.tsx
Normal file
103
src/grid/SpotlightExpandedLayout.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { forwardRef, useCallback, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
|
import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||||
|
import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout";
|
||||||
|
import { DragCallback } from "./Grid";
|
||||||
|
import styles from "./SpotlightExpandedLayout.module.css";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||||
|
* tile stretches edge-to-edge and is overlaid by a picture-in-picture tile.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightExpandedLayout: CallLayout<
|
||||||
|
SpotlightExpandedLayoutModel
|
||||||
|
> = ({ minBounds, pipAlignment }) => ({
|
||||||
|
scrollingOnTop: true,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const spotlightTileModel: SpotlightTileModel = useMemo(
|
||||||
|
() => ({ type: "spotlight", vms: model.spotlight, maximised: true }),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<Slot
|
||||||
|
className={styles.spotlight}
|
||||||
|
id="spotlight"
|
||||||
|
model={spotlightTileModel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
||||||
|
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[width, height, model.pip === undefined, pipAlignmentValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipTileModel: GridTileModel | undefined = useMemo(
|
||||||
|
() => model.pip && { type: "grid", vm: model.pip },
|
||||||
|
[model.pip],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragPip: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
pipAlignment.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
{pipTileModel && (
|
||||||
|
<Slot
|
||||||
|
className={styles.pip}
|
||||||
|
id="pip"
|
||||||
|
model={pipTileModel}
|
||||||
|
onDrag={onDragPip}
|
||||||
|
data-block-alignment={pipAlignmentValue.block}
|
||||||
|
data-inline-alignment={pipAlignmentValue.inline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
--gap: 20px;
|
||||||
|
gap: var(--gap);
|
||||||
|
--grid-slot-width: 180px;
|
||||||
|
grid-template-columns: 1fr var(--grid-slot-width);
|
||||||
|
grid-template-rows: minmax(1fr, auto);
|
||||||
|
padding-inline: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
container: spotlight / size;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS makes us put a condition here, even though all we want to do is
|
||||||
|
unconditionally select the container so we can use cq units */
|
||||||
|
@container spotlight (width > 0) {
|
||||||
|
.spotlight > .slot {
|
||||||
|
inline-size: min(100cqi, 100cqb * (17 / 9));
|
||||||
|
block-size: min(100cqb, 100cqi / (4 / 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap);
|
||||||
|
justify-content: center;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > .slot {
|
||||||
|
inline-size: 180px;
|
||||||
|
block-size: 135px;
|
||||||
|
}
|
||||||
98
src/grid/SpotlightLandscapeLayout.tsx
Normal file
98
src/grid/SpotlightLandscapeLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { forwardRef, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
|
||||||
|
import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "spotlight landscape" layout, in which the spotlight
|
||||||
|
* tile takes up most of the space on the left, and the grid of participants is
|
||||||
|
* shown as a scrolling rail on the right.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightLandscapeLayout: CallLayout<
|
||||||
|
SpotlightLandscapeLayoutModel
|
||||||
|
> = ({ minBounds }) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const tileModel: TileModel = useMemo(
|
||||||
|
() => ({
|
||||||
|
type: "spotlight",
|
||||||
|
vms: model.spotlight,
|
||||||
|
maximised: false,
|
||||||
|
}),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.grid.length, width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<div className={styles.spotlight}>
|
||||||
|
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
|
[model.grid],
|
||||||
|
);
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.spotlight.length, model.grid, width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<div
|
||||||
|
className={classNames(styles.spotlight, {
|
||||||
|
[styles.withIndicators]: model.spotlight.length > 1,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{tileModels.map((m) => (
|
||||||
|
<Slot
|
||||||
|
key={m.vm.id}
|
||||||
|
className={styles.slot}
|
||||||
|
id={m.vm.id}
|
||||||
|
model={m}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.layer {
|
|
||||||
margin-inline: var(--inline-content-inset);
|
|
||||||
block-size: 100%;
|
|
||||||
display: grid;
|
|
||||||
--grid-gap: 20px;
|
|
||||||
gap: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="landscape"] {
|
|
||||||
--grid-slot-width: 180px;
|
|
||||||
grid-template-columns: 1fr calc(
|
|
||||||
var(--grid-columns) * var(--grid-slot-width) + (var(--grid-columns) - 1) *
|
|
||||||
var(--grid-gap)
|
|
||||||
);
|
|
||||||
grid-template-rows: minmax(1fr, auto);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spotlight {
|
|
||||||
container: spotlight / size;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSS makes us put a condition here, even though all we want to do is
|
|
||||||
unconditionally select the container so we can use cq units */
|
|
||||||
@container spotlight (width > 0) {
|
|
||||||
.layer[data-orientation="landscape"] > .spotlight > .slot {
|
|
||||||
inline-size: min(100cqi, 100cqb * (17 / 9));
|
|
||||||
block-size: min(100cqb, 100cqi / (4 / 3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--grid-gap);
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="landscape"] > .grid {
|
|
||||||
align-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer > .grid > .slot {
|
|
||||||
inline-size: var(--grid-slot-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="landscape"] > .grid > .slot {
|
|
||||||
block-size: 135px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="portrait"] {
|
|
||||||
margin-inline: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="portrait"] > .spotlight {
|
|
||||||
inline-size: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
margin-block-end: var(--cpd-space-4x);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="portrait"] > .spotlight.withIndicators {
|
|
||||||
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="portrait"] > .spotlight > .slot {
|
|
||||||
inline-size: 100%;
|
|
||||||
block-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="portrait"] > .grid {
|
|
||||||
margin-inline: var(--inline-content-inset);
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer[data-orientation="portrait"] > .grid > .slot {
|
|
||||||
--grid-slot-width: calc(
|
|
||||||
(100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns)
|
|
||||||
);
|
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
}
|
|
||||||
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
--gap: 20px;
|
||||||
|
gap: var(--gap);
|
||||||
|
margin-inline: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
container: spotlight / size;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
margin-block-end: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight.withIndicators {
|
||||||
|
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight > .slot {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
justify-content: center;
|
||||||
|
align-content: start;
|
||||||
|
padding-inline: var(--grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > .slot {
|
||||||
|
inline-size: var(--grid-tile-width);
|
||||||
|
block-size: var(--grid-tile-height);
|
||||||
|
}
|
||||||
@@ -18,46 +18,44 @@ import { CSSProperties, forwardRef, useMemo } from "react";
|
|||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
|
import {
|
||||||
import { SpotlightLayout as SpotlightLayoutModel } from "../state/CallViewModel";
|
CallLayout,
|
||||||
import styles from "./SpotlightLayout.module.css";
|
GridTileModel,
|
||||||
|
TileModel,
|
||||||
|
arrangeTiles,
|
||||||
|
} from "./CallLayout";
|
||||||
|
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./SpotlightPortraitLayout.module.css";
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
|
||||||
interface GridCSSProperties extends CSSProperties {
|
interface GridCSSProperties extends CSSProperties {
|
||||||
"--grid-columns": number;
|
"--grid-gap": string;
|
||||||
|
"--grid-tile-width": string;
|
||||||
|
"--grid-tile-height": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Layout {
|
/**
|
||||||
orientation: "portrait" | "landscape";
|
* An implementation of the "spotlight portrait" layout, in which the spotlight
|
||||||
gridColumns: number;
|
* tile is shown across the top of the screen, and the grid of participants
|
||||||
}
|
* scrolls behind it.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightPortraitLayout: CallLayout<
|
||||||
|
SpotlightPortraitLayoutModel
|
||||||
|
> = ({ minBounds }) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
function getLayout(gridLength: number, width: number): Layout {
|
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
||||||
const orientation = width < 800 ? "portrait" : "landscape";
|
{ model, Slot },
|
||||||
return {
|
ref,
|
||||||
orientation,
|
) {
|
||||||
gridColumns:
|
|
||||||
orientation === "portrait"
|
|
||||||
? Math.floor(width / 190)
|
|
||||||
: gridLength > 20
|
|
||||||
? 2
|
|
||||||
: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
|
|
||||||
minBounds,
|
|
||||||
}) => ({
|
|
||||||
fixed: forwardRef(function SpotlightLayoutFixed({ model, Slot }, ref) {
|
|
||||||
const { width, height } = useObservableEagerState(minBounds);
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
const layout = getLayout(model.grid.length, width);
|
|
||||||
const tileModel: TileModel = useMemo(
|
const tileModel: TileModel = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: "spotlight",
|
type: "spotlight",
|
||||||
vms: model.spotlight,
|
vms: model.spotlight,
|
||||||
maximised: layout.orientation === "portrait",
|
maximised: true,
|
||||||
}),
|
}),
|
||||||
[model.spotlight, layout.orientation],
|
[model.spotlight],
|
||||||
);
|
);
|
||||||
const [generation] = useReactiveState<number>(
|
const [generation] = useReactiveState<number>(
|
||||||
(prev) => (prev === undefined ? 0 : prev + 1),
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
@@ -65,27 +63,24 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
ref={ref}
|
|
||||||
data-generation={generation}
|
|
||||||
data-orientation={layout.orientation}
|
|
||||||
className={styles.layer}
|
|
||||||
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
|
|
||||||
>
|
|
||||||
<div className={styles.spotlight}>
|
<div className={styles.spotlight}>
|
||||||
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.grid} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
scrolling: forwardRef(function SpotlightLayoutScrolling(
|
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
|
||||||
{ model, Slot },
|
{ model, Slot },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { width, height } = useObservableEagerState(minBounds);
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
const layout = getLayout(model.grid.length, width);
|
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||||
|
width,
|
||||||
|
0,
|
||||||
|
model.grid.length,
|
||||||
|
);
|
||||||
const tileModels: GridTileModel[] = useMemo(
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
[model.grid],
|
[model.grid],
|
||||||
@@ -99,9 +94,14 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-generation={generation}
|
data-generation={generation}
|
||||||
data-orientation={layout.orientation}
|
|
||||||
className={styles.layer}
|
className={styles.layer}
|
||||||
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
|
style={
|
||||||
|
{
|
||||||
|
"--grid-gap": `${gap}px`,
|
||||||
|
"--grid-tile-width": `${Math.floor(tileWidth)}px`,
|
||||||
|
"--grid-tile-height": `${Math.floor(tileHeight)}px`,
|
||||||
|
} as GridCSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.spotlight, {
|
className={classNames(styles.spotlight, {
|
||||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Observable, defer, finalize, tap } from "rxjs";
|
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
|
||||||
|
|
||||||
const nothing = Symbol("nothing");
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
@@ -35,3 +35,15 @@ export function finalizeValue<T>(callback: (finalValue: T) => void) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RxJS operator that accumulates a state from a source of events. This is like
|
||||||
|
* scan, except it emits an initial value immediately before any events arrive.
|
||||||
|
*/
|
||||||
|
export function accumulate<State, Event>(
|
||||||
|
initial: State,
|
||||||
|
update: (state: State, event: Event) => State,
|
||||||
|
) {
|
||||||
|
return (events: Observable<Event>): Observable<State> =>
|
||||||
|
events.pipe(scan(update, initial), startWith(initial));
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ limitations under the License.
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--cpd-space-3x);
|
gap: var(--cpd-space-3x);
|
||||||
padding-block: var(--cpd-space-4x);
|
padding-block: var(--cpd-space-4x);
|
||||||
margin-inline: var(--inline-content-inset);
|
padding-inline: var(--inline-content-inset);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(0, 0, 0, 0) 0%,
|
rgba(0, 0, 0, 0) 0%,
|
||||||
@@ -123,17 +123,16 @@ limitations under the License.
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fixedGrid {
|
.fixedGrid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
/* Disable pointer events so the overlay doesn't block interaction with
|
|
||||||
elements behind it */
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixedGrid > :not(:first-child) {
|
|
||||||
pointer-events: initial;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollingGrid {
|
.scrollingGrid {
|
||||||
@@ -143,6 +142,18 @@ limitations under the License.
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fixedGrid,
|
||||||
|
.scrollingGrid {
|
||||||
|
/* Disable pointer events so the overlay doesn't block interaction with
|
||||||
|
elements behind it */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedGrid > :not(:first-child),
|
||||||
|
.scrollingGrid > :not(:first-child) {
|
||||||
|
pointer-events: initial;
|
||||||
|
}
|
||||||
|
|
||||||
.tile {
|
.tile {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
import LogoMark from "../icons/LogoMark.svg?react";
|
import LogoMark from "../icons/LogoMark.svg?react";
|
||||||
@@ -59,7 +59,6 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
|||||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||||
import { useLiveKit } from "../livekit/useLiveKit";
|
import { useLiveKit } from "../livekit/useLiveKit";
|
||||||
import { useFullscreen } from "./useFullscreen";
|
|
||||||
import { useWakeLock } from "../useWakeLock";
|
import { useWakeLock } from "../useWakeLock";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
import { MuteStates } from "./MuteStates";
|
import { MuteStates } from "./MuteStates";
|
||||||
@@ -76,14 +75,16 @@ import { SpotlightTile } from "../tile/SpotlightTile";
|
|||||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { makeGridLayout } from "../grid/GridLayout";
|
import { makeGridLayout } from "../grid/GridLayout";
|
||||||
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
|
|
||||||
import {
|
import {
|
||||||
CallLayout,
|
CallLayoutOutputs,
|
||||||
TileModel,
|
TileModel,
|
||||||
defaultPipAlignment,
|
defaultPipAlignment,
|
||||||
defaultSpotlightAlignment,
|
defaultSpotlightAlignment,
|
||||||
} from "../grid/CallLayout";
|
} from "../grid/CallLayout";
|
||||||
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
||||||
|
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||||
|
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||||
|
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -194,24 +195,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
||||||
connState,
|
connState,
|
||||||
);
|
);
|
||||||
|
const windowMode = useObservableEagerState(vm.windowMode);
|
||||||
const layout = useObservableEagerState(vm.layout);
|
const layout = useObservableEagerState(vm.layout);
|
||||||
const gridMode = useObservableEagerState(vm.gridMode);
|
const gridMode = useObservableEagerState(vm.gridMode);
|
||||||
const hasSpotlight = layout.spotlight !== undefined;
|
|
||||||
const fullscreenItems = useMemo(
|
|
||||||
() => (hasSpotlight ? ["spotlight"] : []),
|
|
||||||
[hasSpotlight],
|
|
||||||
);
|
|
||||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
|
||||||
useFullscreen(fullscreenItems);
|
|
||||||
const toggleSpotlightFullscreen = useCallback(
|
|
||||||
() => toggleFullscreen("spotlight"),
|
|
||||||
[toggleFullscreen],
|
|
||||||
);
|
|
||||||
|
|
||||||
// The maximised participant: either the participant that the user has
|
|
||||||
// manually put in fullscreen, or (TODO) the spotlight if the window is too
|
|
||||||
// small to show everyone
|
|
||||||
const maximisedParticipant = fullscreenItem;
|
|
||||||
|
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||||
@@ -235,14 +221,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
const gridBounds = useMemo(
|
const gridBounds = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
width: footerBounds.width,
|
width: bounds.width,
|
||||||
height: bounds.height - headerBounds.height - footerBounds.height,
|
height:
|
||||||
|
bounds.height -
|
||||||
|
headerBounds.height -
|
||||||
|
(windowMode === "flat" ? 0 : footerBounds.height),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
footerBounds.width,
|
bounds.width,
|
||||||
bounds.height,
|
bounds.height,
|
||||||
headerBounds.height,
|
headerBounds.height,
|
||||||
footerBounds.height,
|
footerBounds.height,
|
||||||
|
windowMode,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const gridBoundsObservable = useObservable(gridBounds);
|
const gridBoundsObservable = useObservable(gridBounds);
|
||||||
@@ -254,29 +244,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => new BehaviorSubject(defaultPipAlignment),
|
() => new BehaviorSubject(defaultPipAlignment),
|
||||||
);
|
);
|
||||||
|
|
||||||
const layoutSystem = useObservableEagerState(
|
|
||||||
useInitial(() =>
|
|
||||||
vm.layout.pipe(
|
|
||||||
map((l) => {
|
|
||||||
let makeLayout: CallLayout<Layout>;
|
|
||||||
if (l.type === "grid")
|
|
||||||
makeLayout = makeGridLayout as CallLayout<Layout>;
|
|
||||||
else if (l.type === "spotlight")
|
|
||||||
makeLayout = makeSpotlightLayout as CallLayout<Layout>;
|
|
||||||
else if (l.type === "one-on-one")
|
|
||||||
makeLayout = makeOneOnOneLayout as CallLayout<Layout>;
|
|
||||||
else throw new Error(`Unimplemented layout: ${l.type}`);
|
|
||||||
|
|
||||||
return makeLayout({
|
|
||||||
minBounds: gridBoundsObservable,
|
|
||||||
spotlightAlignment,
|
|
||||||
pipAlignment,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const setGridMode = useCallback(
|
const setGridMode = useCallback(
|
||||||
(mode: GridMode) => vm.setGridMode(mode),
|
(mode: GridMode) => vm.setGridMode(mode),
|
||||||
[vm],
|
[vm],
|
||||||
@@ -318,10 +285,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [setGridMode]);
|
}, [setGridMode]);
|
||||||
|
|
||||||
const showSpotlightIndicators = useObservable(layout.type === "spotlight");
|
const toggleSpotlightExpanded = useCallback(
|
||||||
const showSpeakingIndicators = useObservable(
|
() => vm.toggleSpotlightExpanded(),
|
||||||
layout.type === "spotlight" ||
|
[vm],
|
||||||
(layout.type === "grid" && layout.grid.length > 2),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const Tile = useMemo(
|
const Tile = useMemo(
|
||||||
@@ -333,20 +299,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{ className, style, targetWidth, targetHeight, model },
|
{ className, style, targetWidth, targetHeight, model },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
|
||||||
const showSpeakingIndicatorsValue = useObservableEagerState(
|
const showSpeakingIndicatorsValue = useObservableEagerState(
|
||||||
showSpeakingIndicators,
|
vm.showSpeakingIndicators,
|
||||||
);
|
);
|
||||||
const showSpotlightIndicatorsValue = useObservableEagerState(
|
const showSpotlightIndicatorsValue = useObservableEagerState(
|
||||||
showSpotlightIndicators,
|
vm.showSpotlightIndicators,
|
||||||
);
|
);
|
||||||
|
|
||||||
return model.type === "grid" ? (
|
return model.type === "grid" ? (
|
||||||
<GridTile
|
<GridTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={model.vm}
|
vm={model.vm}
|
||||||
maximised={false}
|
|
||||||
fullscreen={false}
|
|
||||||
onToggleFullscreen={toggleFullscreen}
|
|
||||||
onOpenProfile={openProfile}
|
onOpenProfile={openProfile}
|
||||||
targetWidth={targetWidth}
|
targetWidth={targetWidth}
|
||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
@@ -359,8 +323,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
vms={model.vms}
|
vms={model.vms}
|
||||||
maximised={model.maximised}
|
maximised={model.maximised}
|
||||||
fullscreen={false}
|
expanded={spotlightExpanded}
|
||||||
onToggleFullscreen={toggleSpotlightFullscreen}
|
onToggleExpanded={toggleSpotlightExpanded}
|
||||||
targetWidth={targetWidth}
|
targetWidth={targetWidth}
|
||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
showIndicators={showSpotlightIndicatorsValue}
|
showIndicators={showSpotlightIndicatorsValue}
|
||||||
@@ -369,52 +333,74 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
[
|
[vm, toggleSpotlightExpanded, openProfile],
|
||||||
toggleFullscreen,
|
|
||||||
toggleSpotlightFullscreen,
|
|
||||||
openProfile,
|
|
||||||
showSpeakingIndicators,
|
|
||||||
showSpotlightIndicators,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const layouts = useMemo(() => {
|
||||||
|
const inputs = {
|
||||||
|
minBounds: gridBoundsObservable,
|
||||||
|
spotlightAlignment,
|
||||||
|
pipAlignment,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
grid: makeGridLayout(inputs),
|
||||||
|
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||||
|
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
||||||
|
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
||||||
|
"one-on-one": makeOneOnOneLayout(inputs),
|
||||||
|
};
|
||||||
|
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
if (maximisedParticipant !== null) {
|
if (layout.type === "pip") {
|
||||||
const fullscreen = maximisedParticipant === fullscreenItem;
|
return (
|
||||||
if (maximisedParticipant === "spotlight") {
|
<SpotlightTile
|
||||||
return (
|
className={classNames(styles.tile, styles.maximised)}
|
||||||
<SpotlightTile
|
vms={layout.spotlight!}
|
||||||
className={classNames(styles.tile, styles.maximised)}
|
maximised
|
||||||
vms={layout.spotlight!}
|
expanded
|
||||||
maximised
|
onToggleExpanded={null}
|
||||||
fullscreen={fullscreen}
|
targetWidth={gridBounds.height}
|
||||||
onToggleFullscreen={toggleSpotlightFullscreen}
|
targetHeight={gridBounds.width}
|
||||||
targetWidth={gridBounds.height}
|
showIndicators={false}
|
||||||
targetHeight={gridBounds.width}
|
/>
|
||||||
showIndicators={false}
|
);
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
|
||||||
|
const fixedGrid = (
|
||||||
|
<Grid
|
||||||
|
key="fixed"
|
||||||
|
className={styles.fixedGrid}
|
||||||
|
style={{
|
||||||
|
insetBlockStart: headerBounds.bottom,
|
||||||
|
height: gridBounds.height,
|
||||||
|
}}
|
||||||
|
model={layout}
|
||||||
|
Layout={layers.fixed}
|
||||||
|
Tile={Tile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const scrollingGrid = (
|
||||||
|
<Grid
|
||||||
|
key="scrolling"
|
||||||
|
className={styles.scrollingGrid}
|
||||||
|
model={layout}
|
||||||
|
Layout={layers.scrolling}
|
||||||
|
Tile={Tile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// The grid tiles go *under* the spotlight in the portrait layout, but
|
||||||
|
// *over* the spotlight in the expanded layout
|
||||||
|
return layout.type === "spotlight-expanded" ? (
|
||||||
<>
|
<>
|
||||||
<Grid
|
{fixedGrid}
|
||||||
className={styles.scrollingGrid}
|
{scrollingGrid}
|
||||||
model={layout}
|
</>
|
||||||
Layout={layoutSystem.scrolling}
|
) : (
|
||||||
Tile={Tile}
|
<>
|
||||||
/>
|
{scrollingGrid}
|
||||||
<Grid
|
{fixedGrid}
|
||||||
className={styles.fixedGrid}
|
|
||||||
style={{
|
|
||||||
insetBlockStart: headerBounds.bottom,
|
|
||||||
height: gridBounds.height,
|
|
||||||
}}
|
|
||||||
model={layout}
|
|
||||||
Layout={layoutSystem.fixed}
|
|
||||||
Tile={Tile}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -424,14 +410,13 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toggleScreensharing = useCallback(async () => {
|
const toggleScreensharing = useCallback(async () => {
|
||||||
exitFullscreen();
|
|
||||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
||||||
audio: true,
|
audio: true,
|
||||||
selfBrowserSurface: "include",
|
selfBrowserSurface: "include",
|
||||||
surfaceSwitching: "include",
|
surfaceSwitching: "include",
|
||||||
systemAudio: "include",
|
systemAudio: "include",
|
||||||
});
|
});
|
||||||
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
|
}, [localParticipant, isScreenShareEnabled]);
|
||||||
|
|
||||||
let footer: JSX.Element | null;
|
let footer: JSX.Element | null;
|
||||||
|
|
||||||
@@ -484,11 +469,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={footerRef}
|
ref={footerRef}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
showControls
|
styles.footer,
|
||||||
? styles.footer
|
!showControls &&
|
||||||
: hideHeader
|
(hideHeader ? styles.footerHidden : styles.footerThin),
|
||||||
? [styles.footer, styles.footerHidden]
|
{ [styles.overlay]: windowMode === "flat" },
|
||||||
: [styles.footer, styles.footerThin],
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!mobile && !hideHeader && (
|
{!mobile && !hideHeader && (
|
||||||
@@ -515,7 +499,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.inRoom} ref={containerRef}>
|
<div className={styles.inRoom} ref={containerRef}>
|
||||||
{!hideHeader && maximisedParticipant === null && (
|
{!hideHeader && windowMode !== "pip" && windowMode !== "flat" && (
|
||||||
<Header className={styles.header} ref={headerRef}>
|
<Header className={styles.header} ref={headerRef}>
|
||||||
<LeftNav>
|
<LeftNav>
|
||||||
<RoomHeaderInfo
|
<RoomHeaderInfo
|
||||||
|
|||||||
@@ -18,20 +18,12 @@ limitations under the License.
|
|||||||
margin-inline: var(--inline-content-inset);
|
margin-inline: var(--inline-content-inset);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
block-size: 50vh;
|
block-size: 50vh;
|
||||||
}
|
border-radius: var(--cpd-space-4x);
|
||||||
|
|
||||||
.preview.content {
|
|
||||||
margin-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
block-size: 100%;
|
|
||||||
inline-size: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content video {
|
.preview > video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -69,12 +61,20 @@ limitations under the License.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview.content .buttonBar {
|
|
||||||
padding-inline: var(--inline-content-inset);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-aspect-ratio: 1 / 1) {
|
@media (min-aspect-ratio: 1 / 1) {
|
||||||
.preview video {
|
.preview > video {
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 550px) {
|
||||||
|
.preview {
|
||||||
|
margin-inline: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonBar {
|
||||||
|
padding-inline: var(--inline-content-inset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,13 +21,11 @@ import { usePreviewTracks } from "@livekit/components-react";
|
|||||||
import { LocalVideoTrack, Track } from "livekit-client";
|
import { LocalVideoTrack, Track } from "livekit-client";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { Glass } from "@vector-im/compound-web";
|
|
||||||
|
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
import styles from "./VideoPreview.module.css";
|
import styles from "./VideoPreview.module.css";
|
||||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||||
import { MuteStates } from "./MuteStates";
|
import { MuteStates } from "./MuteStates";
|
||||||
import { useMediaQuery } from "../useMediaQuery";
|
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
|
||||||
@@ -116,8 +114,8 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
}, [videoTrack]);
|
}, [videoTrack]);
|
||||||
|
|
||||||
const content = (
|
return (
|
||||||
<>
|
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||||
<video
|
<video
|
||||||
ref={videoEl}
|
ref={videoEl}
|
||||||
muted
|
muted
|
||||||
@@ -137,21 +135,6 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.buttonBar}>{children}</div>
|
<div className={styles.buttonBar}>{children}</div>
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMediaQuery("(max-width: 550px)") ? (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.preview, styles.content)}
|
|
||||||
ref={previewRef}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Glass className={styles.preview}>
|
|
||||||
<div className={styles.content} ref={previewRef}>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
</Glass>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ import {
|
|||||||
audit,
|
audit,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
concat,
|
concat,
|
||||||
concatMap,
|
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
mergeAll,
|
mergeAll,
|
||||||
@@ -44,11 +44,11 @@ import {
|
|||||||
sample,
|
sample,
|
||||||
scan,
|
scan,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
|
skip,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
throttleTime,
|
throttleTime,
|
||||||
timer,
|
timer,
|
||||||
withLatestFrom,
|
|
||||||
zip,
|
zip,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
UserMediaViewModel,
|
UserMediaViewModel,
|
||||||
} from "./MediaViewModel";
|
} from "./MediaViewModel";
|
||||||
import { finalizeValue } from "../observable-utils";
|
import { accumulate, finalizeValue } from "../observable-utils";
|
||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
import { duplicateTiles } from "../settings/settings";
|
import { duplicateTiles } from "../settings/settings";
|
||||||
|
|
||||||
@@ -88,25 +88,30 @@ export interface GridLayout {
|
|||||||
grid: UserMediaViewModel[];
|
grid: UserMediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpotlightLayout {
|
export interface SpotlightLandscapeLayout {
|
||||||
type: "spotlight";
|
type: "spotlight-landscape";
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
grid: UserMediaViewModel[];
|
grid: UserMediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OneOnOneLayout {
|
export interface SpotlightPortraitLayout {
|
||||||
type: "one-on-one";
|
type: "spotlight-portrait";
|
||||||
spotlight?: ScreenShareViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
local: LocalUserMediaViewModel;
|
grid: UserMediaViewModel[];
|
||||||
remote: RemoteUserMediaViewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FullScreenLayout {
|
export interface SpotlightExpandedLayout {
|
||||||
type: "full screen";
|
type: "spotlight-expanded";
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
pip?: UserMediaViewModel;
|
pip?: UserMediaViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OneOnOneLayout {
|
||||||
|
type: "one-on-one";
|
||||||
|
local: LocalUserMediaViewModel;
|
||||||
|
remote: RemoteUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipLayout {
|
export interface PipLayout {
|
||||||
type: "pip";
|
type: "pip";
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
@@ -118,14 +123,15 @@ export interface PipLayout {
|
|||||||
*/
|
*/
|
||||||
export type Layout =
|
export type Layout =
|
||||||
| GridLayout
|
| GridLayout
|
||||||
| SpotlightLayout
|
| SpotlightLandscapeLayout
|
||||||
|
| SpotlightPortraitLayout
|
||||||
|
| SpotlightExpandedLayout
|
||||||
| OneOnOneLayout
|
| OneOnOneLayout
|
||||||
| FullScreenLayout
|
|
||||||
| PipLayout;
|
| PipLayout;
|
||||||
|
|
||||||
export type GridMode = "grid" | "spotlight";
|
export type GridMode = "grid" | "spotlight";
|
||||||
|
|
||||||
export type WindowMode = "normal" | "full screen" | "pip";
|
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||||
@@ -301,16 +307,13 @@ export class CallViewModel extends ViewModel {
|
|||||||
},
|
},
|
||||||
).pipe(
|
).pipe(
|
||||||
mergeAll(),
|
mergeAll(),
|
||||||
// Aggregate the hold instructions into a single list showing which
|
// Accumulate the hold instructions into a single list showing which
|
||||||
// participants are being held
|
// participants are being held
|
||||||
scan(
|
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
||||||
(holds, instruction) =>
|
"hold" in instruction
|
||||||
"hold" in instruction
|
? [instruction.hold, ...holds]
|
||||||
? [instruction.hold, ...holds]
|
: holds.filter((h) => h !== instruction.unhold),
|
||||||
: holds.filter((h) => h !== instruction.unhold),
|
|
||||||
[] as RemoteParticipant[][],
|
|
||||||
),
|
),
|
||||||
startWith([]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
||||||
@@ -395,6 +398,11 @@ export class CallViewModel extends ViewModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
|
||||||
|
this.mediaItems.pipe(
|
||||||
|
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly screenShares: Observable<ScreenShare[]> =
|
private readonly screenShares: Observable<ScreenShare[]> =
|
||||||
this.mediaItems.pipe(
|
this.mediaItems.pipe(
|
||||||
map((mediaItems) =>
|
map((mediaItems) =>
|
||||||
@@ -409,7 +417,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlightSpeaker: Observable<UserMedia | null> =
|
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
|
||||||
this.userMedia.pipe(
|
this.userMedia.pipe(
|
||||||
switchMap((mediaItems) =>
|
switchMap((mediaItems) =>
|
||||||
mediaItems.length === 0
|
mediaItems.length === 0
|
||||||
@@ -420,7 +428,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
|
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
|
||||||
(prev, mediaItems) =>
|
(prev, mediaItems) =>
|
||||||
// Decide who to spotlight:
|
// Decide who to spotlight:
|
||||||
// If the previous speaker (not the local user) is still speaking,
|
// If the previous speaker (not the local user) is still speaking,
|
||||||
@@ -433,11 +441,11 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Otherwise, stick with the person who was last speaking
|
// Otherwise, stick with the person who was last speaking
|
||||||
prev ??
|
prev ??
|
||||||
// Otherwise, spotlight the local user
|
// Otherwise, spotlight the local user
|
||||||
mediaItems.find(([m]) => m.vm.local)?.[0] ??
|
mediaItems.find(([m]) => m.vm.local)![0],
|
||||||
null,
|
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
|
map((speaker) => speaker.vm),
|
||||||
shareReplay(1),
|
shareReplay(1),
|
||||||
throttleTime(1600, undefined, { leading: true, trailing: true }),
|
throttleTime(1600, undefined, { leading: true, trailing: true }),
|
||||||
);
|
);
|
||||||
@@ -480,38 +488,91 @@ export class CallViewModel extends ViewModel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
|
private readonly spotlightAndPip: Observable<
|
||||||
[this.screenShares, this.spotlightSpeaker],
|
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
|
||||||
(screenShares, spotlightSpeaker): MediaViewModel[] =>
|
> = this.screenShares.pipe(
|
||||||
|
map((screenShares) =>
|
||||||
screenShares.length > 0
|
screenShares.length > 0
|
||||||
? screenShares.map((m) => m.vm)
|
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
|
||||||
: spotlightSpeaker === null
|
: ([
|
||||||
? []
|
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
|
||||||
: [spotlightSpeaker.vm],
|
this.localUserMedia.pipe(
|
||||||
|
switchMap((vm) =>
|
||||||
|
vm.alwaysShow.pipe(
|
||||||
|
map((alwaysShow) => (alwaysShow ? vm : null)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] as const),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Make this react to changes in window dimensions and screen
|
private readonly spotlight: Observable<MediaViewModel[]> =
|
||||||
// orientation
|
this.spotlightAndPip.pipe(
|
||||||
private readonly windowMode = of<WindowMode>("normal");
|
switchMap(([spotlight]) => spotlight),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly pip: Observable<UserMediaViewModel | null> =
|
||||||
|
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The general shape of the window.
|
||||||
|
*/
|
||||||
|
public readonly windowMode: Observable<WindowMode> = fromEvent(
|
||||||
|
window,
|
||||||
|
"resize",
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => {
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
if (height <= 400 && width <= 340) return "pip";
|
||||||
|
if (width <= 660) return "narrow";
|
||||||
|
if (height <= 660) return "flat";
|
||||||
|
return "normal";
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly spotlightExpandedToggle = new Subject<void>();
|
||||||
|
public readonly spotlightExpanded: Observable<boolean> =
|
||||||
|
this.spotlightExpandedToggle.pipe(
|
||||||
|
accumulate(false, (expanded) => !expanded),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
public toggleSpotlightExpanded(): void {
|
||||||
|
this.spotlightExpandedToggle.next();
|
||||||
|
}
|
||||||
|
|
||||||
private readonly gridModeUserSelection = new Subject<GridMode>();
|
private readonly gridModeUserSelection = new Subject<GridMode>();
|
||||||
/**
|
/**
|
||||||
* The layout mode of the media tile grid.
|
* The layout mode of the media tile grid.
|
||||||
*/
|
*/
|
||||||
public readonly gridMode: Observable<GridMode> = merge(
|
public readonly gridMode: Observable<GridMode> =
|
||||||
// Always honor a manual user selection
|
|
||||||
this.gridModeUserSelection,
|
|
||||||
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
||||||
// automatically switch to spotlight mode and reset when screen sharing ends
|
// automatically switch to spotlight mode and reset when screen sharing ends
|
||||||
this.hasRemoteScreenShares.pipe(
|
this.gridModeUserSelection.pipe(
|
||||||
withLatestFrom(this.gridModeUserSelection.pipe(startWith(null))),
|
startWith(null),
|
||||||
concatMap(([hasScreenShares, userSelection]) =>
|
switchMap((userSelection) =>
|
||||||
userSelection === "spotlight"
|
(userSelection === "spotlight"
|
||||||
? EMPTY
|
? EMPTY
|
||||||
: of<GridMode>(hasScreenShares ? "spotlight" : "grid"),
|
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
|
||||||
|
skip(userSelection === null ? 0 : 1),
|
||||||
|
map(
|
||||||
|
([hasScreenShares, windowMode]): GridMode =>
|
||||||
|
hasScreenShares || windowMode === "flat"
|
||||||
|
? "spotlight"
|
||||||
|
: "grid",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).pipe(startWith(userSelection ?? "grid")),
|
||||||
),
|
),
|
||||||
),
|
distinctUntilChanged(),
|
||||||
).pipe(distinctUntilChanged(), shareReplay(1));
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
public setGridMode(value: GridMode): void {
|
public setGridMode(value: GridMode): void {
|
||||||
this.gridModeUserSelection.next(value);
|
this.gridModeUserSelection.next(value);
|
||||||
@@ -519,11 +580,24 @@ export class CallViewModel extends ViewModel {
|
|||||||
|
|
||||||
public readonly layout: Observable<Layout> = this.windowMode.pipe(
|
public readonly layout: Observable<Layout> = this.windowMode.pipe(
|
||||||
switchMap((windowMode) => {
|
switchMap((windowMode) => {
|
||||||
|
const spotlightLandscapeLayout = combineLatest(
|
||||||
|
[this.grid, this.spotlight],
|
||||||
|
(grid, spotlight): Layout => ({
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
spotlight,
|
||||||
|
grid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spotlightExpandedLayout = combineLatest(
|
||||||
|
[this.spotlight, this.pip],
|
||||||
|
(spotlight, pip): Layout => ({
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
spotlight,
|
||||||
|
pip: pip ?? undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
switch (windowMode) {
|
switch (windowMode) {
|
||||||
case "full screen":
|
|
||||||
throw new Error("unimplemented");
|
|
||||||
case "pip":
|
|
||||||
throw new Error("unimplemented");
|
|
||||||
case "normal":
|
case "normal":
|
||||||
return this.gridMode.pipe(
|
return this.gridMode.pipe(
|
||||||
switchMap((gridMode) => {
|
switchMap((gridMode) => {
|
||||||
@@ -532,11 +606,9 @@ export class CallViewModel extends ViewModel {
|
|||||||
return combineLatest(
|
return combineLatest(
|
||||||
[this.grid, this.spotlight, this.screenShares],
|
[this.grid, this.spotlight, this.screenShares],
|
||||||
(grid, spotlight, screenShares): Layout =>
|
(grid, spotlight, screenShares): Layout =>
|
||||||
grid.length == 2
|
grid.length == 2 && screenShares.length === 0
|
||||||
? {
|
? {
|
||||||
type: "one-on-one",
|
type: "one-on-one",
|
||||||
spotlight:
|
|
||||||
screenShares.length > 0 ? spotlight : undefined,
|
|
||||||
local: grid.find(
|
local: grid.find(
|
||||||
(vm) => vm.local,
|
(vm) => vm.local,
|
||||||
) as LocalUserMediaViewModel,
|
) as LocalUserMediaViewModel,
|
||||||
@@ -555,22 +627,59 @@ export class CallViewModel extends ViewModel {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
case "spotlight":
|
case "spotlight":
|
||||||
return combineLatest(
|
return this.spotlightExpanded.pipe(
|
||||||
[this.grid, this.spotlight],
|
switchMap((expanded) =>
|
||||||
(grid, spotlight): Layout => ({
|
expanded
|
||||||
type: "spotlight",
|
? spotlightExpandedLayout
|
||||||
spotlight,
|
: spotlightLandscapeLayout,
|
||||||
grid,
|
),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
case "narrow":
|
||||||
|
return combineLatest(
|
||||||
|
[this.grid, this.spotlight],
|
||||||
|
(grid, spotlight): Layout => ({
|
||||||
|
type: "spotlight-portrait",
|
||||||
|
spotlight,
|
||||||
|
grid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
case "flat":
|
||||||
|
return this.gridMode.pipe(
|
||||||
|
switchMap((gridMode) => {
|
||||||
|
switch (gridMode) {
|
||||||
|
case "grid":
|
||||||
|
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||||
|
// this window mode.
|
||||||
|
return spotlightLandscapeLayout;
|
||||||
|
case "spotlight":
|
||||||
|
return spotlightExpandedLayout;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
case "pip":
|
||||||
|
return this.spotlight.pipe(
|
||||||
|
map((spotlight): Layout => ({ type: "pip", spotlight })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
shareReplay(1),
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
|
||||||
|
map((l) => l.type !== "grid"),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
|
||||||
|
map((l) => l.type !== "one-on-one" && l.type !== "spotlight-expanded"),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||||
private readonly matrixRoom: MatrixRoom,
|
private readonly matrixRoom: MatrixRoom,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ limitations under the License.
|
|||||||
|
|
||||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||||
borders don't support gradients */
|
borders don't support gradients */
|
||||||
.tile[data-maximised="false"]::before {
|
.tile::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: -1; /* Put it below the outline */
|
z-index: -1; /* Put it below the outline */
|
||||||
@@ -43,27 +43,22 @@ borders don't support gradients */
|
|||||||
background-blend-mode: overlay, normal;
|
background-blend-mode: overlay, normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile[data-maximised="false"].speaking {
|
.tile.speaking {
|
||||||
/* !important because speaking border should take priority over hover */
|
/* !important because speaking border should take priority over hover */
|
||||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
|
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile[data-maximised="false"].speaking::before {
|
.tile.speaking::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.tile[data-maximised="false"]:hover {
|
.tile:hover {
|
||||||
outline: var(--cpd-border-width-2) solid
|
outline: var(--cpd-border-width-2) solid
|
||||||
var(--cpd-color-border-interactive-hovered);
|
var(--cpd-color-border-interactive-hovered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile[data-maximised="true"] {
|
|
||||||
--media-view-border-radius: 0;
|
|
||||||
--media-view-fg-inset: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muteIcon[data-muted="true"] {
|
.muteIcon[data-muted="true"] {
|
||||||
color: var(--cpd-color-icon-secondary);
|
color: var(--cpd-color-icon-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ interface TileProps {
|
|||||||
style?: ComponentProps<typeof animated.div>["style"];
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
maximised: boolean;
|
|
||||||
displayName: string;
|
displayName: string;
|
||||||
nameTag: string;
|
nameTag: string;
|
||||||
showSpeakingIndicators: boolean;
|
showSpeakingIndicators: boolean;
|
||||||
@@ -79,7 +78,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
menuEnd,
|
menuEnd,
|
||||||
className,
|
className,
|
||||||
nameTag,
|
nameTag,
|
||||||
maximised,
|
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -151,7 +149,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
{menu}
|
{menu}
|
||||||
</Menu>
|
</Menu>
|
||||||
}
|
}
|
||||||
data-maximised={maximised}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -273,9 +270,6 @@ RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
|||||||
|
|
||||||
interface GridTileProps {
|
interface GridTileProps {
|
||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
maximised: boolean;
|
|
||||||
fullscreen: boolean;
|
|
||||||
onToggleFullscreen: (itemId: string) => void;
|
|
||||||
onOpenProfile: () => void;
|
onOpenProfile: () => void;
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
@@ -285,7 +279,7 @@ interface GridTileProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||||
({ vm, fullscreen, onToggleFullscreen, onOpenProfile, ...props }, ref) => {
|
({ vm, onOpenProfile, ...props }, ref) => {
|
||||||
const nameData = useNameData(vm);
|
const nameData = useNameData(vm);
|
||||||
|
|
||||||
if (vm instanceof LocalUserMediaViewModel) {
|
if (vm instanceof LocalUserMediaViewModel) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
grid-template-areas: ". button2" "nameTag button1";
|
grid-template-areas: ". ." "nameTag button";
|
||||||
gap: var(--cpd-space-1x);
|
gap: var(--cpd-space-1x);
|
||||||
place-items: start;
|
place-items: start;
|
||||||
}
|
}
|
||||||
@@ -175,9 +175,5 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fg > button:first-of-type {
|
.fg > button:first-of-type {
|
||||||
grid-area: button1;
|
grid-area: button;
|
||||||
}
|
|
||||||
|
|
||||||
.fg > button:nth-of-type(2) {
|
|
||||||
grid-area: button2;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
nameTag: string;
|
nameTag: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
secondaryButton?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||||
@@ -62,7 +61,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
nameTag,
|
nameTag,
|
||||||
displayName,
|
displayName,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
secondaryButton,
|
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -120,7 +118,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryButton}
|
{primaryButton}
|
||||||
{secondaryButton}
|
|
||||||
</div>
|
</div>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,28 +15,11 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.tile {
|
.tile {
|
||||||
--border-width: var(--cpd-space-3x);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.maximised {
|
|
||||||
--border-width: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border {
|
|
||||||
box-sizing: border-box;
|
|
||||||
block-size: 100%;
|
|
||||||
inline-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile.maximised .border {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contents {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
border-radius: var(--cpd-space-6x);
|
border-radius: var(--cpd-space-6x);
|
||||||
contain: strict;
|
contain: strict;
|
||||||
overflow: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
scroll-snap-type: inline mandatory;
|
scroll-snap-type: inline mandatory;
|
||||||
scroll-snap-stop: always;
|
scroll-snap-stop: always;
|
||||||
@@ -46,18 +29,18 @@ limitations under the License.
|
|||||||
scroll-behavior: smooth; */
|
scroll-behavior: smooth; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile.maximised .contents {
|
.tile.maximised {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contents > .item {
|
.item {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
--media-view-fg-inset: 10px;
|
--media-view-fg-inset: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contents > .item.snap {
|
.item.snap {
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +88,7 @@ limitations under the License.
|
|||||||
inset-inline-end: var(--cpd-space-1x);
|
inset-inline-end: var(--cpd-space-1x);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullScreen {
|
.expand {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -118,23 +101,23 @@ limitations under the License.
|
|||||||
transition-property: opacity, background-color;
|
transition-property: opacity, background-color;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
--inset: calc(var(--border-width) + 6px);
|
--inset: 6px;
|
||||||
inset-block-end: var(--inset);
|
inset-block-end: var(--inset);
|
||||||
inset-inline-end: var(--inset);
|
inset-inline-end: var(--inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullScreen > svg {
|
.expand > svg {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--cpd-color-icon-on-solid-primary);
|
color: var(--cpd-color-icon-on-solid-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover) {
|
@media (hover) {
|
||||||
.fullScreen:hover {
|
.expand:hover {
|
||||||
background: var(--cpd-color-bg-action-primary-hovered);
|
background: var(--cpd-color-bg-action-primary-hovered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullScreen:active {
|
.expand:active {
|
||||||
background: var(--cpd-color-bg-action-primary-pressed);
|
background: var(--cpd-color-bg-action-primary-pressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Glass } from "@vector-im/compound-web";
|
|
||||||
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
|
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
|
||||||
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
|
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
|
||||||
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
|
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
|
||||||
@@ -174,8 +173,8 @@ SpotlightItem.displayName = "SpotlightItem";
|
|||||||
interface Props {
|
interface Props {
|
||||||
vms: MediaViewModel[];
|
vms: MediaViewModel[];
|
||||||
maximised: boolean;
|
maximised: boolean;
|
||||||
fullscreen: boolean;
|
expanded: boolean;
|
||||||
onToggleFullscreen: () => void;
|
onToggleExpanded: (() => void) | null;
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
@@ -188,8 +187,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
{
|
{
|
||||||
vms,
|
vms,
|
||||||
maximised,
|
maximised,
|
||||||
fullscreen,
|
expanded,
|
||||||
onToggleFullscreen,
|
onToggleExpanded,
|
||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
@@ -254,9 +253,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
setScrollToId(vms[visibleIndex + 1].id);
|
setScrollToId(vms[visibleIndex + 1].id);
|
||||||
}, [latestVisibleId, latestVms, setScrollToId]);
|
}, [latestVisibleId, latestVms, setScrollToId]);
|
||||||
|
|
||||||
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
|
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||||
|
|
||||||
// We need a wrapper element because Glass doesn't provide an animated.div
|
|
||||||
return (
|
return (
|
||||||
<animated.div
|
<animated.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -274,33 +272,29 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Glass className={styles.border}>
|
{vms.map((vm) => (
|
||||||
{/* Similarly we need a wrapper element here because Glass expects a
|
<SpotlightItem
|
||||||
single child */}
|
key={vm.id}
|
||||||
<div className={styles.contents}>
|
vm={vm}
|
||||||
{vms.map((vm) => (
|
targetWidth={targetWidth}
|
||||||
<SpotlightItem
|
targetHeight={targetHeight}
|
||||||
key={vm.id}
|
intersectionObserver={intersectionObserver}
|
||||||
vm={vm}
|
snap={scrollToId === null || scrollToId === vm.id}
|
||||||
targetWidth={targetWidth}
|
/>
|
||||||
targetHeight={targetHeight}
|
))}
|
||||||
intersectionObserver={intersectionObserver}
|
{onToggleExpanded && (
|
||||||
snap={scrollToId === null || scrollToId === vm.id}
|
<button
|
||||||
/>
|
className={classNames(styles.expand)}
|
||||||
))}
|
aria-label={
|
||||||
</div>
|
expanded
|
||||||
</Glass>
|
? t("video_tile.full_screen")
|
||||||
<button
|
: t("video_tile.exit_full_screen")
|
||||||
className={classNames(styles.fullScreen)}
|
}
|
||||||
aria-label={
|
onClick={onToggleExpanded}
|
||||||
fullscreen
|
>
|
||||||
? t("video_tile.full_screen")
|
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||||
: t("video_tile.exit_full_screen")
|
</button>
|
||||||
}
|
)}
|
||||||
onClick={onToggleFullscreen}
|
|
||||||
>
|
|
||||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
|
||||||
</button>
|
|
||||||
{canGoToNext && (
|
{canGoToNext && (
|
||||||
<button
|
<button
|
||||||
className={classNames(styles.advance, styles.next)}
|
className={classNames(styles.advance, styles.next)}
|
||||||
@@ -310,15 +304,17 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div
|
{!expanded && (
|
||||||
className={classNames(styles.indicators, {
|
<div
|
||||||
[styles.show]: showIndicators && vms.length > 1,
|
className={classNames(styles.indicators, {
|
||||||
})}
|
[styles.show]: showIndicators && vms.length > 1,
|
||||||
>
|
})}
|
||||||
{vms.map((vm) => (
|
>
|
||||||
<div className={styles.item} data-visible={vm.id === visibleId} />
|
{vms.map((vm) => (
|
||||||
))}
|
<div className={styles.item} data-visible={vm.id === visibleId} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</animated.div>
|
</animated.div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user