From 244003763953db033b5e5350142b3f00b7c8910a Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Jul 2024 15:08:30 -0400 Subject: [PATCH] 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. --- src/Header.module.css | 1 - src/grid/CallLayout.ts | 8 +- src/grid/GridLayout.module.css | 3 +- src/grid/GridLayout.tsx | 2 + src/grid/OneOnOneLayout.module.css | 2 - src/grid/OneOnOneLayout.tsx | 52 +--- src/grid/SpotlightExpandedLayout.module.css | 47 ++++ src/grid/SpotlightExpandedLayout.tsx | 99 ++++++++ src/grid/SpotlightLandscapeLayout.module.css | 54 ++++ src/grid/SpotlightLandscapeLayout.tsx | 93 +++++++ src/grid/SpotlightLayout.module.css | 98 -------- src/grid/SpotlightPortraitLayout.module.css | 56 +++++ ...Layout.tsx => SpotlightPortraitLayout.tsx} | 75 +++--- src/observable-utils.ts | 14 +- src/room/InCallView.module.css | 27 +- src/room/InCallView.tsx | 194 +++++++-------- src/room/VideoPreview.module.css | 30 +-- src/room/VideoPreview.tsx | 21 +- src/state/CallViewModel.ts | 231 +++++++++++++----- src/tile/GridTile.module.css | 13 +- src/tile/GridTile.tsx | 8 +- src/tile/MediaView.module.css | 8 +- src/tile/MediaView.tsx | 3 - src/tile/SpotlightTile.module.css | 37 +-- src/tile/SpotlightTile.tsx | 82 +++---- 25 files changed, 761 insertions(+), 497 deletions(-) create mode 100644 src/grid/SpotlightExpandedLayout.module.css create mode 100644 src/grid/SpotlightExpandedLayout.tsx create mode 100644 src/grid/SpotlightLandscapeLayout.module.css create mode 100644 src/grid/SpotlightLandscapeLayout.tsx delete mode 100644 src/grid/SpotlightLayout.module.css create mode 100644 src/grid/SpotlightPortraitLayout.module.css rename src/grid/{SpotlightLayout.tsx => SpotlightPortraitLayout.tsx} (61%) diff --git a/src/Header.module.css b/src/Header.module.css index 4e54009d..5a408bd3 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -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 { diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index e97b18a2..e1dd7338 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -65,6 +65,10 @@ export interface SpotlightTileModel { export type TileModel = GridTileModel | SpotlightTileModel; export interface CallLayoutOutputs { + /** + * Whether the scrolling layer of the layout should appear on top. + */ + scrollingOnTop: boolean; /** * The visually fixed (non-scrolling) layer of the layout. */ @@ -121,7 +125,7 @@ export function arrangeTiles( ); let rows = Math.ceil(tileCount / columns); - let tileWidth = (width - (columns - 1) * gap) / columns; + let tileWidth = (width - (columns + 1) * gap) / columns; let tileHeight = (minHeight - (rows - 1) * gap) / rows; // Impose a minimum width and height on the tiles @@ -132,7 +136,7 @@ export function arrangeTiles( // c = (W + g) / (w + g). columns = Math.floor((width + gap) / (tileMinWidth + gap)); rows = Math.ceil(tileCount / columns); - tileWidth = (width - (columns - 1) * gap) / columns; + tileWidth = (width - (columns + 1) * gap) / columns; tileHeight = (minHeight - (rows - 1) * gap) / rows; } if (tileHeight < tileMinHeight) tileHeight = tileMinHeight; diff --git a/src/grid/GridLayout.module.css b/src/grid/GridLayout.module.css index 33edc3be..6838ae91 100644 --- a/src/grid/GridLayout.module.css +++ b/src/grid/GridLayout.module.css @@ -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"] { diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index 4d499eed..b49bb32a 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -40,6 +40,8 @@ export const makeGridLayout: CallLayout = ({ 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) { diff --git a/src/grid/OneOnOneLayout.module.css b/src/grid/OneOnOneLayout.module.css index 0d2ad4ff..5bdeb2c8 100644 --- a/src/grid/OneOnOneLayout.module.css +++ b/src/grid/OneOnOneLayout.module.css @@ -15,7 +15,6 @@ limitations under the License. */ .layer { - margin-inline: var(--inline-content-inset); block-size: 100%; display: grid; place-items: center; @@ -36,7 +35,6 @@ limitations under the License. position: absolute; inline-size: 404px; block-size: 233px; - inset: -12px; } .slot[data-block-alignment="start"] { diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index 2eac1b7e..1f9a39e7 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -19,63 +19,19 @@ import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel"; -import { - CallLayout, - GridTileModel, - SpotlightTileModel, - arrangeTiles, -} from "./CallLayout"; +import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout"; import { useReactiveState } from "../useReactiveState"; import styles from "./OneOnOneLayout.module.css"; import { DragCallback } from "./Grid"; export const makeOneOnOneLayout: CallLayout = ({ minBounds, - spotlightAlignment, pipAlignment, }) => ({ - fixed: forwardRef(function OneOnOneLayoutFixed({ model, Slot }, ref) { - const { width, height } = useObservableEagerState(minBounds); - const spotlightAlignmentValue = useObservableEagerState(spotlightAlignment); + scrollingOnTop: false, - const [generation] = useReactiveState( - (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 ( -
- {spotlightTileModel && ( - - )} -
- ); + fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) { + return
; }), scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { diff --git a/src/grid/SpotlightExpandedLayout.module.css b/src/grid/SpotlightExpandedLayout.module.css new file mode 100644 index 00000000..6556110e --- /dev/null +++ b/src/grid/SpotlightExpandedLayout.module.css @@ -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; +} diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx new file mode 100644 index 00000000..40f77ca9 --- /dev/null +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -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( + (prev) => (prev === undefined ? 0 : prev + 1), + [width, height], + ); + + const spotlightTileModel: SpotlightTileModel = useMemo( + () => ({ type: "spotlight", vms: model.spotlight, maximised: true }), + [model.spotlight], + ); + + return ( +
+ +
+ ); + }), + + scrolling: forwardRef(function SpotlightExpandedLayoutScrolling( + { model, Slot }, + ref, + ) { + const { width, height } = useObservableEagerState(minBounds); + const pipAlignmentValue = useObservableEagerState(pipAlignment); + + const [generation] = useReactiveState( + (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 ( +
+ {pipTileModel && ( + + )} +
+ ); + }), +}); diff --git a/src/grid/SpotlightLandscapeLayout.module.css b/src/grid/SpotlightLandscapeLayout.module.css new file mode 100644 index 00000000..8ca91e10 --- /dev/null +++ b/src/grid/SpotlightLandscapeLayout.module.css @@ -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; +} diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx new file mode 100644 index 00000000..40b02f9a --- /dev/null +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -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( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.grid.length, width, height], + ); + + return ( +
+
+ +
+
+
+ ); + }), + + 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( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.spotlight.length, model.grid, width, height], + ); + + return ( +
+
1, + })} + /> +
+ {tileModels.map((m) => ( + + ))} +
+
+ ); + }), +}); diff --git a/src/grid/SpotlightLayout.module.css b/src/grid/SpotlightLayout.module.css deleted file mode 100644 index d58a95a1..00000000 --- a/src/grid/SpotlightLayout.module.css +++ /dev/null @@ -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; -} diff --git a/src/grid/SpotlightPortraitLayout.module.css b/src/grid/SpotlightPortraitLayout.module.css new file mode 100644 index 00000000..1ee91334 --- /dev/null +++ b/src/grid/SpotlightPortraitLayout.module.css @@ -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); +} diff --git a/src/grid/SpotlightLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx similarity index 61% rename from src/grid/SpotlightLayout.tsx rename to src/grid/SpotlightPortraitLayout.tsx index 9ddbce10..5c0cb0a8 100644 --- a/src/grid/SpotlightLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -18,46 +18,39 @@ import { CSSProperties, forwardRef, useMemo } from "react"; import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; -import { CallLayout, GridTileModel, TileModel } from "./CallLayout"; -import { SpotlightLayout as SpotlightLayoutModel } from "../state/CallViewModel"; -import styles from "./SpotlightLayout.module.css"; +import { + CallLayout, + GridTileModel, + TileModel, + arrangeTiles, +} from "./CallLayout"; +import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; +import styles from "./SpotlightPortraitLayout.module.css"; import { useReactiveState } from "../useReactiveState"; interface GridCSSProperties extends CSSProperties { - "--grid-columns": number; + "--grid-gap": string; + "--grid-tile-width": string; + "--grid-tile-height": string; } -interface Layout { - orientation: "portrait" | "landscape"; - gridColumns: number; -} +export const makeSpotlightPortraitLayout: CallLayout< + SpotlightPortraitLayoutModel +> = ({ minBounds }) => ({ + scrollingOnTop: false, -function getLayout(gridLength: number, width: number): Layout { - const orientation = width < 800 ? "portrait" : "landscape"; - return { - orientation, - gridColumns: - orientation === "portrait" - ? Math.floor(width / 190) - : gridLength > 20 - ? 2 - : 1, - }; -} - -export const makeSpotlightLayout: CallLayout = ({ - 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( (prev) => (prev === undefined ? 0 : prev + 1), @@ -65,27 +58,24 @@ export const makeSpotlightLayout: CallLayout = ({ ); return ( -
+
-
); }), - scrolling: forwardRef(function SpotlightLayoutScrolling( + scrolling: forwardRef(function SpotlightPortraitLayoutScrolling( { model, Slot }, ref, ) { const { width, height } = useObservableEagerState(minBounds); - const layout = getLayout(model.grid.length, width); + const { gap, tileWidth, tileHeight } = arrangeTiles( + width, + 0, + model.grid.length, + ); const tileModels: GridTileModel[] = useMemo( () => model.grid.map((vm) => ({ type: "grid", vm })), [model.grid], @@ -99,9 +89,14 @@ export const makeSpotlightLayout: CallLayout = ({
(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( + initial: State, + update: (state: State, event: Event) => State, +) { + return (events: Observable): Observable => + events.pipe(scan(update, initial), startWith(initial)); +} diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 60c46aa6..b8cf9f5e 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -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; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index a1b3f4cc..a3e02869 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 = ({ 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 = ({ 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 = ({ () => new BehaviorSubject(defaultPipAlignment), ); - const layoutSystem = useObservableEagerState( - useInitial(() => - vm.layout.pipe( - map((l) => { - let makeLayout: CallLayout; - if (l.type === "grid") - makeLayout = makeGridLayout as CallLayout; - else if (l.type === "spotlight") - makeLayout = makeSpotlightLayout as CallLayout; - else if (l.type === "one-on-one") - makeLayout = makeOneOnOneLayout as CallLayout; - 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 = ({ } }, [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 = ({ { 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" ? ( = ({ 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 = ({ /> ); }), - [ - 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 ( - - ); - } + if (layout.type === "pip") { + return ( + + ); } - return ( + const layers = layouts[layout.type] as CallLayoutOutputs; + const fixedGrid = ( + + ); + const scrollingGrid = ( + + ); + // The grid tiles go *under* the spotlight in the portrait layout, but + // *over* the spotlight in the expanded layout + return layout.type === "spotlight expanded" ? ( <> - - + {fixedGrid} + {scrollingGrid} + + ) : ( + <> + {scrollingGrid} + {fixedGrid} ); }; @@ -424,14 +410,13 @@ export const InCallView: FC = ({ ); 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 = ({
{!mobile && !hideHeader && ( @@ -515,7 +499,7 @@ export const InCallView: FC = ({ return (
- {!hideHeader && maximisedParticipant === null && ( + {!hideHeader && windowMode !== "pip" && windowMode !== "flat" && (
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); + } +} diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index 3be88f1f..5899a8bf 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -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 = ({ }; }, [videoTrack]); - const content = ( - <> + return ( +
)}
{children}
- - ); - - return useMediaQuery("(max-width: 550px)") ? ( -
- {content}
- ) : ( - -
- {content} -
-
); }; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 6ed21a59..5d50c5c5 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -34,9 +34,9 @@ import { audit, combineLatest, concat, - concatMap, distinctUntilChanged, filter, + fromEvent, map, merge, mergeAll, @@ -44,11 +44,11 @@ import { sample, scan, shareReplay, + skip, startWith, switchMap, throttleTime, timer, - withLatestFrom, zip, } from "rxjs"; import { logger } from "matrix-js-sdk/src/logger"; @@ -67,7 +67,7 @@ import { ScreenShareViewModel, UserMediaViewModel, } from "./MediaViewModel"; -import { finalizeValue } from "../observable-utils"; +import { accumulate, finalizeValue } from "../observable-utils"; import { ObservableScope } from "./ObservableScope"; // How long we wait after a focus switch before showing the real participant @@ -80,25 +80,30 @@ export interface GridLayout { grid: UserMediaViewModel[]; } -export interface SpotlightLayout { - type: "spotlight"; +export interface SpotlightLandscapeLayout { + type: "spotlight landscape"; spotlight: MediaViewModel[]; grid: UserMediaViewModel[]; } -export interface OneOnOneLayout { - type: "one-on-one"; - spotlight?: ScreenShareViewModel[]; - local: LocalUserMediaViewModel; - remote: RemoteUserMediaViewModel; +export interface SpotlightPortraitLayout { + type: "spotlight portrait"; + spotlight: MediaViewModel[]; + grid: UserMediaViewModel[]; } -export interface FullScreenLayout { - type: "full screen"; +export interface SpotlightExpandedLayout { + type: "spotlight expanded"; spotlight: MediaViewModel[]; pip?: UserMediaViewModel; } +export interface OneOnOneLayout { + type: "one-on-one"; + local: LocalUserMediaViewModel; + remote: RemoteUserMediaViewModel; +} + export interface PipLayout { type: "pip"; spotlight: MediaViewModel[]; @@ -110,14 +115,15 @@ export interface PipLayout { */ export type Layout = | GridLayout - | SpotlightLayout + | SpotlightLandscapeLayout + | SpotlightPortraitLayout + | SpotlightExpandedLayout | OneOnOneLayout - | FullScreenLayout | PipLayout; export type GridMode = "grid" | "spotlight"; -export type WindowMode = "normal" | "full screen" | "pip"; +export type WindowMode = "normal" | "narrow" | "flat" | "pip"; /** * Sorting bins defining the order in which media tiles appear in the layout. @@ -269,16 +275,13 @@ export class CallViewModel extends ViewModel { }, ).pipe( mergeAll(), - // Aggregate the hold instructions into a single list showing which + // Accumulate the hold instructions into a single list showing which // participants are being held - scan( - (holds, instruction) => - "hold" in instruction - ? [instruction.hold, ...holds] - : holds.filter((h) => h !== instruction.unhold), - [] as RemoteParticipant[][], + accumulate([] as RemoteParticipant[][], (holds, instruction) => + "hold" in instruction + ? [instruction.hold, ...holds] + : holds.filter((h) => h !== instruction.unhold), ), - startWith([]), ); private readonly remoteParticipants: Observable = @@ -352,6 +355,11 @@ export class CallViewModel extends ViewModel { map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)), ); + private readonly localUserMedia: Observable = + this.mediaItems.pipe( + map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel), + ); + private readonly screenShares: Observable = this.mediaItems.pipe( map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)), @@ -364,7 +372,7 @@ export class CallViewModel extends ViewModel { distinctUntilChanged(), ); - private readonly spotlightSpeaker: Observable = + private readonly spotlightSpeaker: Observable = this.userMedia.pipe( switchMap((ms) => ms.length === 0 @@ -373,7 +381,7 @@ export class CallViewModel extends ViewModel { ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))), ), ), - scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>( + scan<(readonly [UserMedia, boolean])[], UserMedia, null>( (prev, ms) => // Decide who to spotlight: // If the previous speaker (not the local user) is still speaking, @@ -386,11 +394,11 @@ export class CallViewModel extends ViewModel { // Otherwise, stick with the person who was last speaking prev ?? // Otherwise, spotlight the local user - ms.find(([m]) => m.vm.local)?.[0] ?? - null, + ms.find(([m]) => m.vm.local)![0], null, ), distinctUntilChanged(), + map((speaker) => speaker.vm), shareReplay(1), throttleTime(1600, undefined, { leading: true, trailing: true }), ); @@ -433,38 +441,91 @@ export class CallViewModel extends ViewModel { }), ); - private readonly spotlight: Observable = combineLatest( - [this.screenShares, this.spotlightSpeaker], - (screenShares, spotlightSpeaker): MediaViewModel[] => + private readonly spotlightAndPip: Observable< + [Observable, Observable] + > = 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("normal"); + private readonly spotlight: Observable = + this.spotlightAndPip.pipe( + switchMap(([spotlight]) => spotlight), + shareReplay(1), + ); + + private readonly pip: Observable = + this.spotlightAndPip.pipe(switchMap(([, pip]) => pip)); + + /** + * The general shape of the window. + */ + public readonly windowMode: Observable = 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(); + public readonly spotlightExpanded: Observable = + this.spotlightExpandedToggle.pipe( + accumulate(false, (expanded) => !expanded), + shareReplay(1), + ); + + public toggleSpotlightExpanded(): void { + this.spotlightExpandedToggle.next(); + } private readonly gridModeUserSelection = new Subject(); /** * The layout mode of the media tile grid. */ - public readonly gridMode: Observable = merge( - // Always honor a manual user selection - this.gridModeUserSelection, + public readonly gridMode: Observable = // 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(hasScreenShares ? "spotlight" : "grid"), + : combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe( + skip(userSelection === null ? 0 : 1), + map( + ([hasScreenShares, windowMode]): GridMode => + hasScreenShares || windowMode === "flat" + ? "spotlight" + : "grid", + ), + ) + ).pipe(startWith(userSelection ?? "grid")), ), - ), - ).pipe(distinctUntilChanged(), shareReplay(1)); + distinctUntilChanged(), + shareReplay(1), + ); public setGridMode(value: GridMode): void { this.gridModeUserSelection.next(value); @@ -472,11 +533,24 @@ export class CallViewModel extends ViewModel { public readonly layout: Observable = this.windowMode.pipe( switchMap((windowMode) => { + const spotlightLandscapeLayout = combineLatest( + [this.grid, this.spotlight], + (grid, spotlight): Layout => ({ + type: "spotlight landscape", + spotlight, + grid, + }), + ); + const spotlightExpandedLayout = combineLatest( + [this.spotlight, this.pip], + (spotlight, pip): Layout => ({ + type: "spotlight expanded", + spotlight, + pip: pip ?? undefined, + }), + ); + switch (windowMode) { - case "full screen": - throw new Error("unimplemented"); - case "pip": - throw new Error("unimplemented"); case "normal": return this.gridMode.pipe( switchMap((gridMode) => { @@ -485,11 +559,9 @@ export class CallViewModel extends ViewModel { return combineLatest( [this.grid, this.spotlight, this.screenShares], (grid, spotlight, screenShares): Layout => - grid.length == 2 + grid.length == 2 && screenShares.length === 0 ? { type: "one-on-one", - spotlight: - screenShares.length > 0 ? spotlight : undefined, local: grid.find( (vm) => vm.local, ) as LocalUserMediaViewModel, @@ -507,22 +579,59 @@ export class CallViewModel extends ViewModel { }, ); case "spotlight": - return combineLatest( - [this.grid, this.spotlight], - (grid, spotlight): Layout => ({ - type: "spotlight", - spotlight, - grid, - }), + return this.spotlightExpanded.pipe( + switchMap((expanded) => + expanded + ? spotlightExpandedLayout + : spotlightLandscapeLayout, + ), ); } }), ); + case "narrow": + return combineLatest( + [this.grid, this.spotlight], + (grid, spotlight): Layout => ({ + type: "spotlight portrait", + spotlight, + grid, + }), + ); + case "flat": + return this.gridMode.pipe( + switchMap((gridMode) => { + switch (gridMode) { + case "grid": + // Yes, grid mode actually gets you a "spotlight" layout in + // this window mode. + return spotlightLandscapeLayout; + case "spotlight": + return spotlightExpandedLayout; + } + }), + ); + case "pip": + return this.spotlight.pipe( + map((spotlight): Layout => ({ type: "pip", spotlight })), + ); } }), shareReplay(1), ); + public showSpotlightIndicators: Observable = this.layout.pipe( + map((l) => l.type !== "grid"), + distinctUntilChanged(), + shareReplay(1), + ); + + public showSpeakingIndicators: Observable = 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, diff --git a/src/tile/GridTile.module.css b/src/tile/GridTile.module.css index 7ef66d8d..ea015f43 100644 --- a/src/tile/GridTile.module.css +++ b/src/tile/GridTile.module.css @@ -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); } diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 4dd83567..eb2625e8 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -57,7 +57,6 @@ interface TileProps { style?: ComponentProps["style"]; targetWidth: number; targetHeight: number; - maximised: boolean; displayName: string; nameTag: string; showSpeakingIndicators: boolean; @@ -79,7 +78,6 @@ const UserMediaTile = forwardRef( menuEnd, className, nameTag, - maximised, ...props }, ref, @@ -151,7 +149,6 @@ const UserMediaTile = forwardRef( {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( - ({ vm, fullscreen, onToggleFullscreen, onOpenProfile, ...props }, ref) => { + ({ vm, onOpenProfile, ...props }, ref) => { const nameData = useNameData(vm); if (vm instanceof LocalUserMediaViewModel) { diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 65cf9fc7..e3622f4d 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -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; } diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 69c3591e..e34b4fdd 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -42,7 +42,6 @@ interface Props extends ComponentProps { nameTag: string; displayName: string; primaryButton?: ReactNode; - secondaryButton?: ReactNode; } export const MediaView = forwardRef( @@ -62,7 +61,6 @@ export const MediaView = forwardRef( nameTag, displayName, primaryButton, - secondaryButton, ...props }, ref, @@ -120,7 +118,6 @@ export const MediaView = forwardRef( )}
{primaryButton} - {secondaryButton}
); diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index cc591fee..1aee4589 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -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); } diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index a171fe4f..5407b1a7 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -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( { vms, maximised, - fullscreen, - onToggleFullscreen, + expanded, + onToggleExpanded, targetWidth, targetHeight, showIndicators, @@ -254,9 +253,8 @@ export const SpotlightTile = forwardRef( 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 ( ( )} - - {/* Similarly we need a wrapper element here because Glass expects a - single child */} -
- {vms.map((vm) => ( - - ))} -
-
- + {vms.map((vm) => ( + + ))} + {onToggleExpanded && ( + + )} {canGoToNext && ( )} -
1, - })} - > - {vms.map((vm) => ( -
- ))} -
+ {!expanded && ( +
1, + })} + > + {vms.map((vm) => ( +
+ ))} +
+ )} ); },