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:
Robin
2024-07-03 15:08:30 -04:00
parent a16f235277
commit 2440037639
25 changed files with 761 additions and 497 deletions

View File

@@ -22,7 +22,6 @@ limitations under the License.
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;
padding-inline: var(--inline-content-inset); padding-inline: var(--inline-content-inset);
padding-block-end: var(--cpd-space-4x);
} }
.nav { .nav {

View File

@@ -65,6 +65,10 @@ export interface SpotlightTileModel {
export type TileModel = GridTileModel | SpotlightTileModel; export type TileModel = GridTileModel | SpotlightTileModel;
export interface CallLayoutOutputs<Model> { export interface CallLayoutOutputs<Model> {
/**
* Whether the scrolling layer of the layout should appear on top.
*/
scrollingOnTop: boolean;
/** /**
* The visually fixed (non-scrolling) layer of the layout. * The visually fixed (non-scrolling) layer of the layout.
*/ */
@@ -121,7 +125,7 @@ export function arrangeTiles(
); );
let rows = Math.ceil(tileCount / columns); let rows = Math.ceil(tileCount / columns);
let tileWidth = (width - (columns - 1) * gap) / columns; let tileWidth = (width - (columns + 1) * gap) / columns;
let tileHeight = (minHeight - (rows - 1) * gap) / rows; let tileHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum width and height on the tiles // Impose a minimum width and height on the tiles
@@ -132,7 +136,7 @@ export function arrangeTiles(
// c = (W + g) / (w + g). // c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (tileMinWidth + gap)); columns = Math.floor((width + gap) / (tileMinWidth + gap));
rows = Math.ceil(tileCount / columns); rows = Math.ceil(tileCount / columns);
tileWidth = (width - (columns - 1) * gap) / columns; tileWidth = (width - (columns + 1) * gap) / columns;
tileHeight = (minHeight - (rows - 1) * gap) / rows; tileHeight = (minHeight - (rows - 1) * gap) / rows;
} }
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight; if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;

View File

@@ -16,7 +16,6 @@ limitations under the License.
.fixed, .fixed,
.scrolling { .scrolling {
margin-inline: var(--inline-content-inset);
block-size: 100%; block-size: 100%;
} }
@@ -41,7 +40,7 @@ limitations under the License.
position: absolute; position: absolute;
inline-size: 404px; inline-size: 404px;
block-size: 233px; block-size: 233px;
inset: -12px; inset: 0;
} }
.fixed > .slot[data-block-alignment="start"] { .fixed > .slot[data-block-alignment="start"] {

View File

@@ -40,6 +40,8 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds, minBounds,
spotlightAlignment, spotlightAlignment,
}) => ({ }) => ({
scrollingOnTop: false,
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile // The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives // lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) { fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {

View File

@@ -15,7 +15,6 @@ limitations under the License.
*/ */
.layer { .layer {
margin-inline: var(--inline-content-inset);
block-size: 100%; block-size: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
@@ -36,7 +35,6 @@ limitations under the License.
position: absolute; position: absolute;
inline-size: 404px; inline-size: 404px;
block-size: 233px; block-size: 233px;
inset: -12px;
} }
.slot[data-block-alignment="start"] { .slot[data-block-alignment="start"] {

View File

@@ -19,63 +19,19 @@ import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel"; import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
import { import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
CallLayout,
GridTileModel,
SpotlightTileModel,
arrangeTiles,
} from "./CallLayout";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import styles from "./OneOnOneLayout.module.css"; import styles from "./OneOnOneLayout.module.css";
import { DragCallback } from "./Grid"; import { DragCallback } from "./Grid";
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
minBounds, minBounds,
spotlightAlignment,
pipAlignment, pipAlignment,
}) => ({ }) => ({
fixed: forwardRef(function OneOnOneLayoutFixed({ model, Slot }, ref) { scrollingOnTop: false,
const { width, height } = useObservableEagerState(minBounds);
const spotlightAlignmentValue = useObservableEagerState(spotlightAlignment);
const [generation] = useReactiveState<number>( fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
(prev) => (prev === undefined ? 0 : prev + 1), return <div ref={ref} data-generation={0} />;
[width, height, model.spotlight === undefined, spotlightAlignmentValue],
);
const spotlightTileModel: SpotlightTileModel | undefined = useMemo(
() =>
model.spotlight && {
type: "spotlight",
vms: model.spotlight,
maximised: false,
},
[model.spotlight],
);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
spotlightAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} data-generation={generation} className={styles.layer}>
{spotlightTileModel && (
<Slot
className={classNames(styles.slot, styles.spotlight)}
id="spotlight"
model={spotlightTileModel}
onDrag={onDragSpotlight}
data-block-alignment={spotlightAlignmentValue.block}
data-inline-alignment={spotlightAlignmentValue.inline}
/>
)}
</div>
);
}), }),
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {

View 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;
}

View 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>
);
}),
});

View 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;
}

View 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>
);
}),
});

View File

@@ -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;
}

View 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);
}

View File

