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;
|
||||
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.
|
||||
*/
|
||||
@@ -122,7 +126,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
|
||||
@@ -133,7 +137,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"] {
|
||||
|
||||
@@ -36,10 +36,16 @@ interface GridCSSProperties extends CSSProperties {
|
||||
"--height": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of the "grid" layout, in which all participants are shown
|
||||
* together in a scrolling grid.
|
||||
*/
|
||||
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;
|
||||
@@ -43,7 +42,6 @@ limitations under the License.
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
inset: -12px;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="start"] {
|
||||
|
||||
@@ -19,63 +19,23 @@ 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";
|
||||
|
||||
/**
|
||||
* 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> = ({
|
||||
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;
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
/**
|
||||
* An implementation of the "spotlight portrait" layout, in which the spotlight
|
||||
* 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 {
|
||||
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 +63,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 +94,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";
|
||||
import { duplicateTiles } from "../settings/settings";
|
||||
|
||||
@@ -88,25 +88,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[];
|
||||
@@ -118,14 +123,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.
|
||||
@@ -301,16 +307,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[]> =
|
||||
@@ -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[]> =
|
||||
this.mediaItems.pipe(
|
||||
map((mediaItems) =>
|
||||
@@ -409,7 +417,7 @@ export class CallViewModel extends ViewModel {
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
private readonly spotlightSpeaker: Observable<UserMedia | null> =
|
||||
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
|
||||
this.userMedia.pipe(
|
||||
switchMap((mediaItems) =>
|
||||
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) =>
|
||||
// Decide who to spotlight:
|
||||
// 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
|
||||
prev ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.vm.local)?.[0] ??
|
||||
null,
|
||||
mediaItems.find(([m]) => m.vm.local)![0],
|
||||
null,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
map((speaker) => speaker.vm),
|
||||
shareReplay(1),
|
||||
throttleTime(1600, undefined, { leading: true, trailing: true }),
|
||||
);
|
||||
@@ -480,38 +488,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);
|
||||
@@ -519,11 +580,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) => {
|
||||
@@ -532,11 +606,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,
|
||||
@@ -555,22 +627,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