diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index 4ce5a7c2..54f53ee9 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -25,7 +25,14 @@ export const defaultSpotlightAlignment: Alignment = { inline: "end", block: "end", }; -export const defaultPipAlignment: Alignment = { inline: "end", block: "start" }; +export const defaultPortraitPipAlignment: Alignment = { + inline: "end", + block: "end", +}; +export const defaultLandscapePipAlignment: Alignment = { + inline: "end", + block: "start", +}; export interface CallLayoutInputs { /** @@ -37,9 +44,13 @@ export interface CallLayoutInputs { */ spotlightAlignment$: BehaviorSubject; /** - * The alignment of the small picture-in-picture tile, if present. + * The alignment of the small picture-in-picture tile, if present, when in portrait. */ - pipAlignment$: BehaviorSubject; + portraitPipAlignment$: BehaviorSubject; + /** + * The alignment of the small picture-in-picture tile, if present, when in landscape. + */ + landscapePipAlignment$: BehaviorSubject; } export interface CallLayoutOutputs { diff --git a/src/grid/OneOnOneLayout.module.css b/src/grid/OneOnOneLandscapeLayout.module.css similarity index 100% rename from src/grid/OneOnOneLayout.module.css rename to src/grid/OneOnOneLandscapeLayout.module.css diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLandscapeLayout.tsx similarity index 66% rename from src/grid/OneOnOneLayout.tsx rename to src/grid/OneOnOneLandscapeLayout.tsx index fd9c0a65..e7ad3440 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLandscapeLayout.tsx @@ -1,5 +1,6 @@ /* Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. @@ -9,31 +10,35 @@ import { type ReactNode, useCallback, useMemo } from "react"; import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; -import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts"; +import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts"; import { type CallLayout, arrangeTiles } from "./CallLayout"; -import styles from "./OneOnOneLayout.module.css"; +import styles from "./OneOnOneLandscapeLayout.module.css"; import { type DragCallback, useUpdateLayout } from "./Grid"; import { useBehavior } from "../useBehavior"; /** - * An implementation of the "one-on-one" layout, in which the remote participant - * is shown at maximum size, overlaid by a small view of the local participant. + * An implementation of the "one-on-one" layout for landscape screens, in which + * the remote participant is shown at maximum size, overlaid by a small view of + * the local participant. */ -export const makeOneOnOneLayout: CallLayout = ({ - minBounds$, - pipAlignment$, -}) => ({ +export const makeOneOnOneLandscapeLayout: CallLayout< + OneOnOneLandscapeLayoutModel +> = ({ minBounds$, landscapePipAlignment$ }) => ({ scrollingOnTop: false, - fixed: function OneOnOneLayoutFixed({ ref }): ReactNode { + fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode { useUpdateLayout(); return
; }, - scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { + scrolling: function OneOnOneLandscapeLayoutScrolling({ + ref, + model, + Slot, + }): ReactNode { useUpdateLayout(); const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useBehavior(pipAlignment$); + const pipAlignmentValue = useBehavior(landscapePipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], @@ -41,7 +46,7 @@ export const makeOneOnOneLayout: CallLayout = ({ const onDragLocalTile: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment$.next({ + landscapePipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), diff --git a/src/grid/OneOnOnePortraitLayout.module.css b/src/grid/OneOnOnePortraitLayout.module.css new file mode 100644 index 00000000..0ac1b78d --- /dev/null +++ b/src/grid/OneOnOnePortraitLayout.module.css @@ -0,0 +1,45 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +.layer { + block-size: 100%; + display: grid; + place-items: center; +} + +.container { + position: relative; +} + +.local { + position: absolute; + inline-size: 180px; + block-size: 135px; + inset: 0; +} + +.spotlight { + position: absolute; + inline-size: 404px; + block-size: 233px; +} + +.slot[data-block-alignment="start"] { + inset-block-end: unset; +} + +.slot[data-block-alignment="end"] { + inset-block-start: unset; +} + +.slot[data-inline-alignment="start"] { + inset-inline-end: unset; +} + +.slot[data-inline-alignment="end"] { + inset-inline-start: unset; +} diff --git a/src/grid/OneOnOnePortraitLayout.tsx b/src/grid/OneOnOnePortraitLayout.tsx new file mode 100644 index 00000000..e1a1bbd8 --- /dev/null +++ b/src/grid/OneOnOnePortraitLayout.tsx @@ -0,0 +1,78 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type ReactNode, useCallback, useMemo } from "react"; +import { useObservableEagerState } from "observable-hooks"; +import classNames from "classnames"; + +import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts"; +import { type CallLayout, arrangeTiles } from "./CallLayout"; +import styles from "./OneOnOnePortraitLayout.module.css"; +import { type DragCallback, useUpdateLayout } from "./Grid"; +import { useBehavior } from "../useBehavior"; + +/** + * An implementation of the "one-on-one" layout for portrait screens, in which + * the remote participant is shown at maximum size, overlaid by a small view of + * the local participant. + */ +export const makeOneOnOnePortraitLayout: CallLayout< + OneOnOnePortraitLayoutModel +> = ({ minBounds$, portraitPipAlignment$ }) => ({ + scrollingOnTop: false, + + fixed: function OneOnOnePortraitLayoutFixed({ ref }): ReactNode { + useUpdateLayout(); + return
; + }, + + scrolling: function OneOnOnePortraitLayoutScrolling({ + ref, + model, + Slot, + }): ReactNode { + useUpdateLayout(); + const { width, height } = useObservableEagerState(minBounds$); + const pipAlignmentValue = useBehavior(portraitPipAlignment$); + const { tileWidth, tileHeight } = useMemo( + () => arrangeTiles(width, height, 1), + [width, height], + ); + + const onDragLocalTile: DragCallback = useCallback( + ({ xRatio, yRatio }) => + portraitPipAlignment$.next({ + block: yRatio < 0.5 ? "start" : "end", + inline: xRatio < 0.5 ? "start" : "end", + }), + [], + ); + + return ( +
+ + {model.pip && ( + + )} + +
+ ); + }, +}); diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index ac47f0d4..f7bcefc0 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -19,7 +19,7 @@ import { useBehavior } from "../useBehavior"; */ export const makeSpotlightExpandedLayout: CallLayout< SpotlightExpandedLayoutModel -> = ({ pipAlignment$ }) => ({ +> = ({ landscapePipAlignment$ }) => ({ scrollingOnTop: true, fixed: function SpotlightExpandedLayoutFixed({ @@ -46,11 +46,11 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const pipAlignmentValue = useBehavior(pipAlignment$); + const pipAlignmentValue = useBehavior(landscapePipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment$.next({ + landscapePipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7fc97e27..b042bd59 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -53,10 +53,12 @@ import { E2eeType } from "../e2ee/e2eeType"; import { makeGridLayout } from "../grid/GridLayout"; import { type CallLayoutOutputs, - defaultPipAlignment, defaultSpotlightAlignment, + defaultPortraitPipAlignment, + defaultLandscapePipAlignment, } from "../grid/CallLayout"; -import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; +import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout"; +import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; @@ -344,8 +346,11 @@ export const InCallView: FC = ({ const spotlightAlignment$ = useInitial( () => new BehaviorSubject(defaultSpotlightAlignment), ); - const pipAlignment$ = useInitial( - () => new BehaviorSubject(defaultPipAlignment), + const portraitPipAlignment$ = useInitial( + () => new BehaviorSubject(defaultPortraitPipAlignment), + ); + const landscapePipAlignment$ = useInitial( + () => new BehaviorSubject(defaultLandscapePipAlignment), ); const setGridMode = useCallback( @@ -486,16 +491,23 @@ export const InCallView: FC = ({ const inputs = { minBounds$: gridBoundsObservable$, spotlightAlignment$, - pipAlignment$, + portraitPipAlignment$, + landscapePipAlignment$, }; return { grid: makeGridLayout(inputs), "spotlight-landscape": makeSpotlightLandscapeLayout(inputs), "spotlight-portrait": makeSpotlightPortraitLayout(inputs), "spotlight-expanded": makeSpotlightExpandedLayout(inputs), - "one-on-one": makeOneOnOneLayout(inputs), + "one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs), + "one-on-one-portrait": makeOneOnOnePortraitLayout(inputs), }; - }, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]); + }, [ + gridBoundsObservable$, + spotlightAlignment$, + portraitPipAlignment$, + landscapePipAlignment$, + ]); const renderContent = (): JSX.Element => { if (layout.type === "pip") { diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e298bcfd..c1c1d83a 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -68,7 +68,8 @@ import { setPipEnabled$ } from "../../controls"; import { TileStore } from "../TileStore"; import { gridLikeLayout } from "../GridLikeLayout"; import { spotlightExpandedLayout } from "../SpotlightExpandedLayout"; -import { oneOnOneLayout } from "../OneOnOneLayout"; +import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout"; +import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout"; import { pipLayout } from "../PipLayout"; import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement"; import { @@ -89,7 +90,8 @@ import { type GridLayoutMedia, type Layout, type LayoutMedia, - type OneOnOneLayoutMedia, + type OneOnOneLandscapeLayoutMedia, + type OneOnOnePortraitLayoutMedia, type SpotlightExpandedLayoutMedia, type SpotlightLandscapeLayoutMedia, type SpotlightPortraitLayoutMedia, @@ -1090,50 +1092,76 @@ export function createCallViewModel$( ), ); - const oneOnOneLayoutMedia$: Observable = - combineLatest([userMedia$, screenShares$]).pipe( - switchMap(([userMedia, screenShares]) => { - // One-on-one layout only supports 2 user media, no screen shares - if (userMedia.length <= 2 && screenShares.length === 0) { - const local = userMedia.find( - (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => - vm.type === "user" && vm.local, + const oneOnOneLayoutMedia$: Observable<{ + local: LocalUserMediaViewModel; + remote: UserMediaViewModel | RingingMediaViewModel; + } | null> = combineLatest([userMedia$, screenShares$]).pipe( + switchMap(([userMedia, screenShares]) => { + // One-on-one layout only supports 2 user media, no screen shares + if (userMedia.length <= 2 && screenShares.length === 0) { + const local = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => + vm.type === "user" && vm.local, + ); + + if (local !== undefined) { + const remote = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => + vm.type === "user" && !vm.local, ); - if (local !== undefined) { - const remote = userMedia.find( - ( - vm, - ): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => - vm.type === "user" && !vm.local, + if (remote !== undefined) return of({ local, remote }); + + // If there's no other user media in the call (could still happen in + // this branch due to the duplicate tiles option), we could possibly + // show ringing media instead + if (userMedia.length === 1) + return ringingMedia$.pipe( + map((ringingMedia) => { + return ringingMedia.length === 1 + ? { + local, + remote: ringingMedia[0], + } + : null; + }), ); - - if (remote !== undefined) - return of({ - type: "one-on-one" as const, - spotlight: remote, - pip: local, - }); - - // If there's no other user media in the call (could still happen in - // this branch due to the duplicate tiles option), we could possibly - // show ringing media instead - if (userMedia.length === 1) - return ringingMedia$.pipe( - map((ringingMedia) => { - return ringingMedia.length === 1 - ? { - type: "one-on-one" as const, - spotlight: local, - pip: ringingMedia[0], - } - : null; - }), - ); - } } + } - return of(null); + return of(null); + }), + ); + + const oneOnOneLandscapeLayoutMedia$: Observable = + oneOnOneLayoutMedia$.pipe( + map((media) => { + if (media === null) return null; + return media.remote.type === "ringing" + ? { + type: "one-on-one-landscape" as const, + spotlight: media.local, + pip: media.remote, + } + : { + type: "one-on-one-landscape" as const, + spotlight: media.remote, + pip: media.local, + }; + }), + ); + + const oneOnOnePortraitLayoutMedia$: Observable = + oneOnOneLayoutMedia$.pipe( + switchMap((media) => { + if (media === null) return of(null); + return media.local.videoEnabled$.pipe( + map((videoEnabled) => ({ + type: "one-on-one-portrait" as const, + spotlight: media.remote, + pip: videoEnabled ? media.local : undefined, + })), + ); }), ); @@ -1153,7 +1181,7 @@ export function createCallViewModel$( switchMap((gridMode) => { switch (gridMode) { case "grid": - return oneOnOneLayoutMedia$.pipe( + return oneOnOneLandscapeLayoutMedia$.pipe( switchMap((oneOnOne) => oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), ), @@ -1170,7 +1198,7 @@ export function createCallViewModel$( }), ); case "narrow": - return oneOnOneLayoutMedia$.pipe( + return oneOnOnePortraitLayoutMedia$.pipe( switchMap((oneOnOne) => oneOnOne === null ? combineLatest([grid$, spotlight$], (grid, spotlight) => @@ -1179,9 +1207,7 @@ export function createCallViewModel$( ? spotlightPortraitLayoutMedia$ : gridLayoutMedia$, ).pipe(switchAll()) - : // The expanded spotlight layout makes for a better one-on-one - // experience in narrow windows - spotlightExpandedLayoutMedia$, + : of(oneOnOne), ), ); case "flat": @@ -1238,8 +1264,11 @@ export function createCallViewModel$( case "spotlight-expanded": [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); break; - case "one-on-one": - [layout, newTiles] = oneOnOneLayout(media, prevTiles); + case "one-on-one-landscape": + [layout, newTiles] = oneOnOneLandscapeLayout(media, prevTiles); + break; + case "one-on-one-portrait": + [layout, newTiles] = oneOnOnePortraitLayout(media, prevTiles); break; case "pip": [layout, newTiles] = pipLayout(media, prevTiles); @@ -1290,7 +1319,8 @@ export function createCallViewModel$( // indicators. And in one-on-one layout there's no question as to who is // speaking. case "spotlight-expanded": - case "one-on-one": + case "one-on-one-landscape": + case "one-on-one-portrait": return of(false); default: return of(true); diff --git a/src/state/OneOnOneLayout.ts b/src/state/OneOnOneLandscapeLayout.ts similarity index 63% rename from src/state/OneOnOneLayout.ts rename to src/state/OneOnOneLandscapeLayout.ts index 27fa4439..7a5d2046 100644 --- a/src/state/OneOnOneLayout.ts +++ b/src/state/OneOnOneLandscapeLayout.ts @@ -1,20 +1,24 @@ /* Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./layout-types"; +import { + type OneOnOneLandscapeLayout, + type OneOnOneLandscapeLayoutMedia, +} from "./layout-types"; import { type TileStore } from "./TileStore"; /** - * Produces a one-on-one layout with the given media. + * Produces a one-on-one landscape layout with the given media. */ -export function oneOnOneLayout( - media: OneOnOneLayoutMedia, +export function oneOnOneLandscapeLayout( + media: OneOnOneLandscapeLayoutMedia, prevTiles: TileStore, -): [OneOnOneLayout, TileStore] { +): [OneOnOneLandscapeLayout, TileStore] { const update = prevTiles.from(2); update.registerGridTile(media.pip); update.registerGridTile(media.spotlight); diff --git a/src/state/OneOnOnePortraitLayout.ts b/src/state/OneOnOnePortraitLayout.ts new file mode 100644 index 00000000..afea5171 --- /dev/null +++ b/src/state/OneOnOnePortraitLayout.ts @@ -0,0 +1,34 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + type OneOnOnePortraitLayout, + type OneOnOnePortraitLayoutMedia, +} from "./layout-types"; +import { type TileStore } from "./TileStore"; + +/** + * Produces a one-on-one portrait layout with the given media. + */ +export function oneOnOnePortraitLayout( + media: OneOnOnePortraitLayoutMedia, + prevTiles: TileStore, +): [OneOnOnePortraitLayout, TileStore] { + const update = prevTiles.from(media.pip === undefined ? 1 : 2); + if (media.pip !== undefined) update.registerGridTile(media.pip); + update.registerGridTile(media.spotlight); + const tiles = update.build(); + return [ + { + type: media.type, + spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, + pip: media.pip && tiles.gridTilesByMedia.get(media.pip)!, + }, + tiles, + ]; +} diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index eeec0c88..26bf418c 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -22,6 +22,7 @@ export class GridTileViewModel { public readonly media$: Behavior< UserMediaViewModel | RingingMediaViewModel >, + public readonly showNameTag$: Behavior, ) {} } diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 2e779057..84fd8972 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -38,12 +38,18 @@ export interface SpotlightExpandedLayoutMedia { pip?: UserMediaViewModel; } -export interface OneOnOneLayoutMedia { - type: "one-on-one"; +export interface OneOnOneLandscapeLayoutMedia { + type: "one-on-one-landscape"; spotlight: UserMediaViewModel; pip: LocalUserMediaViewModel | RingingMediaViewModel; } +export interface OneOnOnePortraitLayoutMedia { + type: "one-on-one-portrait"; + spotlight: UserMediaViewModel | RingingMediaViewModel; + pip?: LocalUserMediaViewModel; +} + export interface PipLayoutMedia { type: "pip"; spotlight: MediaViewModel[]; @@ -54,7 +60,8 @@ export type LayoutMedia = | SpotlightLandscapeLayoutMedia | SpotlightPortraitLayoutMedia | SpotlightExpandedLayoutMedia - | OneOnOneLayoutMedia + | OneOnOneLandscapeLayoutMedia + | OneOnOnePortraitLayoutMedia | PipLayoutMedia; export interface GridLayout { @@ -84,12 +91,18 @@ export interface SpotlightExpandedLayout { pip?: GridTileViewModel; } -export interface OneOnOneLayout { - type: "one-on-one"; +export interface OneOnOneLandscapeLayout { + type: "one-on-one-landscape"; spotlight: GridTileViewModel; pip: GridTileViewModel; } +export interface OneOnOnePortraitLayout { + type: "one-on-one-portrait"; + spotlight: GridTileViewModel; + pip?: GridTileViewModel; +} + export interface PipLayout { type: "pip"; spotlight: SpotlightTileViewModel; @@ -104,5 +117,6 @@ export type Layout = | SpotlightLandscapeLayout | SpotlightPortraitLayout | SpotlightExpandedLayout - | OneOnOneLayout + | OneOnOneLandscapeLayout + | OneOnOnePortraitLayout | PipLayout;