diff --git a/src/Header.module.css b/src/Header.module.css index 4e54009dc..5a408bd3d 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 e97b18a27..e1dd7338b 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 33edc3bed..6838ae911 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 4d499eeda..b49bb32aa 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 0d2ad4ff6..5bdeb2c8c 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 2eac1b7e4..1f9a39e71 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); - - 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", - }), - [], - ); + scrollingOnTop: false, - 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 000000000..6556110e2 --- /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 000000000..40f77ca9c --- /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 000000000..8ca91e108 --- /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 000000000..40b02f9ae --- /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 d58a95a18..000000000 --- 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 000000000..1ee91334f --- /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 9ddbce10c..5c0cb0a83 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; -} - -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 makeSpotlightPortraitLayout: CallLayout< + SpotlightPortraitLayoutModel +> = ({ minBounds }) => ({ + scrollingOnTop: false, -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 60c46aa62..b8cf9f5e7 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 a1b3f4cce..a3e028692 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 3be88f1f8..5899a8bfc 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 6ed21a59e..5d50c5c5f 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 7ef66d8d5..ea015f435 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 4dd835671..eb2625e82 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 65cf9fc77..e3622f4d2 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 69c3591ea..e34b4fdda 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 cc591fee3..1aee4589f 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 a171fe4fd..5407b1a75 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) => ( +
+ ))} +
+ )} ); },