@@ -18,46 +18,39 @@ import { CSSProperties, forwardRef, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout"; import {
import { SpotlightLayout as SpotlightLayoutModel } from "../state/CallViewModel"; CallLayout,
import styles from "./SpotlightLayout.module.css"; GridTileModel,
TileModel,
arrangeTiles,
} from "./CallLayout";
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--grid-columns": number; "--grid-gap": string;
"--grid-tile-width": string;
"--grid-tile-height": string;
} }
interface Layout { export const makeSpotlightPortraitLayout: CallLayout<
orientation: "portrait" | "landscape"; SpotlightPortraitLayoutModel
gridColumns: number; > = ({ minBounds }) => ({
} scrollingOnTop: false,
function getLayout(gridLength: number, width: number): Layout { fixed: forwardRef(function SpotlightPortraitLayoutFixed(
const orientation = width < 800 ? "portrait" : "landscape"; { model, Slot },
return { ref,
orientation, ) {
gridColumns:
orientation === "portrait"
? Math.floor(width / 190)
: gridLength > 20
? 2
: 1,
};
}
export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
minBounds,
}) => ({
fixed: forwardRef(function SpotlightLayoutFixed({ model, Slot }, ref) {
const { width, height } = useObservableEagerState(minBounds); const { width, height } = useObservableEagerState(minBounds);
const layout = getLayout(model.grid.length, width);
const tileModel: TileModel = useMemo( const tileModel: TileModel = useMemo(
() => ({ () => ({
type: "spotlight", type: "spotlight",
vms: model.spotlight, vms: model.spotlight,
maximised: layout.orientation === "portrait", maximised: true,
}), }),
[model.spotlight, layout.orientation], [model.spotlight],
); );
const [generation] = useReactiveState<number>( const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1), (prev) => (prev === undefined ? 0 : prev + 1),
@@ -65,27 +58,24 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
); );
return ( return (
<div <div ref={ref} data-generation={generation} className={styles.layer}>
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={styles.layer}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
>
<div className={styles.spotlight}> <div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} /> <Slot className={styles.slot} id="spotlight" model={tileModel} />
</div> </div>
<div className={styles.grid} />
</div> </div>
); );
}), }),
scrolling: forwardRef(function SpotlightLayoutScrolling( scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
{ model, Slot }, { model, Slot },
ref, ref,
) { ) {
const { width, height } = useObservableEagerState(minBounds); const { width, height } = useObservableEagerState(minBounds);
const layout = getLayout(model.grid.length, width); const { gap, tileWidth, tileHeight } = arrangeTiles(
width,
0,
model.grid.length,
);
const tileModels: GridTileModel[] = useMemo( const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })), () => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid], [model.grid],
@@ -99,9 +89,14 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
<div <div
ref={ref} ref={ref}
data-generation={generation} data-generation={generation}
data-orientation={layout.orientation}
className={styles.layer} className={styles.layer}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties} style={
{
"--grid-gap": `${gap}px`,
"--grid-tile-width": `${Math.floor(tileWidth)}px`,
"--grid-tile-height": `${Math.floor(tileHeight)}px`,
} as GridCSSProperties
}
> >
<div <div
className={classNames(styles.spotlight, { className={classNames(styles.spotlight, {

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Observable, defer, finalize, tap } from "rxjs"; import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
const nothing = Symbol("nothing"); const nothing = Symbol("nothing");
@@ -35,3 +35,15 @@ export function finalizeValue<T>(callback: (finalValue: T) => void) {
); );
}); });
} }
/**
* RxJS operator that accumulates a state from a source of events. This is like
* scan, except it emits an initial value immediately before any events arrive.
*/
export function accumulate<State, Event>(
initial: State,
update: (state: State, event: Event) => State,
) {
return (events: Observable<Event>): Observable<State> =>
events.pipe(scan(update, initial), startWith(initial));
}

View File

