diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 76675c824..ae6a54697 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -93,17 +93,16 @@ import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { layoutShallowEquals, type Alignment, - type GridLayoutMedia, type Layout, type LayoutMedia, - type OneOnOneDesktopLayoutMedia, - type OneOnOneMobileLayoutMedia, - type SpotlightExpandedLayoutMedia, - type SpotlightLandscapeLayoutMedia, - type SpotlightPortraitLayoutMedia, type WindowMode, } from "../layout-types.ts"; -import { chooseSpotlightSpeaker, computeSpotlight } from "../layoutMedia.ts"; +import { + chooseSpotlightSpeaker, + computeLayoutMedia, + computeSpotlight, + type OneOnOneMedia, +} from "../layoutMedia.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; @@ -147,7 +146,6 @@ import { import { Publisher } from "./localMember/Publisher.ts"; import { type Connection } from "./remoteMembers/Connection.ts"; import { - type LayoutMode, type LayoutSwitchViewModel, createLayoutSwitchViewModel, } from "../LayoutSwitchViewModel.ts"; @@ -265,10 +263,6 @@ export function callViewModelOptionsFromParams( export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; export const THROTTLE_SOUND_EFFECT_MS = 500; -// This is the number of participants that we think constitutes a "small" call -// on mobile. No spotlight tile should be shown below this threshold. -const smallMobileCallThreshold = 3; - // How long the footer should be shown for when hovering over or interacting // with the interface const showFooterMs = 4000; @@ -1773,133 +1767,3 @@ function getE2eeKeyProvider( return keyProvider; } } - -interface OneOnOneMedia { - local: LocalUserMediaViewModel; - remote: UserMediaViewModel | RingingMediaViewModel; -} - -interface LayoutMediaInputs { - windowMode: WindowMode; - /** The layout chosen with the layout switch. */ - layoutMode: LayoutMode; - spotlightExpanded: boolean; - /** Set when the call qualifies for a one-on-one layout. */ - oneOnOne: OneOnOneMedia | null; - localVideoEnabled: boolean; - spotlight: MediaViewModel[]; - grid: UserMediaViewModel[]; - pip: UserMediaViewModel | undefined; - desktop: boolean; -} - -/** - * Decides which layout to use and which media goes where in it. - */ -function computeLayoutMedia({ - windowMode, - layoutMode, - spotlightExpanded, - oneOnOne, - localVideoEnabled, - spotlight, - grid, - pip, - desktop, -}: LayoutMediaInputs): LayoutMedia { - switch (windowMode) { - case "normal": - if (layoutMode === "grid") - return oneOnOne === null - ? gridLayoutMedia(spotlight, grid) - : oneOnOneDesktopLayoutMedia(oneOnOne); - return spotlightExpanded - ? spotlightExpandedLayoutMedia(false, spotlight, pip) - : spotlightLandscapeLayoutMedia(false, spotlight, grid); - case "narrow": - if (oneOnOne !== null) - return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); - return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight) - ? spotlightPortraitLayoutMedia(spotlight, grid) - : gridLayoutMedia(spotlight, grid); - case "flat": - if (oneOnOne !== null) - return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); - // Yes, grid mode actually gets you a "spotlight" layout in this window - // mode. - return layoutMode === "grid" - ? spotlightLandscapeLayoutMedia(true, spotlight, grid) - : spotlightExpandedLayoutMedia(true, spotlight, pip); - case "pip": - return { type: "pip", edgeToEdge: !desktop, spotlight }; - } -} - -function hasScreenShare(spotlight: MediaViewModel[]): boolean { - return spotlight.some((vm) => vm.type === "screen share"); -} - -function gridLayoutMedia( - spotlight: MediaViewModel[], - grid: UserMediaViewModel[], -): GridLayoutMedia { - return { - type: "grid", - edgeToEdge: false, - spotlight: hasScreenShare(spotlight) ? spotlight : undefined, - grid, - }; -} - -function oneOnOneDesktopLayoutMedia( - media: OneOnOneMedia, -): OneOnOneDesktopLayoutMedia { - return media.remote.type === "ringing" - ? { - type: "one-on-one-desktop", - edgeToEdge: false, - spotlight: media.local, - pip: media.remote, - } - : { - type: "one-on-one-desktop", - edgeToEdge: false, - spotlight: media.remote, - pip: media.local, - }; -} - -function spotlightExpandedLayoutMedia( - edgeToEdge: boolean, - spotlight: MediaViewModel[], - pip: UserMediaViewModel | undefined, -): SpotlightExpandedLayoutMedia { - return { type: "spotlight-expanded", edgeToEdge, spotlight, pip }; -} - -function spotlightLandscapeLayoutMedia( - edgeToEdge: boolean, - spotlight: MediaViewModel[], - grid: UserMediaViewModel[], -): SpotlightLandscapeLayoutMedia { - return { type: "spotlight-landscape", edgeToEdge, spotlight, grid }; -} - -function oneOnOneMobileLayoutMedia( - media: OneOnOneMedia, - localVideoEnabled: boolean, -): OneOnOneMobileLayoutMedia { - return { - type: "one-on-one-mobile", - edgeToEdge: true, - spotlight: media.remote, - pip: localVideoEnabled ? media.local : undefined, - }; -} - -function spotlightPortraitLayoutMedia( - spotlight: MediaViewModel[], - grid: UserMediaViewModel[], -): SpotlightPortraitLayoutMedia { - return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid }; -} diff --git a/src/state/layoutMedia.test.ts b/src/state/layoutMedia.test.ts index 8e164cc9a..afa159877 100644 --- a/src/state/layoutMedia.test.ts +++ b/src/state/layoutMedia.test.ts @@ -7,7 +7,12 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it } from "vitest"; -import { chooseSpotlightSpeaker, computeSpotlight } from "./layoutMedia"; +import { + chooseSpotlightSpeaker, + computeLayoutMedia, + computeSpotlight, + type LayoutMediaInputs, +} from "./layoutMedia"; import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; @@ -110,3 +115,106 @@ describe("computeSpotlight", () => { ).toEqual({ spotlight: [local], pip: undefined }); }); }); + +describe("computeLayoutMedia", () => { + const base: LayoutMediaInputs = { + windowMode: "normal", + layoutMode: "grid", + spotlightExpanded: false, + oneOnOne: null, + localVideoEnabled: true, + spotlight: [alice], + grid: [local, alice, bob], + pip: local, + desktop: false, + }; + const layout = ( + overrides: Partial, + ): ReturnType => + computeLayoutMedia({ ...base, ...overrides }); + + it("uses a plain grid unless a screen share needs the spotlight", () => { + expect(layout({})).toEqual({ + type: "grid", + edgeToEdge: false, + spotlight: undefined, + grid: base.grid, + }); + expect(layout({ spotlight: [screenShare] }).spotlight).toEqual([ + screenShare, + ]); + }); + + it("switches between landscape and expanded spotlight", () => { + expect(layout({ layoutMode: "spotlight" })).toMatchObject({ + type: "spotlight-landscape", + edgeToEdge: false, + }); + expect( + layout({ layoutMode: "spotlight", spotlightExpanded: true }), + ).toEqual({ + type: "spotlight-expanded", + edgeToEdge: false, + spotlight: [alice], + pip: local, + }); + }); + + it("puts the remote user in the desktop one-on-one spotlight, or the local user when ringing", () => { + expect(layout({ oneOnOne: { local, remote: alice } })).toEqual({ + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: alice, + pip: local, + }); + expect(layout({ oneOnOne: { local, remote: ringing } })).toMatchObject({ + spotlight: local, + pip: ringing, + }); + }); + + it("uses a mobile one-on-one layout in narrow and flat windows, hiding the PiP without video", () => { + const oneOnOne = { local, remote: alice }; + expect(layout({ windowMode: "narrow", oneOnOne })).toEqual({ + type: "one-on-one-mobile", + edgeToEdge: true, + spotlight: alice, + pip: local, + }); + expect( + layout({ windowMode: "flat", oneOnOne, localVideoEnabled: false }), + ).toMatchObject({ type: "one-on-one-mobile", pip: undefined }); + }); + + it("uses a portrait spotlight in narrow windows for big calls or screen shares", () => { + expect(layout({ windowMode: "narrow" }).type).toBe("grid"); + expect( + layout({ windowMode: "narrow", grid: [local, alice, bob, carol] }).type, + ).toBe("spotlight-portrait"); + expect( + layout({ windowMode: "narrow", spotlight: [screenShare] }).type, + ).toBe("spotlight-portrait"); + }); + + it("uses edge-to-edge spotlight layouts in flat windows", () => { + expect(layout({ windowMode: "flat" })).toMatchObject({ + type: "spotlight-landscape", + edgeToEdge: true, + }); + expect( + layout({ windowMode: "flat", layoutMode: "spotlight" }), + ).toMatchObject({ + type: "spotlight-expanded", + edgeToEdge: true, + }); + }); + + it("uses the PiP layout, edge to edge except on desktop", () => { + expect(layout({ windowMode: "pip" })).toEqual({ + type: "pip", + edgeToEdge: true, + spotlight: [alice], + }); + expect(layout({ windowMode: "pip", desktop: true }).edgeToEdge).toBe(false); + }); +}); diff --git a/src/state/layoutMedia.ts b/src/state/layoutMedia.ts index 64365b3a8..56dec9979 100644 --- a/src/state/layoutMedia.ts +++ b/src/state/layoutMedia.ts @@ -5,11 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { type LayoutMode } from "./LayoutSwitchViewModel.ts"; +import { + type GridLayoutMedia, + type LayoutMedia, + type OneOnOneDesktopLayoutMedia, + type OneOnOneMobileLayoutMedia, + type SpotlightExpandedLayoutMedia, + type SpotlightLandscapeLayoutMedia, + type SpotlightPortraitLayoutMedia, + type WindowMode, +} from "./layout-types.ts"; +import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; import { type ScreenShareViewModel } from "./media/ScreenShareViewModel.ts"; import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts"; +/** + * This is the number of participants that we think constitutes a "small" call + * on mobile. No spotlight tile should be shown below this threshold. + */ +const smallMobileCallThreshold = 3; + /** * Picks who to spotlight based on who is speaking, preferring the previous * pick so that the spotlight doesn't flip eagerly between speakers. @@ -66,3 +84,133 @@ export function computeSpotlight({ pip: localPip === speaker ? undefined : localPip, }; } + +export interface OneOnOneMedia { + local: LocalUserMediaViewModel; + remote: UserMediaViewModel | RingingMediaViewModel; +} + +export interface LayoutMediaInputs { + windowMode: WindowMode; + /** The layout chosen with the layout switch. */ + layoutMode: LayoutMode; + spotlightExpanded: boolean; + /** Set when the call qualifies for a one-on-one layout. */ + oneOnOne: OneOnOneMedia | null; + localVideoEnabled: boolean; + spotlight: MediaViewModel[]; + grid: UserMediaViewModel[]; + pip: UserMediaViewModel | undefined; + desktop: boolean; +} + +/** + * Decides which layout to use and which media goes where in it. + */ +export function computeLayoutMedia({ + windowMode, + layoutMode, + spotlightExpanded, + oneOnOne, + localVideoEnabled, + spotlight, + grid, + pip, + desktop, +}: LayoutMediaInputs): LayoutMedia { + switch (windowMode) { + case "normal": + if (layoutMode === "grid") + return oneOnOne === null + ? gridLayoutMedia(spotlight, grid) + : oneOnOneDesktopLayoutMedia(oneOnOne); + return spotlightExpanded + ? spotlightExpandedLayoutMedia(false, spotlight, pip) + : spotlightLandscapeLayoutMedia(false, spotlight, grid); + case "narrow": + if (oneOnOne !== null) + return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); + return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight) + ? spotlightPortraitLayoutMedia(spotlight, grid) + : gridLayoutMedia(spotlight, grid); + case "flat": + if (oneOnOne !== null) + return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); + // Yes, grid mode actually gets you a "spotlight" layout in this window + // mode. + return layoutMode === "grid" + ? spotlightLandscapeLayoutMedia(true, spotlight, grid) + : spotlightExpandedLayoutMedia(true, spotlight, pip); + case "pip": + return { type: "pip", edgeToEdge: !desktop, spotlight }; + } +} + +function hasScreenShare(spotlight: MediaViewModel[]): boolean { + return spotlight.some((vm) => vm.type === "screen share"); +} + +function gridLayoutMedia( + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): GridLayoutMedia { + return { + type: "grid", + edgeToEdge: false, + spotlight: hasScreenShare(spotlight) ? spotlight : undefined, + grid, + }; +} + +function oneOnOneDesktopLayoutMedia( + media: OneOnOneMedia, +): OneOnOneDesktopLayoutMedia { + return media.remote.type === "ringing" + ? { + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: media.local, + pip: media.remote, + } + : { + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: media.remote, + pip: media.local, + }; +} + +function spotlightExpandedLayoutMedia( + edgeToEdge: boolean, + spotlight: MediaViewModel[], + pip: UserMediaViewModel | undefined, +): SpotlightExpandedLayoutMedia { + return { type: "spotlight-expanded", edgeToEdge, spotlight, pip }; +} + +function spotlightLandscapeLayoutMedia( + edgeToEdge: boolean, + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): SpotlightLandscapeLayoutMedia { + return { type: "spotlight-landscape", edgeToEdge, spotlight, grid }; +} + +function oneOnOneMobileLayoutMedia( + media: OneOnOneMedia, + localVideoEnabled: boolean, +): OneOnOneMobileLayoutMedia { + return { + type: "one-on-one-mobile", + edgeToEdge: true, + spotlight: media.remote, + pip: localVideoEnabled ? media.local : undefined, + }; +} + +function spotlightPortraitLayoutMedia( + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): SpotlightPortraitLayoutMedia { + return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid }; +}