Implement most of the remaining layout changes
Includes the mobile UX optimizations and the tweaks we've made to cut down on wasted space, but does not yet include the change to embed the spotlight tile within the grid.
This commit is contained in:
@@ -22,7 +22,6 @@ limitations under the License.
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-block-end: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface SpotlightTileModel {
|
||||
export type TileModel = GridTileModel | SpotlightTileModel;
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -121,7 +125,7 @@ export function arrangeTiles(
|
||||
);
|
||||
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;
|
||||
|
||||
// Impose a minimum width and height on the tiles
|
||||
@@ -132,7 +136,7 @@ export function arrangeTiles(
|
||||
// c = (W + g) / (w + g).
|
||||
columns = Math.floor((width + gap) / (tileMinWidth + gap));
|
||||
rows = Math.ceil(tileCount / columns);
|
||||
tileWidth = (width - (columns - 1) * gap) / columns;
|
||||
tileWidth = (width - (columns + 1) * gap) / columns;
|
||||
tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||
}
|
||||
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;
|
||||
|
||||
@@ -16,7 +16,6 @@ limitations under the License.
|
||||
|
||||
.fixed,
|
||||
.scrolling {
|
||||
margin-inline: var(--inline-content-inset);
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ limitations under the License.
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
inset: -12px;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.fixed > .slot[data-block-alignment="start"] {
|
||||
|
||||
@@ -40,6 +40,8 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
minBounds,
|
||||
spotlightAlignment,
|
||||
}) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||
// lives
|
||||
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
|
||||
|
||||
@@ -15,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.layer {
|
||||
margin-inline: var(--inline-content-inset);
|
||||
block-size: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -36,7 +35,6 @@ limitations under the License.
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
inset: -12px;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="start"] {
|
||||
|
||||
@@ -19,63 +19,19 @@ import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
||||
import {
|
||||
CallLayout,
|
||||
GridTileModel,
|
||||
SpotlightTileModel,
|
||||
arrangeTiles,
|
||||
} from "./CallLayout";
|
||||
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import styles from "./OneOnOneLayout.module.css";
|
||||
import { DragCallback } from "./Grid";
|
||||
|
||||
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
minBounds,
|
||||
spotlightAlignment,
|
||||
pipAlignment,
|
||||
}) => ({
|
||||
fixed: forwardRef(function OneOnOneLayoutFixed({ model, Slot }, ref) {
|
||||
const { width, height } = useObservableEagerState(minBounds);
|
||||
const spotlightAlignmentValue = useObservableEagerState(spotlightAlignment);
|
||||
scrollingOnTop: false,
|
||||
|
||||
const [generation] = useReactiveState<number>(
|
||||
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||
[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>
|
||||
);
|
||||
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
|
||||
return <div ref={ref} data-generation={0} />;
|
||||
}),
|
||||
|
||||
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;
|
||||
}
|
||||
99
src/grid/SpotlightExpandedLayout.tsx
Normal file
99
src/grid/SpotlightExpandedLayout.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
93
src/grid/SpotlightLandscapeLayout.tsx
Normal file
93
src/grid/SpotlightLandscapeLayout.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
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";
|
||||
|
||||
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,39 @@ import { CSSProperties, forwardRef, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
|
||||
import { SpotlightLayout as SpotlightLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightLayout.module.css";
|
||||
import {
|
||||
CallLayout,
|
||||
GridTileModel,
|
||||
TileModel,
|
||||
arrangeTiles,
|
||||
} from "./CallLayout";
|
||||
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightPortraitLayout.module.css";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--grid-columns": number;
|
||||
"--grid-gap": string;
|
||||
"--grid-tile-width": string;
|
||||
"--grid-tile-height": string;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
orientation: "portrait" | "landscape";
|
||||
gridColumns: number;
|
||||
}
|
||||
export const makeSpotlightPortraitLayout: CallLayout<
|
||||
SpotlightPortraitLayoutModel
|
||||
> = ({ minBounds }) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
function getLayout(gridLength: number, width: number): Layout {
|
||||
const orientation = width < 800 ? "portrait" : "landscape";
|
||||
return {
|
||||
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) {
|
||||
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
const { width, height } = useObservableEagerState(minBounds);
|
||||
const layout = getLayout(model.grid.length, width);
|
||||
const tileModel: TileModel = useMemo(
|
||||
() => ({
|
||||
type: "spotlight",
|
||||
vms: model.spotlight,
|
||||
maximised: layout.orientation === "portrait",
|
||||
maximised: true,
|
||||
}),
|
||||
[model.spotlight, layout.orientation],
|
||||
[model.spotlight],
|
||||
);
|
||||
const [generation] = useReactiveState<number>(
|
||||
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||
@@ -65,27 +58,24 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-generation={generation}
|
||||
data-orientation={layout.orientation}
|
||||
className={styles.layer}
|
||||
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
|
||||
>
|
||||
<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 SpotlightLayoutScrolling(
|
||||
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
|
||||
{ model, Slot },
|
||||
ref,
|
||||
) {
|
||||
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(
|
||||
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||
[model.grid],
|
||||
@@ -99,9 +89,14 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
|
||||
<div
|
||||
ref={ref}
|
||||
data-generation={generation}
|
||||
data-orientation={layout.orientation}
|
||||
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
|
||||
className={classNames(styles.spotlight, {
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Observable, defer, finalize, tap } from "rxjs";
|
||||
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
|
||||
|
||||
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;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding-block: var(--cpd-space-4x);
|
||||
margin-inline: var(--inline-content-inset);
|
||||
padding-inline: var(--inline-content-inset);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
@@ -123,17 +123,16 @@ limitations under the License.
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer.overlay {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
.fixedGrid {
|
||||
position: absolute;
|
||||
inline-size: 100%;
|
||||
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 {
|
||||
@@ -143,6 +142,18 @@ limitations under the License.
|
||||
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 {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import useMeasure from "react-use-measure";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
@@ -59,7 +59,6 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { useLiveKit } from "../livekit/useLiveKit";
|
||||
import { useFullscreen } from "./useFullscreen";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
@@ -76,14 +75,16 @@ import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
|
||||
import {
|
||||
CallLayout,
|
||||
CallLayoutOutputs,
|
||||
TileModel,
|
||||
defaultPipAlignment,
|
||||
defaultSpotlightAlignment,
|
||||
} from "../grid/CallLayout";
|
||||
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 ?? {});
|
||||
|
||||
@@ -194,24 +195,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
||||
connState,
|
||||
);
|
||||
const windowMode = useObservableEagerState(vm.windowMode);
|
||||
const layout = useObservableEagerState(vm.layout);
|
||||
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 [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
@@ -235,14 +221,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
const gridBounds = useMemo(
|
||||
() => ({
|
||||
width: footerBounds.width,
|
||||
height: bounds.height - headerBounds.height - footerBounds.height,
|
||||
width: bounds.width,
|
||||
height:
|
||||
bounds.height -
|
||||
headerBounds.height -
|
||||
(windowMode === "flat" ? 0 : footerBounds.height),
|
||||
}),
|
||||
[
|
||||
footerBounds.width,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
headerBounds.height,
|
||||
footerBounds.height,
|
||||
windowMode,
|
||||
],
|
||||
);
|
||||
const gridBoundsObservable = useObservable(gridBounds);
|
||||
@@ -254,29 +244,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => 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(
|
||||
(mode: GridMode) => vm.setGridMode(mode),
|
||||
[vm],
|
||||
@@ -318,10 +285,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}
|
||||
}, [setGridMode]);
|
||||
|
||||
const showSpotlightIndicators = useObservable(layout.type === "spotlight");
|
||||
const showSpeakingIndicators = useObservable(
|
||||
layout.type === "spotlight" ||
|
||||
(layout.type === "grid" && layout.grid.length > 2),
|
||||
const toggleSpotlightExpanded = useCallback(
|
||||
() => vm.toggleSpotlightExpanded(),
|
||||
[vm],
|
||||
);
|
||||
|
||||
const Tile = useMemo(
|
||||
@@ -333,20 +299,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{ className, style, targetWidth, targetHeight, model },
|
||||
ref,
|
||||
) {
|
||||
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
|
||||
const showSpeakingIndicatorsValue = useObservableEagerState(
|
||||
showSpeakingIndicators,
|
||||
vm.showSpeakingIndicators,
|
||||
);
|
||||
const showSpotlightIndicatorsValue = useObservableEagerState(
|
||||
showSpotlightIndicators,
|
||||
vm.showSpotlightIndicators,
|
||||
);
|
||||
|
||||
return model.type === "grid" ? (
|
||||
<GridTile
|
||||
ref={ref}
|
||||
vm={model.vm}
|
||||
maximised={false}
|
||||
fullscreen={false}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onOpenProfile={openProfile}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
@@ -359,8 +323,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
ref={ref}
|
||||
vms={model.vms}
|
||||
maximised={model.maximised}
|
||||
fullscreen={false}
|
||||
onToggleFullscreen={toggleSpotlightFullscreen}
|
||||
expanded={spotlightExpanded}
|
||||
onToggleExpanded={toggleSpotlightExpanded}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
showIndicators={showSpotlightIndicatorsValue}
|
||||
@@ -369,52 +333,74 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[
|
||||
toggleFullscreen,
|
||||
toggleSpotlightFullscreen,
|
||||
openProfile,
|
||||
showSpeakingIndicators,
|
||||
showSpotlightIndicators,
|
||||
],
|
||||
[vm, toggleSpotlightExpanded, openProfile],
|
||||
);
|
||||
|
||||
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 => {
|
||||
if (maximisedParticipant !== null) {
|
||||
const fullscreen = maximisedParticipant === fullscreenItem;
|
||||
if (maximisedParticipant === "spotlight") {
|
||||
return (
|
||||
<SpotlightTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
vms={layout.spotlight!}
|
||||
maximised
|
||||
fullscreen={fullscreen}
|
||||
onToggleFullscreen={toggleSpotlightFullscreen}
|
||||
targetWidth={gridBounds.height}
|
||||
targetHeight={gridBounds.width}
|
||||
showIndicators={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (layout.type === "pip") {
|
||||
return (
|
||||
<SpotlightTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
vms={layout.spotlight!}
|
||||
maximised
|
||||
expanded
|
||||
onToggleExpanded={null}
|
||||
targetWidth={gridBounds.height}
|
||||
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
|
||||
className={styles.scrollingGrid}
|
||||
model={layout}
|
||||
Layout={layoutSystem.scrolling}
|
||||
Tile={Tile}
|
||||
/>
|
||||
<Grid
|
||||
className={styles.fixedGrid}
|
||||
style={{
|
||||
insetBlockStart: headerBounds.bottom,
|
||||
height: gridBounds.height,
|
||||
}}
|
||||
model={layout}
|
||||
Layout={layoutSystem.fixed}
|
||||
Tile={Tile}
|
||||
/>
|
||||
{fixedGrid}
|
||||
{scrollingGrid}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{scrollingGrid}
|
||||
{fixedGrid}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -424,14 +410,13 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
exitFullscreen();
|
||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
||||
audio: true,
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "include",
|
||||
systemAudio: "include",
|
||||
});
|
||||
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
|
||||
}, [localParticipant, isScreenShareEnabled]);
|
||||
|
||||
let footer: JSX.Element | null;
|
||||
|
||||
@@ -484,11 +469,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<div
|
||||
ref={footerRef}
|
||||
className={classNames(
|
||||
showControls
|
||||
? styles.footer
|
||||
: hideHeader
|
||||
? [styles.footer, styles.footerHidden]
|
||||
: [styles.footer, styles.footerThin],
|
||||
styles.footer,
|
||||
!showControls &&
|
||||
(hideHeader ? styles.footerHidden : styles.footerThin),
|
||||
{ [styles.overlay]: windowMode === "flat" },
|
||||
)}
|
||||
>
|
||||
{!mobile && !hideHeader && (
|
||||
@@ -515,7 +499,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
return (
|
||||
<div className={styles.inRoom} ref={containerRef}>
|
||||
{!hideHeader && maximisedParticipant === null && (
|
||||
{!hideHeader && windowMode !== "pip" && windowMode !== "flat" && (
|
||||
<Header className={styles.header} ref={headerRef}>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
|
||||
@@ -18,20 +18,12 @@ limitations under the License.
|
||||
margin-inline: var(--inline-content-inset);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
}
|
||||
|
||||
.preview.content {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
border-radius: var(--cpd-space-4x);
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content video {
|
||||
.preview > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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) {
|
||||
.preview video {
|
||||
.preview > video {
|
||||
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 classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Glass } from "@vector-im/compound-web";
|
||||
|
||||
import { Avatar } from "../Avatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
@@ -116,8 +114,8 @@ export const VideoPreview: FC<Props> = ({
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
ref={videoEl}
|
||||
muted
|
||||
@@ -137,21 +135,6 @@ export const VideoPreview: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.buttonBar}>{children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return useMediaQuery("(max-width: 550px)") ? (
|
||||
<div
|
||||
className={classNames(styles.preview, styles.content)}
|
||||
ref={previewRef}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
<Glass className={styles.preview}>
|
||||
<div className={styles.content} ref={previewRef}>
|
||||
{content}
|
||||
</div>
|
||||
</Glass>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
audit,
|
||||
combineLatest,
|
||||
concat,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
mergeAll,
|
||||
@@ -44,11 +44,11 @@ import {
|
||||
sample,
|
||||
scan,
|
||||
shareReplay,
|
||||
skip,
|
||||
startWith,
|
||||
switchMap,
|
||||
throttleTime,
|
||||
timer,
|
||||
withLatestFrom,
|
||||
zip,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
ScreenShareViewModel,
|
||||
UserMediaViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { finalizeValue } from "../observable-utils";
|
||||
import { accumulate, finalizeValue } from "../observable-utils";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
@@ -80,25 +80,30 @@ export interface GridLayout {
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightLayout {
|
||||
type: "spotlight";
|
||||
export interface SpotlightLandscapeLayout {
|
||||
type: "spotlight landscape";
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface OneOnOneLayout {
|
||||
type: "one-on-one";
|
||||
spotlight?: ScreenShareViewModel[];
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: RemoteUserMediaViewModel;
|
||||
export interface SpotlightPortraitLayout {
|
||||
type: "spotlight portrait";
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface FullScreenLayout {
|
||||
type: "full screen";
|
||||
export interface SpotlightExpandedLayout {
|
||||
type: "spotlight expanded";
|
||||
spotlight: MediaViewModel[];
|
||||
pip?: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOneLayout {
|
||||
type: "one-on-one";
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: RemoteUserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
type: "pip";
|
||||
spotlight: MediaViewModel[];
|
||||
@@ -110,14 +115,15 @@ export interface PipLayout {
|
||||
*/
|
||||
export type Layout =
|
||||
| GridLayout
|
||||
| SpotlightLayout
|
||||
| SpotlightLandscapeLayout
|
||||
| SpotlightPortraitLayout
|
||||
| SpotlightExpandedLayout
|
||||
| OneOnOneLayout
|
||||
| FullScreenLayout
|
||||
| PipLayout;
|
||||
|
||||
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.
|
||||
@@ -269,16 +275,13 @@ export class CallViewModel extends ViewModel {
|
||||
},
|
||||
).pipe(
|
||||
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
|
||||
scan(
|
||||
(holds, instruction) =>
|
||||
"hold" in instruction
|
||||
? [instruction.hold, ...holds]
|
||||
: holds.filter((h) => h !== instruction.unhold),
|
||||
[] as RemoteParticipant[][],
|
||||
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
||||
"hold" in instruction
|
||||
? [instruction.hold, ...holds]
|
||||
: holds.filter((h) => h !== instruction.unhold),
|
||||
),
|
||||
startWith([]),
|
||||
);
|
||||
|
||||
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
||||
@@ -352,6 +355,11 @@ export class CallViewModel extends ViewModel {
|
||||
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
|
||||
);
|
||||
|
||||
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
|
||||
this.mediaItems.pipe(
|
||||
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
|
||||
);
|
||||
|
||||
private readonly screenShares: Observable<ScreenShare[]> =
|
||||
this.mediaItems.pipe(
|
||||
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
|
||||
@@ -364,7 +372,7 @@ export class CallViewModel extends ViewModel {
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
private readonly spotlightSpeaker: Observable<UserMedia | null> =
|
||||
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
|
||||
this.userMedia.pipe(
|
||||
switchMap((ms) =>
|
||||
ms.length === 0
|
||||
@@ -373,7 +381,7 @@ export class CallViewModel extends ViewModel {
|
||||
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
|
||||
),
|
||||
),
|
||||
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
|
||||
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
|
||||
(prev, ms) =>
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker (not the local user) is still speaking,
|
||||
@@ -386,11 +394,11 @@ export class CallViewModel extends ViewModel {
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
prev ??
|
||||
// Otherwise, spotlight the local user
|
||||
ms.find(([m]) => m.vm.local)?.[0] ??
|
||||
null,
|
||||
ms.find(([m]) => m.vm.local)![0],
|
||||
null,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
map((speaker) => speaker.vm),
|
||||
shareReplay(1),
|
||||
throttleTime(1600, undefined, { leading: true, trailing: true }),
|
||||
);
|
||||
@@ -433,38 +441,91 @@ export class CallViewModel extends ViewModel {
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
|
||||
[this.screenShares, this.spotlightSpeaker],
|
||||
(screenShares, spotlightSpeaker): MediaViewModel[] =>
|
||||
private readonly spotlightAndPip: Observable<
|
||||
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
|
||||
> = this.screenShares.pipe(
|
||||
map((screenShares) =>
|
||||
screenShares.length > 0
|
||||
? screenShares.map((m) => m.vm)
|
||||
: spotlightSpeaker === null
|
||||
? []
|
||||
: [spotlightSpeaker.vm],
|
||||
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
|
||||
: ([
|
||||
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
|
||||
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
|
||||
// orientation
|
||||
private readonly windowMode = of<WindowMode>("normal");
|
||||
private readonly spotlight: Observable<MediaViewModel[]> =
|
||||
this.spotlightAndPip.pipe(
|
||||
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>();
|
||||
/**
|
||||
* The layout mode of the media tile grid.
|
||||
*/
|
||||
public readonly gridMode: Observable<GridMode> = merge(
|
||||
// Always honor a manual user selection
|
||||
this.gridModeUserSelection,
|
||||
public readonly gridMode: Observable<GridMode> =
|
||||
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
||||
// automatically switch to spotlight mode and reset when screen sharing ends
|
||||
this.hasRemoteScreenShares.pipe(
|
||||
withLatestFrom(this.gridModeUserSelection.pipe(startWith(null))),
|
||||
concatMap(([hasScreenShares, userSelection]) =>
|
||||
userSelection === "spotlight"
|
||||
this.gridModeUserSelection.pipe(
|
||||
startWith(null),
|
||||
switchMap((userSelection) =>
|
||||
(userSelection === "spotlight"
|
||||
? 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")),
|
||||
),
|
||||
),
|
||||
).pipe(distinctUntilChanged(), shareReplay(1));
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
public setGridMode(value: GridMode): void {
|
||||
this.gridModeUserSelection.next(value);
|
||||
@@ -472,11 +533,24 @@ export class CallViewModel extends ViewModel {
|
||||
|
||||
public readonly layout: Observable<Layout> = this.windowMode.pipe(
|
||||
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) {
|
||||
case "full screen":
|
||||
throw new Error("unimplemented");
|
||||
case "pip":
|
||||
throw new Error("unimplemented");
|
||||
case "normal":
|
||||
return this.gridMode.pipe(
|
||||
switchMap((gridMode) => {
|
||||
@@ -485,11 +559,9 @@ export class CallViewModel extends ViewModel {
|
||||
return combineLatest(
|
||||
[this.grid, this.spotlight, this.screenShares],
|
||||
(grid, spotlight, screenShares): Layout =>
|
||||
grid.length == 2
|
||||
grid.length == 2 && screenShares.length === 0
|
||||
? {
|
||||
type: "one-on-one",
|
||||
spotlight:
|
||||
screenShares.length > 0 ? spotlight : undefined,
|
||||
local: grid.find(
|
||||
(vm) => vm.local,
|
||||
) as LocalUserMediaViewModel,
|
||||
@@ -507,22 +579,59 @@ export class CallViewModel extends ViewModel {
|
||||
},
|
||||
);
|
||||
case "spotlight":
|
||||
return combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight): Layout => ({
|
||||
type: "spotlight",
|
||||
spotlight,
|
||||
grid,
|
||||
}),
|
||||
return this.spotlightExpanded.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? spotlightExpandedLayout
|
||||
: spotlightLandscapeLayout,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
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),
|
||||
);
|
||||
|
||||
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(
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
private readonly matrixRoom: MatrixRoom,
|
||||
|
||||
@@ -22,7 +22,7 @@ limitations under the License.
|
||||
|
||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||
borders don't support gradients */
|
||||
.tile[data-maximised="false"]::before {
|
||||
.tile::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1; /* Put it below the outline */
|
||||
@@ -43,27 +43,22 @@ borders don't support gradients */
|
||||
background-blend-mode: overlay, normal;
|
||||
}
|
||||
|
||||
.tile[data-maximised="false"].speaking {
|
||||
.tile.speaking {
|
||||
/* !important because speaking border should take priority over hover */
|
||||
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;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tile[data-maximised="false"]:hover {
|
||||
.tile:hover {
|
||||
outline: var(--cpd-border-width-2) solid
|
||||
var(--cpd-color-border-interactive-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.tile[data-maximised="true"] {
|
||||
--media-view-border-radius: 0;
|
||||
--media-view-fg-inset: 10px;
|
||||
}
|
||||
|
||||
.muteIcon[data-muted="true"] {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ interface TileProps {
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
maximised: boolean;
|
||||
displayName: string;
|
||||
nameTag: string;
|
||||
showSpeakingIndicators: boolean;
|
||||
@@ -79,7 +78,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
menuEnd,
|
||||
className,
|
||||
nameTag,
|
||||
maximised,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -151,7 +149,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
data-maximised={maximised}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -273,9 +270,6 @@ RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
||||
|
||||
interface GridTileProps {
|
||||
vm: UserMediaViewModel;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: (itemId: string) => void;
|
||||
onOpenProfile: () => void;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
@@ -285,7 +279,7 @@ interface GridTileProps {
|
||||
}
|
||||
|
||||
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||
({ vm, fullscreen, onToggleFullscreen, onOpenProfile, ...props }, ref) => {
|
||||
({ vm, onOpenProfile, ...props }, ref) => {
|
||||
const nameData = useNameData(vm);
|
||||
|
||||
if (vm instanceof LocalUserMediaViewModel) {
|
||||
|
||||
@@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas: ". button2" "nameTag button1";
|
||||
grid-template-areas: ". ." "nameTag button";
|
||||
gap: var(--cpd-space-1x);
|
||||
place-items: start;
|
||||
}
|
||||
@@ -175,9 +175,5 @@ unconditionally select the container so we can use cqmin units */
|
||||
}
|
||||
|
||||
.fg > button:first-of-type {
|
||||
grid-area: button1;
|
||||
}
|
||||
|
||||
.fg > button:nth-of-type(2) {
|
||||
grid-area: button2;
|
||||
grid-area: button;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
nameTag: string;
|
||||
displayName: string;
|
||||
primaryButton?: ReactNode;
|
||||
secondaryButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
@@ -62,7 +61,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
nameTag,
|
||||
displayName,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -120,7 +118,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
)}
|
||||
</div>
|
||||
{primaryButton}
|
||||
{secondaryButton}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
|
||||
@@ -15,28 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.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;
|
||||
border-radius: var(--cpd-space-6x);
|
||||
contain: strict;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
scroll-snap-type: inline mandatory;
|
||||
scroll-snap-stop: always;
|
||||
@@ -46,18 +29,18 @@ limitations under the License.
|
||||
scroll-behavior: smooth; */
|
||||
}
|
||||
|
||||
.tile.maximised .contents {
|
||||
.tile.maximised {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.contents > .item {
|
||||
.item {
|
||||
height: 100%;
|
||||
flex-basis: 100%;
|
||||
flex-shrink: 0;
|
||||
--media-view-fg-inset: 10px;
|
||||
}
|
||||
|
||||
.contents > .item.snap {
|
||||
.item.snap {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
@@ -105,7 +88,7 @@ limitations under the License.
|
||||
inset-inline-end: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.fullScreen {
|
||||
.expand {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
@@ -118,23 +101,23 @@ limitations under the License.
|
||||
transition-property: opacity, background-color;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
--inset: calc(var(--border-width) + 6px);
|
||||
--inset: 6px;
|
||||
inset-block-end: var(--inset);
|
||||
inset-inline-end: var(--inset);
|
||||
}
|
||||
|
||||
.fullScreen > svg {
|
||||
.expand > svg {
|
||||
display: block;
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.fullScreen:hover {
|
||||
.expand:hover {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.fullScreen:active {
|
||||
.expand:active {
|
||||
background: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Glass } from "@vector-im/compound-web";
|
||||
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 ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
|
||||
@@ -174,8 +173,8 @@ SpotlightItem.displayName = "SpotlightItem";
|
||||
interface Props {
|
||||
vms: MediaViewModel[];
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: (() => void) | null;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
showIndicators: boolean;
|
||||
@@ -188,8 +187,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
{
|
||||
vms,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showIndicators,
|
||||
@@ -254,9 +253,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
setScrollToId(vms[visibleIndex + 1].id);
|
||||
}, [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 (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
@@ -274,33 +272,29 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
<Glass className={styles.border}>
|
||||
{/* Similarly we need a wrapper element here because Glass expects a
|
||||
single child */}
|
||||
<div className={styles.contents}>
|
||||
{vms.map((vm) => (
|
||||
<SpotlightItem
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver={intersectionObserver}
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Glass>
|
||||
<button
|
||||
className={classNames(styles.fullScreen)}
|
||||
aria-label={
|
||||
fullscreen
|
||||
? t("video_tile.full_screen")
|
||||
: t("video_tile.exit_full_screen")
|
||||
}
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
{vms.map((vm) => (
|
||||
<SpotlightItem
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver={intersectionObserver}
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
/>
|
||||
))}
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded
|
||||
? t("video_tile.full_screen")
|
||||
: t("video_tile.exit_full_screen")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoToNext && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.next)}
|
||||
@@ -310,15 +304,17 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && vms.length > 1,
|
||||
})}
|
||||
>
|
||||
{vms.map((vm) => (
|
||||
<div className={styles.item} data-visible={vm.id === visibleId} />
|
||||
))}
|
||||
</div>
|
||||
{!expanded && (
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && vms.length > 1,
|
||||
})}
|
||||
>
|
||||
{vms.map((vm) => (
|
||||
<div className={styles.item} data-visible={vm.id === visibleId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user