@@ -68,7 +68,7 @@ limitations under the License.
align-items: center; align-items: center;
gap: var(--cpd-space-3x); gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-4x); padding-block: var(--cpd-space-4x);
margin-inline: var(--inline-content-inset); padding-inline: var(--inline-content-inset);
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
@@ -123,17 +123,16 @@ limitations under the License.
display: none; display: none;
} }
.footer.overlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
}
.fixedGrid { .fixedGrid {
position: absolute; position: absolute;
inline-size: 100%; inline-size: 100%;
align-self: center; align-self: center;
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
}
.fixedGrid > :not(:first-child) {
pointer-events: initial;
} }
.scrollingGrid { .scrollingGrid {
@@ -143,6 +142,18 @@ limitations under the License.
align-self: center; align-self: center;
} }
.fixedGrid,
.scrollingGrid {
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
}
.fixedGrid > :not(:first-child),
.scrollingGrid > :not(:first-child) {
pointer-events: initial;
}
.tile { .tile {
position: absolute; position: absolute;
inset-block-start: 0; inset-block-start: 0;

View File

@@ -35,7 +35,7 @@ import {
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import LogoMark from "../icons/LogoMark.svg?react"; import LogoMark from "../icons/LogoMark.svg?react";
@@ -59,7 +59,6 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake"; import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useLiveKit } from "../livekit/useLiveKit"; import { useLiveKit } from "../livekit/useLiveKit";
import { useFullscreen } from "./useFullscreen";
import { useWakeLock } from "../useWakeLock"; import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { MuteStates } from "./MuteStates"; import { MuteStates } from "./MuteStates";
@@ -76,14 +75,16 @@ import { SpotlightTile } from "../tile/SpotlightTile";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout"; import { makeGridLayout } from "../grid/GridLayout";
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
import { import {
CallLayout, CallLayoutOutputs,
TileModel, TileModel,
defaultPipAlignment, defaultPipAlignment,
defaultSpotlightAlignment, defaultSpotlightAlignment,
} from "../grid/CallLayout"; } from "../grid/CallLayout";
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -194,24 +195,9 @@ export const InCallView: FC<InCallViewProps> = ({
matrixInfo.e2eeSystem.kind !== E2eeType.NONE, matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState, connState,
); );
const windowMode = useObservableEagerState(vm.windowMode);
const layout = useObservableEagerState(vm.layout); const layout = useObservableEagerState(vm.layout);
const gridMode = useObservableEagerState(vm.gridMode); const gridMode = useObservableEagerState(vm.gridMode);
const hasSpotlight = layout.spotlight !== undefined;
const fullscreenItems = useMemo(
() => (hasSpotlight ? ["spotlight"] : []),
[hasSpotlight],
);
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
useFullscreen(fullscreenItems);
const toggleSpotlightFullscreen = useCallback(
() => toggleFullscreen("spotlight"),
[toggleFullscreen],
);
// The maximised participant: either the participant that the user has
// manually put in fullscreen, or (TODO) the spotlight if the window is too
// small to show everyone
const maximisedParticipant = fullscreenItem;
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
@@ -235,14 +221,18 @@ export const InCallView: FC<InCallViewProps> = ({
const gridBounds = useMemo( const gridBounds = useMemo(
() => ({ () => ({
width: footerBounds.width, width: bounds.width,
height: bounds.height - headerBounds.height - footerBounds.height, height:
bounds.height -
headerBounds.height -
(windowMode === "flat" ? 0 : footerBounds.height),
}), }),
[ [
footerBounds.width, bounds.width,
bounds.height, bounds.height,
headerBounds.height, headerBounds.height,
footerBounds.height, footerBounds.height,
windowMode,
], ],
); );
const gridBoundsObservable = useObservable(gridBounds); const gridBoundsObservable = useObservable(gridBounds);
@@ -254,29 +244,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => new BehaviorSubject(defaultPipAlignment), () => new BehaviorSubject(defaultPipAlignment),
); );
const layoutSystem = useObservableEagerState(
useInitial(() =>
vm.layout.pipe(
map((l) => {
let makeLayout: CallLayout<Layout>;
if (l.type === "grid")
makeLayout = makeGridLayout as CallLayout<Layout>;
else if (l.type === "spotlight")
makeLayout = makeSpotlightLayout as CallLayout<Layout>;
else if (l.type === "one-on-one")
makeLayout = makeOneOnOneLayout as CallLayout<Layout>;
else throw new Error(`Unimplemented layout: ${l.type}`);
return makeLayout({
minBounds: gridBoundsObservable,
spotlightAlignment,
pipAlignment,
});
}),
),
),
);
const setGridMode = useCallback( const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode), (mode: GridMode) => vm.setGridMode(mode),
[vm], [vm],
@@ -318,10 +285,9 @@ export const InCallView: FC<InCallViewProps> = ({
} }
}, [setGridMode]); }, [setGridMode]);
const showSpotlightIndicators = useObservable(layout.type === "spotlight"); const toggleSpotlightExpanded = useCallback(
const showSpeakingIndicators = useObservable( () => vm.toggleSpotlightExpanded(),
layout.type === "spotlight" || [vm],
(layout.type === "grid" && layout.grid.length > 2),
); );
const Tile = useMemo( const Tile = useMemo(
@@ -333,20 +299,18 @@ export const InCallView: FC<InCallViewProps> = ({
{ className, style, targetWidth, targetHeight, model }, { className, style, targetWidth, targetHeight, model },
ref, ref,
) { ) {
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
const showSpeakingIndicatorsValue = useObservableEagerState( const showSpeakingIndicatorsValue = useObservableEagerState(
showSpeakingIndicators, vm.showSpeakingIndicators,
); );
const showSpotlightIndicatorsValue = useObservableEagerState( const showSpotlightIndicatorsValue = useObservableEagerState(
showSpotlightIndicators, vm.showSpotlightIndicators,
); );
return model.type === "grid" ? ( return model.type === "grid" ? (
<GridTile <GridTile
ref={ref} ref={ref}
vm={model.vm} vm={model.vm}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
onOpenProfile={openProfile} onOpenProfile={openProfile}
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
@@ -359,8 +323,8 @@ export const InCallView: FC<InCallViewProps> = ({
ref={ref} ref={ref}
vms={model.vms} vms={model.vms}
maximised={model.maximised} maximised={model.maximised}
fullscreen={false} expanded={spotlightExpanded}
onToggleFullscreen={toggleSpotlightFullscreen} onToggleExpanded={toggleSpotlightExpanded}
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue} showIndicators={showSpotlightIndicatorsValue}
@@ -369,52 +333,74 @@ export const InCallView: FC<InCallViewProps> = ({
/> />
); );
}), }),
[ [vm, toggleSpotlightExpanded, openProfile],
toggleFullscreen,
toggleSpotlightFullscreen,
openProfile,
showSpeakingIndicators,
showSpotlightIndicators,
],
); );
const layouts = useMemo(() => {
const inputs = {
minBounds: gridBoundsObservable,
spotlightAlignment,
pipAlignment,
};
return {
grid: makeGridLayout(inputs),
"spotlight landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight portrait": makeSpotlightPortraitLayout(inputs),
"spotlight expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs),
};
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
if (maximisedParticipant !== null) { if (layout.type === "pip") {
const fullscreen = maximisedParticipant === fullscreenItem; return (
if (maximisedParticipant === "spotlight") { <SpotlightTile
return ( className={classNames(styles.tile, styles.maximised)}
<SpotlightTile vms={layout.spotlight!}
className={classNames(styles.tile, styles.maximised)} maximised
vms={layout.spotlight!} expanded
maximised onToggleExpanded={null}
fullscreen={fullscreen} targetWidth={gridBounds.height}
onToggleFullscreen={toggleSpotlightFullscreen} targetHeight={gridBounds.width}
targetWidth={gridBounds.height} showIndicators={false}
targetHeight={gridBounds.width} />
showIndicators={false} );
/>
);
}
} }
return ( const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
const fixedGrid = (
<Grid
key="fixed"
className={styles.fixedGrid}
style={{
insetBlockStart: headerBounds.bottom,
height: gridBounds.height,
}}
model={layout}
Layout={layers.fixed}
Tile={Tile}
/>
);
const scrollingGrid = (
<Grid
key="scrolling"
className={styles.scrollingGrid}
model={layout}
Layout={layers.scrolling}
Tile={Tile}
/>
);
// The grid tiles go *under* the spotlight in the portrait layout, but
// *over* the spotlight in the expanded layout
return layout.type === "spotlight expanded" ? (
<> <>
<Grid {fixedGrid}
className={styles.scrollingGrid} {scrollingGrid}
model={layout} </>
Layout={layoutSystem.scrolling} ) : (
Tile={Tile} <>
/> {scrollingGrid}
<Grid {fixedGrid}
className={styles.fixedGrid}
style={{
insetBlockStart: headerBounds.bottom,
height: gridBounds.height,
}}
model={layout}
Layout={layoutSystem.fixed}
Tile={Tile}
/>
</> </>
); );
}; };
@@ -424,14 +410,13 @@ export const InCallView: FC<InCallViewProps> = ({
); );
const toggleScreensharing = useCallback(async () => { const toggleScreensharing = useCallback(async () => {
exitFullscreen();
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, { await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
audio: true, audio: true,
selfBrowserSurface: "include", selfBrowserSurface: "include",
surfaceSwitching: "include", surfaceSwitching: "include",
systemAudio: "include", systemAudio: "include",
}); });
}, [localParticipant, isScreenShareEnabled, exitFullscreen]); }, [localParticipant, isScreenShareEnabled]);
let footer: JSX.Element | null; let footer: JSX.Element | null;
@@ -484,11 +469,10 @@ export const InCallView: FC<InCallViewProps> = ({
<div <div
ref={footerRef} ref={footerRef}
className={classNames( className={classNames(
showControls styles.footer,
? styles.footer !showControls &&
: hideHeader (hideHeader ? styles.footerHidden : styles.footerThin),
? [styles.footer, styles.footerHidden] { [styles.overlay]: windowMode === "flat" },
: [styles.footer, styles.footerThin],
)} )}
> >
{!mobile && !hideHeader && ( {!mobile && !hideHeader && (
@@ -515,7 +499,7 @@ export const InCallView: FC<InCallViewProps> = ({
return ( return (
<div className={styles.inRoom} ref={containerRef}> <div className={styles.inRoom} ref={containerRef}>
{!hideHeader && maximisedParticipant === null && ( {!hideHeader && windowMode !== "pip" && windowMode !== "flat" && (
<Header className={styles.header} ref={headerRef}> <Header className={styles.header} ref={headerRef}>
<LeftNav> <LeftNav>
<RoomHeaderInfo <RoomHeaderInfo

View File

@@ -18,20 +18,12 @@ limitations under the License.
margin-inline: var(--inline-content-inset); margin-inline: var(--inline-content-inset);
min-block-size: 0; min-block-size: 0;
block-size: 50vh; block-size: 50vh;
} border-radius: var(--cpd-space-4x);
.preview.content {
margin-inline: 0;
}
.content {
position: relative; position: relative;
block-size: 100%;
inline-size: 100%;
overflow: hidden; overflow: hidden;
} }
.content video { .preview > video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
@@ -69,12 +61,20 @@ limitations under the License.
); );
} }
.preview.content .buttonBar {
padding-inline: var(--inline-content-inset);
}
@media (min-aspect-ratio: 1 / 1) { @media (min-aspect-ratio: 1 / 1) {
.preview video { .preview > video {
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
} }
} }
@media (max-width: 550px) {
.preview {
margin-inline: 0;
border-radius: 0;
block-size: 100%;
}
.buttonBar {
padding-inline: var(--inline-content-inset);
}
}

View File

@@ -21,13 +21,11 @@ import { usePreviewTracks } from "@livekit/components-react";
import { LocalVideoTrack, Track } from "livekit-client"; import { LocalVideoTrack, Track } from "livekit-client";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { Glass } from "@vector-im/compound-web";
import { Avatar } from "../Avatar"; import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css"; import styles from "./VideoPreview.module.css";
import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { MuteStates } from "./MuteStates"; import { MuteStates } from "./MuteStates";
import { useMediaQuery } from "../useMediaQuery";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
@@ -116,8 +114,8 @@ export const VideoPreview: FC<Props> = ({
}; };
}, [videoTrack]); }, [videoTrack]);
const content = ( return (
<> <div className={classNames(styles.preview)} ref={previewRef}>
<video <video
ref={videoEl} ref={videoEl}
muted muted
@@ -137,21 +135,6 @@ export const VideoPreview: FC<Props> = ({
</div> </div>
)} )}
<div className={styles.buttonBar}>{children}</div> <div className={styles.buttonBar}>{children}</div>
</>
);
return useMediaQuery("(max-width: 550px)") ? (
<div
className={classNames(styles.preview, styles.content)}
ref={previewRef}
>
{content}
</div> </div>
) : (
<Glass className={styles.preview}>
<div className={styles.content} ref={previewRef}>
{content}
</div>
</Glass>
); );
}; };

View File

@@ -34,9 +34,9 @@ import {
audit, audit,
combineLatest, combineLatest,
concat, concat,
concatMap,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
fromEvent,
map, map,
merge, merge,
mergeAll, mergeAll,
@@ -44,11 +44,11 @@ import {
sample, sample,
scan, scan,
shareReplay, shareReplay,
skip,
startWith, startWith,
switchMap, switchMap,
throttleTime, throttleTime,
timer, timer,
withLatestFrom,
zip, zip,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -67,7 +67,7 @@ import {
ScreenShareViewModel, ScreenShareViewModel,
UserMediaViewModel, UserMediaViewModel,
} from "./MediaViewModel"; } from "./MediaViewModel";
import { finalizeValue } from "../observable-utils"; import { accumulate, finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
// How long we wait after a focus switch before showing the real participant // How long we wait after a focus switch before showing the real participant
@@ -80,25 +80,30 @@ export interface GridLayout {
grid: UserMediaViewModel[]; grid: UserMediaViewModel[];
} }
export interface SpotlightLayout { export interface SpotlightLandscapeLayout {
type: "spotlight"; type: "spotlight landscape";
spotlight: MediaViewModel[]; spotlight: MediaViewModel[];
grid: UserMediaViewModel[]; grid: UserMediaViewModel[];
} }
export interface OneOnOneLayout { export interface SpotlightPortraitLayout {
type: "one-on-one"; type: "spotlight portrait";
spotlight?: ScreenShareViewModel[]; spotlight: MediaViewModel[];
local: LocalUserMediaViewModel; grid: UserMediaViewModel[];
remote: RemoteUserMediaViewModel;
} }
export interface FullScreenLayout { export interface SpotlightExpandedLayout {
type: "full screen"; type: "spotlight expanded";
spotlight: MediaViewModel[]; spotlight: MediaViewModel[];
pip?: UserMediaViewModel; pip?: UserMediaViewModel;
} }
export interface OneOnOneLayout {
type: "one-on-one";
local: LocalUserMediaViewModel;
remote: RemoteUserMediaViewModel;
}
export interface PipLayout { export interface PipLayout {
type: "pip"; type: "pip";
spotlight: MediaViewModel[]; spotlight: MediaViewModel[];
@@ -110,14 +115,15 @@ export interface PipLayout {
*/ */
export type Layout = export type Layout =
| GridLayout | GridLayout
| SpotlightLayout | SpotlightLandscapeLayout
| SpotlightPortraitLayout
| SpotlightExpandedLayout
| OneOnOneLayout | OneOnOneLayout
| FullScreenLayout
| PipLayout; | PipLayout;
export type GridMode = "grid" | "spotlight"; export type GridMode = "grid" | "spotlight";
export type WindowMode = "normal" | "full screen" | "pip"; export type WindowMode = "normal" | "narrow" | "flat" | "pip";
/** /**
* Sorting bins defining the order in which media tiles appear in the layout. * Sorting bins defining the order in which media tiles appear in the layout.
@@ -269,16 +275,13 @@ export class CallViewModel extends ViewModel {
}, },
).pipe( ).pipe(
mergeAll(), mergeAll(),
// Aggregate the hold instructions into a single list showing which // Accumulate the hold instructions into a single list showing which
// participants are being held // participants are being held
scan( accumulate([] as RemoteParticipant[][], (holds, instruction) =>
(holds, instruction) => "hold" in instruction
"hold" in instruction ? [instruction.hold, ...holds]
? [instruction.hold, ...holds] : holds.filter((h) => h !== instruction.unhold),
: holds.filter((h) => h !== instruction.unhold),
[] as RemoteParticipant[][],
), ),
startWith([]),
); );
private readonly remoteParticipants: Observable<RemoteParticipant[]> = private readonly remoteParticipants: Observable<RemoteParticipant[]> =
@@ -352,6 +355,11 @@ export class CallViewModel extends ViewModel {
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)), 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[]> = private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe( this.mediaItems.pipe(
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)), map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
@@ -364,7 +372,7 @@ export class CallViewModel extends ViewModel {
distinctUntilChanged(), distinctUntilChanged(),
); );
private readonly spotlightSpeaker: Observable<UserMedia | null> = private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
this.userMedia.pipe( this.userMedia.pipe(
switchMap((ms) => switchMap((ms) =>
ms.length === 0 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))), 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) => (prev, ms) =>
// Decide who to spotlight: // Decide who to spotlight:
// If the previous speaker (not the local user) is still speaking, // If the previous speaker (not the local user) is still speaking,
@@ -386,11 +394,11 @@ export class CallViewModel extends ViewModel {
// Otherwise, stick with the person who was last speaking // Otherwise, stick with the person who was last speaking
prev ?? prev ??
// Otherwise, spotlight the local user // Otherwise, spotlight the local user
ms.find(([m]) => m.vm.local)?.[0] ?? ms.find(([m]) => m.vm.local)![0],
null,
null, null,
), ),
distinctUntilChanged(), distinctUntilChanged(),
map((speaker) => speaker.vm),
shareReplay(1), shareReplay(1),
throttleTime(1600, undefined, { leading: true, trailing: true }), throttleTime(1600, undefined, { leading: true, trailing: true }),
); );
@@ -433,38 +441,91 @@ export class CallViewModel extends ViewModel {
}), }),
); );
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest( private readonly spotlightAndPip: Observable<
[this.screenShares, this.spotlightSpeaker], [Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
(screenShares, spotlightSpeaker): MediaViewModel[] => > = this.screenShares.pipe(
map((screenShares) =>
screenShares.length > 0 screenShares.length > 0
? screenShares.map((m) => m.vm) ? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
: spotlightSpeaker === null : ([
? [] this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
: [spotlightSpeaker.vm], this.localUserMedia.pipe(
switchMap((vm) =>
vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
),
),
] as const),
),
); );
// TODO: Make this react to changes in window dimensions and screen private readonly spotlight: Observable<MediaViewModel[]> =
// orientation this.spotlightAndPip.pipe(
private readonly windowMode = of<WindowMode>("normal"); switchMap(([spotlight]) => spotlight),
shareReplay(1),
);
private readonly pip: Observable<UserMediaViewModel | null> =
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
/**
* The general shape of the window.
*/
public readonly windowMode: Observable<WindowMode> = fromEvent(
window,
"resize",
).pipe(
startWith(null),
map(() => {
const height = window.innerHeight;
const width = window.innerWidth;
if (height <= 400 && width <= 340) return "pip";
if (width <= 660) return "narrow";
if (height <= 660) return "flat";
return "normal";
}),
distinctUntilChanged(),
shareReplay(1),
);
private readonly spotlightExpandedToggle = new Subject<void>();
public readonly spotlightExpanded: Observable<boolean> =
this.spotlightExpandedToggle.pipe(
accumulate(false, (expanded) => !expanded),
shareReplay(1),
);
public toggleSpotlightExpanded(): void {
this.spotlightExpandedToggle.next();
}
private readonly gridModeUserSelection = new Subject<GridMode>(); private readonly gridModeUserSelection = new Subject<GridMode>();
/** /**
* The layout mode of the media tile grid. * The layout mode of the media tile grid.
*/ */
public readonly gridMode: Observable<GridMode> = merge( public readonly gridMode: Observable<GridMode> =
// Always honor a manual user selection
this.gridModeUserSelection,
// If the user hasn't selected spotlight and somebody starts screen sharing, // If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends // automatically switch to spotlight mode and reset when screen sharing ends
this.hasRemoteScreenShares.pipe( this.gridModeUserSelection.pipe(
withLatestFrom(this.gridModeUserSelection.pipe(startWith(null))), startWith(null),
concatMap(([hasScreenShares, userSelection]) => switchMap((userSelection) =>
userSelection === "spotlight" (userSelection === "spotlight"
? EMPTY ? EMPTY
: of<GridMode>(hasScreenShares ? "spotlight" : "grid"), : combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
skip(userSelection === null ? 0 : 1),
map(
([hasScreenShares, windowMode]): GridMode =>
hasScreenShares || windowMode === "flat"
? "spotlight"
: "grid",
),
)
).pipe(startWith(userSelection ?? "grid")),
), ),
), distinctUntilChanged(),
).pipe(distinctUntilChanged(), shareReplay(1)); shareReplay(1),
);
public setGridMode(value: GridMode): void { public setGridMode(value: GridMode): void {
this.gridModeUserSelection.next(value); this.gridModeUserSelection.next(value);
@@ -472,11 +533,24 @@ export class CallViewModel extends ViewModel {
public readonly layout: Observable<Layout> = this.windowMode.pipe( public readonly layout: Observable<Layout> = this.windowMode.pipe(
switchMap((windowMode) => { switchMap((windowMode) => {
const spotlightLandscapeLayout = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): Layout => ({
type: "spotlight landscape",
spotlight,
grid,
}),
);
const spotlightExpandedLayout = combineLatest(
[this.spotlight, this.pip],
(spotlight, pip): Layout => ({
type: "spotlight expanded",
spotlight,
pip: pip ?? undefined,
}),
);
switch (windowMode) { switch (windowMode) {
case "full screen":
throw new Error("unimplemented");
case "pip":
throw new Error("unimplemented");
case "normal": case "normal":
return this.gridMode.pipe( return this.gridMode.pipe(
switchMap((gridMode) => { switchMap((gridMode) => {
@@ -485,11 +559,9 @@ export class CallViewModel extends ViewModel {
return combineLatest( return combineLatest(
[this.grid, this.spotlight, this.screenShares], [this.grid, this.spotlight, this.screenShares],
(grid, spotlight, screenShares): Layout => (grid, spotlight, screenShares): Layout =>
grid.length == 2 grid.length == 2 && screenShares.length === 0
? { ? {
type: "one-on-one", type: "one-on-one",
spotlight:
screenShares.length > 0 ? spotlight : undefined,
local: grid.find( local: grid.find(
(vm) => vm.local, (vm) => vm.local,
) as LocalUserMediaViewModel, ) as LocalUserMediaViewModel,
@@ -507,22 +579,59 @@ export class CallViewModel extends ViewModel {
}, },
); );
case "spotlight": case "spotlight":
return combineLatest( return this.spotlightExpanded.pipe(
[this.grid, this.spotlight], switchMap((expanded) =>
(grid, spotlight): Layout => ({ expanded
type: "spotlight", ? spotlightExpandedLayout
spotlight, : spotlightLandscapeLayout,
grid, ),
}),
); );
} }
}), }),
); );
case "narrow":
return combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): Layout => ({
type: "spotlight portrait",
spotlight,
grid,
}),
);
case "flat":
return this.gridMode.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayout;
case "spotlight":
return spotlightExpandedLayout;
}
}),
);
case "pip":
return this.spotlight.pipe(
map((spotlight): Layout => ({ type: "pip", spotlight })),
);
} }
}), }),
shareReplay(1), shareReplay(1),
); );
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
map((l) => l.type !== "grid"),
distinctUntilChanged(),
shareReplay(1),
);
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
map((l) => l.type !== "one-on-one" && l.type !== "spotlight expanded"),
distinctUntilChanged(),
shareReplay(1),
);
public constructor( public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room // A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom, private readonly matrixRoom: MatrixRoom,

View File

@@ -22,7 +22,7 @@ limitations under the License.
/* Use a pseudo-element to create the expressive speaking border, since CSS /* Use a pseudo-element to create the expressive speaking border, since CSS
borders don't support gradients */ borders don't support gradients */
.tile[data-maximised="false"]::before { .tile::before {
content: ""; content: "";
position: absolute; position: absolute;
z-index: -1; /* Put it below the outline */ z-index: -1; /* Put it below the outline */
@@ -43,27 +43,22 @@ borders don't support gradients */
background-blend-mode: overlay, normal; background-blend-mode: overlay, normal;
} }
.tile[data-maximised="false"].speaking { .tile.speaking {
/* !important because speaking border should take priority over hover */ /* !important because speaking border should take priority over hover */
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important; outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
} }
.tile[data-maximised="false"].speaking::before { .tile.speaking::before {
opacity: 1; opacity: 1;
} }
@media (hover: hover) { @media (hover: hover) {
.tile[data-maximised="false"]:hover { .tile:hover {
outline: var(--cpd-border-width-2) solid outline: var(--cpd-border-width-2) solid
var(--cpd-color-border-interactive-hovered); var(--cpd-color-border-interactive-hovered);
} }
} }
.tile[data-maximised="true"] {
--media-view-border-radius: 0;
--media-view-fg-inset: 10px;
}
.muteIcon[data-muted="true"] { .muteIcon[data-muted="true"] {
color: var(--cpd-color-icon-secondary); color: var(--cpd-color-icon-secondary);
} }

View File

@@ -57,7 +57,6 @@ interface TileProps {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
maximised: boolean;
displayName: string; displayName: string;
nameTag: string; nameTag: string;
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
@@ -79,7 +78,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
menuEnd, menuEnd,
className, className,
nameTag, nameTag,
maximised,
...props ...props
}, },
ref, ref,
@@ -151,7 +149,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
{menu} {menu}
</Menu> </Menu>
} }
data-maximised={maximised}
{...props} {...props}
/> />
); );
@@ -273,9 +270,6 @@ RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps { interface GridTileProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
onOpenProfile: () => void; onOpenProfile: () => void;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
@@ -285,7 +279,7 @@ interface GridTileProps {
} }
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>( export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, fullscreen, onToggleFullscreen, onOpenProfile, ...props }, ref) => { ({ vm, onOpenProfile, ...props }, ref) => {
const nameData = useNameData(vm); const nameData = useNameData(vm);
if (vm instanceof LocalUserMediaViewModel) { if (vm instanceof LocalUserMediaViewModel) {

View File

@@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
grid-template-areas: ". button2" "nameTag button1"; grid-template-areas: ". ." "nameTag button";
gap: var(--cpd-space-1x); gap: var(--cpd-space-1x);
place-items: start; place-items: start;
} }
@@ -175,9 +175,5 @@ unconditionally select the container so we can use cqmin units */
} }
.fg > button:first-of-type { .fg > button:first-of-type {
grid-area: button1; grid-area: button;
}
.fg > button:nth-of-type(2) {
grid-area: button2;
} }

View File

@@ -42,7 +42,6 @@ interface Props extends ComponentProps<typeof animated.div> {
nameTag: string; nameTag: string;
displayName: string; displayName: string;
primaryButton?: ReactNode; primaryButton?: ReactNode;
secondaryButton?: ReactNode;
} }
export const MediaView = forwardRef<HTMLDivElement, Props>( export const MediaView = forwardRef<HTMLDivElement, Props>(
@@ -62,7 +61,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
nameTag, nameTag,
displayName, displayName,
primaryButton, primaryButton,
secondaryButton,
...props ...props
}, },
ref, ref,
@@ -120,7 +118,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
)} )}
</div> </div>
{primaryButton} {primaryButton}
{secondaryButton}
</div> </div>
</animated.div> </animated.div>
); );

View File

@@ -15,28 +15,11 @@ limitations under the License.
*/ */
.tile { .tile {
--border-width: var(--cpd-space-3x);
}
.tile.maximised {
--border-width: 0px;
}
.border {
box-sizing: border-box;
block-size: 100%;
inline-size: 100%;
}
.tile.maximised .border {
display: contents;
}
.contents {
display: flex; display: flex;
border-radius: var(--cpd-space-6x); border-radius: var(--cpd-space-6x);
contain: strict; contain: strict;
overflow: auto; overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; scrollbar-width: none;
scroll-snap-type: inline mandatory; scroll-snap-type: inline mandatory;
scroll-snap-stop: always; scroll-snap-stop: always;
@@ -46,18 +29,18 @@ limitations under the License.
scroll-behavior: smooth; */ scroll-behavior: smooth; */
} }
.tile.maximised .contents { .tile.maximised {
border-radius: 0; border-radius: 0;
} }
.contents > .item { .item {
height: 100%; height: 100%;
flex-basis: 100%; flex-basis: 100%;
flex-shrink: 0; flex-shrink: 0;
--media-view-fg-inset: 10px; --media-view-fg-inset: 10px;
} }
.contents > .item.snap { .item.snap {
scroll-snap-align: start; scroll-snap-align: start;
} }
@@ -105,7 +88,7 @@ limitations under the License.
inset-inline-end: var(--cpd-space-1x); inset-inline-end: var(--cpd-space-1x);
} }
.fullScreen { .expand {
appearance: none; appearance: none;
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
@@ -118,23 +101,23 @@ limitations under the License.
transition-property: opacity, background-color; transition-property: opacity, background-color;
position: absolute; position: absolute;
z-index: 1; z-index: 1;
--inset: calc(var(--border-width) + 6px); --inset: 6px;
inset-block-end: var(--inset); inset-block-end: var(--inset);
inset-inline-end: var(--inset); inset-inline-end: var(--inset);
} }
.fullScreen > svg { .expand > svg {
display: block; display: block;
color: var(--cpd-color-icon-on-solid-primary); color: var(--cpd-color-icon-on-solid-primary);
} }
@media (hover) { @media (hover) {
.fullScreen:hover { .expand:hover {
background: var(--cpd-color-bg-action-primary-hovered); background: var(--cpd-color-bg-action-primary-hovered);
} }
} }
.fullScreen:active { .expand:active {
background: var(--cpd-color-bg-action-primary-pressed); background: var(--cpd-color-bg-action-primary-pressed);
} }

View File

@@ -23,7 +23,6 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Glass } from "@vector-im/compound-web";
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react"; import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react"; import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react"; import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
@@ -174,8 +173,8 @@ SpotlightItem.displayName = "SpotlightItem";
interface Props { interface Props {
vms: MediaViewModel[]; vms: MediaViewModel[];
maximised: boolean; maximised: boolean;
fullscreen: boolean; expanded: boolean;
onToggleFullscreen: () => void; onToggleExpanded: (() => void) | null;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
showIndicators: boolean; showIndicators: boolean;
@@ -188,8 +187,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
{ {
vms, vms,
maximised, maximised,
fullscreen, expanded,
onToggleFullscreen, onToggleExpanded,
targetWidth, targetWidth,
targetHeight, targetHeight,
showIndicators, showIndicators,
@@ -254,9 +253,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
setScrollToId(vms[visibleIndex + 1].id); setScrollToId(vms[visibleIndex + 1].id);
}, [latestVisibleId, latestVms, setScrollToId]); }, [latestVisibleId, latestVms, setScrollToId]);
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon; const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
// We need a wrapper element because Glass doesn't provide an animated.div
return ( return (
<animated.div <animated.div
ref={ref} ref={ref}
@@ -274,33 +272,29 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<ChevronLeftIcon aria-hidden width={24} height={24} /> <ChevronLeftIcon aria-hidden width={24} height={24} />
</button> </button>
)} )}
<Glass className={styles.border}> {vms.map((vm) => (
{/* Similarly we need a wrapper element here because Glass expects a <SpotlightItem
single child */} key={vm.id}
<div className={styles.contents}> vm={vm}
{vms.map((vm) => ( targetWidth={targetWidth}
<SpotlightItem targetHeight={targetHeight}
key={vm.id} intersectionObserver={intersectionObserver}
vm={vm} snap={scrollToId === null || scrollToId === vm.id}
targetWidth={targetWidth} />
targetHeight={targetHeight} ))}
intersectionObserver={intersectionObserver} {onToggleExpanded && (
snap={scrollToId === null || scrollToId === vm.id} <button
/> className={classNames(styles.expand)}
))} aria-label={
</div> expanded
</Glass> ? t("video_tile.full_screen")
<button : t("video_tile.exit_full_screen")
className={classNames(styles.fullScreen)} }
aria-label={ onClick={onToggleExpanded}
fullscreen >
? t("video_tile.full_screen") <ToggleExpandIcon aria-hidden width={20} height={20} />
: t("video_tile.exit_full_screen") </button>
} )}
onClick={onToggleFullscreen}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
{canGoToNext && ( {canGoToNext && (
<button <button
className={classNames(styles.advance, styles.next)} className={classNames(styles.advance, styles.next)}
@@ -310,15 +304,17 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<ChevronRightIcon aria-hidden width={24} height={24} /> <ChevronRightIcon aria-hidden width={24} height={24} />
</button> </button>
)} )}
<div {!expanded && (
className={classNames(styles.indicators, { <div
[styles.show]: showIndicators && vms.length > 1, className={classNames(styles.indicators, {
})} [styles.show]: showIndicators && vms.length > 1,
> })}
{vms.map((vm) => ( >
<div className={styles.item} data-visible={vm.id === visibleId} /> {vms.map((vm) => (
))} <div className={styles.item} data-visible={vm.id === visibleId} />
</div> ))}
</div>
)}
</animated.div> </animated.div>
); );
}, },