diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index ea7f3cc78..a58b0837f 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -30,7 +30,6 @@ import { scan, startWith, Subject, - switchAll, switchMap, switchScan, take, @@ -83,6 +82,7 @@ import { type ReactionOption, } from "../../reactions"; import { shallowEquals as shallowArrayEquals } from "../../utils/array"; +import { shallowEquals as shallowObjectEquals } from "../../utils/object"; import { type MediaDevices } from "../MediaDevices"; import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; @@ -92,17 +92,17 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; 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, + 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"; @@ -263,21 +263,10 @@ 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; -/** - * The general shape of the space the call is drawn in. Called a window because - * that is what it is in the standalone app; for a component it is the container - * the host gave us, which may be a small corner of a large window. - */ -export type WindowMode = "normal" | "narrow" | "flat" | "pip"; - interface LayoutScanState { layout: Layout | null; overflowing: boolean; @@ -981,33 +970,16 @@ export function createCallViewModel$( mediaItems.length === 0 ? of([]) : combineLatest( - mediaItems.map((m) => - m.speaking$.pipe(map((s) => [m, s] as const)), + mediaItems.map((media) => + media.speaking$.pipe(map((speaking) => ({ media, speaking }))), ), ), ), scan< - (readonly [UserMediaViewModel, boolean])[], + { media: UserMediaViewModel; speaking: boolean }[], UserMediaViewModel | undefined, undefined - >((prev, mediaItems) => { - // Only remote users that are still in the call should be sticky - const [stickyMedia, stickySpeaking] = - (!prev?.local && mediaItems.find(([m]) => m === prev)) || []; - // Decide who to spotlight: - // If the previous speaker is still speaking, stick with them rather - // than switching eagerly to someone else - return stickySpeaking - ? stickyMedia! - : // Otherwise, select any remote user who is speaking - (mediaItems.find(([m, s]) => !m.local && s)?.[0] ?? - // Otherwise, stick with the person who was last speaking - stickyMedia ?? - // Otherwise, spotlight an arbitrary remote user - mediaItems.find(([m]) => !m.local)?.[0] ?? - // Otherwise, spotlight the local user - mediaItems.find(([m]) => m.local)?.[0]); - }, undefined), + >(chooseSpotlightSpeaker, undefined), ), ); @@ -1051,33 +1023,12 @@ export function createCallViewModel$( ), ); - const spotlightAndPip$ = scope.behavior<{ - spotlight: MediaViewModel[]; - pip$: Observable; - }>( - ringingMedia$.pipe( - switchMap((ringingMedia) => { - if (ringingMedia !== null) - return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ }); - - return screenShares$.pipe( - switchMap((screenShares) => { - if (screenShares.length > 0) - return of({ spotlight: screenShares, pip$: spotlightSpeaker$ }); - - return spotlightSpeaker$.pipe( - map((speaker) => ({ - spotlight: speaker ? [speaker] : [], - // Hide PiP if redundant (i.e. if local user is already in spotlight) - pip$: localUserMediaForPip$.pipe( - map((m) => (m === speaker ? undefined : m)), - ), - })), - ); - }), - ); - }), - ), + const spotlightAndPip$ = scope.behavior( + combineLatest( + [ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$], + (ringing, screenShares, speaker, localPip) => + computeSpotlight({ ringing, screenShares, speaker, localPip }), + ).pipe(distinctUntilChanged(shallowObjectEquals)), ); const spotlight$ = scope.behavior( @@ -1135,56 +1086,7 @@ export function createCallViewModel$( hasRemoteScreenShares$, ); - const gridLayoutMedia$: Observable = combineLatest( - [grid$, spotlight$], - (grid, spotlight) => ({ - type: "grid", - edgeToEdge: false, - spotlight: spotlight.some((vm) => vm.type === "screen share") - ? spotlight - : undefined, - grid, - }), - ); - - const spotlightLandscapeLayoutMedia$ = ( - edgeToEdge: boolean, - ): Observable => - combineLatest([grid$, spotlight$], (grid, spotlight) => ({ - type: "spotlight-landscape", - edgeToEdge, - spotlight, - grid, - })); - - const spotlightPortraitLayoutMedia$: Observable = - combineLatest([grid$, spotlight$], (grid, spotlight) => ({ - type: "spotlight-portrait", - edgeToEdge: false, - spotlight, - grid, - })); - - const spotlightExpandedLayoutMedia$ = ( - edgeToEdge: boolean, - ): Observable => - spotlightAndPip$.pipe( - switchMap(({ spotlight, pip$ }) => - pip$.pipe( - map((pip) => ({ - type: "spotlight-expanded" as const, - edgeToEdge, - spotlight, - pip: pip ?? undefined, - })), - ), - ), - ); - - const oneOnOneLayoutMedia$: Behavior<{ - local: LocalUserMediaViewModel; - remote: UserMediaViewModel | RingingMediaViewModel; - } | null> = scope.behavior( + const oneOnOneLayoutMedia$: Behavior = scope.behavior( combineLatest([userMedia$, screenShares$]).pipe( switchMap(([userMedia, screenShares]) => { // One-on-one layout only supports 2 user media, no screen shares @@ -1222,47 +1124,12 @@ export function createCallViewModel$( ), ); - const oneOnOneDesktopLayoutMedia$: Observable = + const localVideoEnabled$ = scope.behavior( oneOnOneLayoutMedia$.pipe( - map((media) => { - if (media === null) return null; - return media.remote.type === "ringing" - ? { - type: "one-on-one-desktop" as const, - edgeToEdge: false, - spotlight: media.local, - pip: media.remote, - } - : { - type: "one-on-one-desktop" as const, - edgeToEdge: false, - spotlight: media.remote, - pip: media.local, - }; - }), - ); - - const oneOnOneMobileLayoutMedia$: Observable = - oneOnOneLayoutMedia$.pipe( - switchMap((media) => { - if (media === null) return of(null); - return media.local.videoEnabled$.pipe( - map((videoEnabled) => ({ - type: "one-on-one-mobile" as const, - edgeToEdge: true as const, - spotlight: media.remote, - pip: videoEnabled ? media.local : undefined, - })), - ); - }), - ); - - const pipLayoutMedia$: Observable = spotlight$.pipe( - map((spotlight) => ({ - type: "pip", - edgeToEdge: platform !== "desktop", - spotlight, - })), + switchMap((media) => + media === null ? of(false) : media.local.videoEnabled$, + ), + ), ); spotlight$ @@ -1297,66 +1164,38 @@ export function createCallViewModel$( * The media to be used to produce a layout. */ const layoutMedia$ = scope.behavior( - windowMode$.pipe( - switchMap((windowMode) => { - switch (windowMode) { - case "normal": - return layoutSwitchVm.layout$.pipe( - switchMap((layout) => { - switch (layout) { - case "grid": - return oneOnOneDesktopLayoutMedia$.pipe( - switchMap((oneOnOne) => - oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), - ), - ); - case "spotlight": - return spotlightExpanded$.pipe( - switchMap((expanded) => - expanded - ? spotlightExpandedLayoutMedia$(false) - : spotlightLandscapeLayoutMedia$(false), - ), - ); - } - }), - ); - case "narrow": - return oneOnOneMobileLayoutMedia$.pipe( - switchMap((oneOnOne) => - oneOnOne === null - ? combineLatest([grid$, spotlight$], (grid, spotlight) => - grid.length > smallMobileCallThreshold || - spotlight.some((vm) => vm.type === "screen share") - ? spotlightPortraitLayoutMedia$ - : gridLayoutMedia$, - ).pipe(switchAll()) - : of(oneOnOne), - ), - ); - case "flat": - return oneOnOneMobileLayoutMedia$.pipe( - switchMap((oneOnOne) => - oneOnOne === null - ? layoutSwitchVm.layout$.pipe( - switchMap((layout) => { - switch (layout) { - case "grid": - // Yes, grid mode actually gets you a "spotlight" layout in - // this window mode. - return spotlightLandscapeLayoutMedia$(true); - case "spotlight": - return spotlightExpandedLayoutMedia$(true); - } - }), - ) - : of(oneOnOne), - ), - ); - case "pip": - return pipLayoutMedia$; - } - }), + // We deliberately don't use combineLatest here. Several of the inputs + // below derive from the same source. So one upstream change cascades + // through them one at a time. combineLatest would see each step of the + // cascade and emit layouts built from inputs that never coexisted. + // Instead, we treat the emissions as a mere "something changed" signal + // and read every input's current value. The derived behaviors were + // subscribed before this one and a BehaviorSubject updates its value + // before notifying. So by the time the first signal arrives every value + // is already consistent. The duplicate signals from the rest of the + // cascade are dropped by distinctUntilChanged. + merge( + windowMode$, + layoutSwitchVm.layout$, + spotlightExpanded$, + oneOnOneLayoutMedia$, + localVideoEnabled$, + spotlightAndPip$, + grid$, + ).pipe( + map(() => + computeLayoutMedia({ + windowMode: windowMode$.value, + layoutMode: layoutSwitchVm.layout$.value, + spotlightExpanded: spotlightExpanded$.value, + oneOnOne: oneOnOneLayoutMedia$.value, + localVideoEnabled: localVideoEnabled$.value, + ...spotlightAndPip$.value, + grid: grid$.value, + desktop: platform === "desktop", + }), + ), + distinctUntilChanged(shallowObjectEquals), ), ); @@ -1657,7 +1496,7 @@ export function createCallViewModel$( layoutInternals$.pipe( map(({ layout }) => layout), // Drop redundant layout updates before they would hit React. - distinctUntilChanged(layoutShallowEquals), + distinctUntilChanged(shallowObjectEquals), ), ); diff --git a/src/state/LayoutSwitchViewModel.ts b/src/state/LayoutSwitchViewModel.ts index 6e61abaaa..f495c6275 100644 --- a/src/state/LayoutSwitchViewModel.ts +++ b/src/state/LayoutSwitchViewModel.ts @@ -14,7 +14,7 @@ import { switchAll, } from "rxjs"; -import { type WindowMode } from "./CallViewModel/CallViewModel.ts"; +import { type WindowMode } from "./layout-types.ts"; import { constant, type Behavior } from "./Behavior.ts"; import { type ObservableScope } from "./ObservableScope.ts"; diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts deleted file mode 100644 index 468c15bb5..000000000 --- a/src/state/layout-types.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* -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 { test, expect } from "vitest"; -import { - type Alignment, - layoutShallowEquals, - type Layout, -} from "./layout-types"; -import { - type SpotlightTileViewModel, - type GridTileViewModel, -} from "./TileViewModel"; -import { BehaviorSubject } from "rxjs"; - -const spotlightTile = {} as unknown as SpotlightTileViewModel; -const gridTile = {} as unknown as GridTileViewModel; -const pipAlignment$ = new BehaviorSubject({ - inline: "end", - block: "end", -}); - -const spotlightExpanded: Layout = { - type: "spotlight-expanded", - spotlight: spotlightTile, - pipAlignment$, -}; - -const spotlightPortrait: Layout = { - type: "spotlight-portrait", - spotlight: spotlightTile, - grid: [gridTile], - setVisibleTiles: () => {}, -}; - -test("layoutShallowEquals considers a layout to be equal to its shallow clone", () => - expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe( - true, - )); - -test("layoutShallowEquals detects a missing key", () => { - expect( - layoutShallowEquals(spotlightExpanded, { - ...spotlightExpanded, - pip: gridTile, - }), - ).toBe(false); - expect( - layoutShallowEquals( - { ...spotlightExpanded, pip: gridTile }, - spotlightExpanded, - ), - ).toBe(false); -}); - -test("layoutShallowEquals considers grid arrays with equal contents to be equal", () => - expect( - layoutShallowEquals(spotlightPortrait, { - ...spotlightPortrait, - grid: [...spotlightPortrait.grid], - }), - ).toBe(true)); - -test("layoutShallowEquals detects grid arrays with different contents", () => - expect( - layoutShallowEquals(spotlightPortrait, { - ...spotlightPortrait, - grid: [...spotlightPortrait.grid, gridTile], - }), - ).toBe(false)); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index be86f2a36..3d80b361e 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -16,7 +16,13 @@ import { type SpotlightTileViewModel, } from "./TileViewModel.ts"; import { type Behavior } from "./Behavior.ts"; -import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; + +/** + * The general shape of the space the call is drawn in. Called a window because + * that is what it is in the standalone app; for a component it is the container + * the host gave us, which may be a small corner of a large window. + */ +export type WindowMode = "normal" | "narrow" | "flat" | "pip"; export interface GridLayoutMedia { type: "grid"; @@ -141,30 +147,3 @@ export type Layout = | OneOnOneDesktopLayout | OneOnOneMobileLayout | PipLayout; - -/** - * Tests whether the top-level properties and array elements of layout `a` are - * equal to those of layout `b`. Useful for deduping redundant layout updates. - */ -export function layoutShallowEquals(a: Layout, b: Layout): boolean { - // If a and b have the same number of keys and every key in a is also in b, - // then they have the same keys. - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - - for (const key of aKeys) { - if (!(key in b)) return false; - - // Now check that they have the same values. - const aValue = (a as any)[key]; - const bValue = (b as any)[key]; - if (Array.isArray(aValue) && Array.isArray(bValue)) { - // Special case for arrays so we can detect when the grid tiles arrays are - // essentially the same. - if (!arrayShallowEquals(aValue, bValue)) return false; - } else if (aValue !== bValue) return false; - } - - return true; -} diff --git a/src/state/layoutMedia.test.ts b/src/state/layoutMedia.test.ts new file mode 100644 index 000000000..afa159877 --- /dev/null +++ b/src/state/layoutMedia.test.ts @@ -0,0 +1,220 @@ +/* +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 { describe, expect, it } from "vitest"; + +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"; +import { type ScreenShareViewModel } from "./media/ScreenShareViewModel"; + +// The layout functions only look at `type` and `local`, so stubs suffice +const local = { type: "user", local: true } as LocalUserMediaViewModel; +const alice = { type: "user", local: false } as RemoteUserMediaViewModel; +const bob = { type: "user", local: false } as RemoteUserMediaViewModel; +const carol = { type: "user", local: false } as RemoteUserMediaViewModel; +const screenShare = { + type: "screen share", + local: false, +} as ScreenShareViewModel; +const ringing = { type: "ringing" } as RingingMediaViewModel; + +describe("chooseSpotlightSpeaker", () => { + const items = ( + ...speaking: boolean[] + ): { + media: RemoteUserMediaViewModel | LocalUserMediaViewModel; + speaking: boolean; + }[] => + [local, alice, bob].map((media, i) => ({ + media, + speaking: speaking[i] ?? false, + })); + + it("prefers a remote speaker over the local user", () => { + expect(chooseSpotlightSpeaker(undefined, items(true, false, true))).toBe( + bob, + ); + }); + + it("falls back to any remote user, then the local user", () => { + expect(chooseSpotlightSpeaker(undefined, items())).toBe(alice); + expect( + chooseSpotlightSpeaker(undefined, [{ media: local, speaking: false }]), + ).toBe(local); + expect(chooseSpotlightSpeaker(undefined, [])).toBeUndefined(); + }); + + it("sticks with the previous speaker while they keep speaking", () => { + expect(chooseSpotlightSpeaker(bob, items(false, true, true))).toBe(bob); + }); + + it("sticks with the previous speaker when nobody is speaking", () => { + expect(chooseSpotlightSpeaker(bob, items())).toBe(bob); + }); + + it("switches when someone else speaks and the previous speaker is silent", () => { + expect(chooseSpotlightSpeaker(bob, items(false, true, false))).toBe(alice); + }); + + it("forgets a previous speaker who left, and never sticks to the local user", () => { + expect(chooseSpotlightSpeaker(carol, items())).toBe(alice); + expect(chooseSpotlightSpeaker(local, items())).toBe(alice); + }); +}); + +describe("computeSpotlight", () => { + it("spotlights ringing media with the local user in the PiP", () => { + expect( + computeSpotlight({ + ringing, + screenShares: [screenShare], + speaker: alice, + localPip: local, + }), + ).toEqual({ spotlight: [ringing], pip: local }); + }); + + it("spotlights screen shares with the speaker in the PiP", () => { + expect( + computeSpotlight({ + ringing: null, + screenShares: [screenShare], + speaker: alice, + localPip: local, + }), + ).toEqual({ spotlight: [screenShare], pip: alice }); + }); + + it("otherwise spotlights the speaker and hides a redundant PiP", () => { + expect( + computeSpotlight({ + ringing: null, + screenShares: [], + speaker: alice, + localPip: local, + }), + ).toEqual({ spotlight: [alice], pip: local }); + expect( + computeSpotlight({ + ringing: null, + screenShares: [], + speaker: local, + localPip: local, + }), + ).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 new file mode 100644 index 000000000..56dec9979 --- /dev/null +++ b/src/state/layoutMedia.ts @@ -0,0 +1,216 @@ +/* +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 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. + * + * @param prev The previous pick, if any. + * @param items All user media in the call along with whether each is speaking. + */ +export function chooseSpotlightSpeaker( + prev: M | undefined, + items: readonly { media: M; speaking: boolean }[], +): M | undefined { + // Only remote users that are still in the call should be sticky + const sticky = items.find((i) => i.media === prev && !i.media.local); + // If the previous speaker is still speaking, stick with them + if (sticky?.speaking) return sticky.media; + return ( + // Otherwise, select any remote user who is speaking + items.find((i) => !i.media.local && i.speaking)?.media ?? + // Otherwise, stick with the person who was last speaking + sticky?.media ?? + // Otherwise, spotlight an arbitrary remote user + items.find((i) => !i.media.local)?.media ?? + // Otherwise, spotlight the local user + items.find((i) => i.media.local)?.media + ); +} + +export interface SpotlightInputs { + ringing: RingingMediaViewModel | null; + screenShares: ScreenShareViewModel[]; + speaker: UserMediaViewModel | undefined; + /** Local user media suitable for showing in a PiP, if any. */ + localPip: UserMediaViewModel | undefined; +} + +/** + * Decides what goes in the spotlight, and what goes in the picture-in-picture + * tile of the expanded spotlight layout. + */ +export function computeSpotlight({ + ringing, + screenShares, + speaker, + localPip, +}: SpotlightInputs): { + spotlight: MediaViewModel[]; + pip: UserMediaViewModel | undefined; +} { + if (ringing !== null) return { spotlight: [ringing], pip: localPip }; + if (screenShares.length > 0) return { spotlight: screenShares, pip: speaker }; + return { + spotlight: speaker ? [speaker] : [], + // Hide PiP if redundant (i.e. if local user is already in spotlight) + 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 }; +} diff --git a/src/utils/object.test.ts b/src/utils/object.test.ts new file mode 100644 index 000000000..6b55ac7f3 --- /dev/null +++ b/src/utils/object.test.ts @@ -0,0 +1,40 @@ +/* +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 { test, expect } from "vitest"; +import { shallowEquals } from "./object"; + +interface Thing { + type: string; + items: object[]; + value: number; + extra?: object; +} + +const item = {}; +const withArray: Thing = { type: "a", items: [item], value: 1 }; + +test("shallowEquals considers an object to be equal to its shallow clone", () => + expect(shallowEquals(withArray, { ...withArray })).toBe(true)); + +test("shallowEquals detects a missing key", () => { + expect(shallowEquals(withArray, { ...withArray, extra: item })).toBe(false); + expect(shallowEquals({ ...withArray, extra: item }, withArray)).toBe(false); +}); + +test("shallowEquals detects a differing value", () => + expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false)); + +test("shallowEquals considers arrays with equal contents to be equal", () => + expect( + shallowEquals(withArray, { ...withArray, items: [...withArray.items] }), + ).toBe(true)); + +test("shallowEquals detects arrays with different contents", () => + expect( + shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }), + ).toBe(false)); diff --git a/src/utils/object.ts b/src/utils/object.ts new file mode 100644 index 000000000..7fc7b5b1d --- /dev/null +++ b/src/utils/object.ts @@ -0,0 +1,38 @@ +/* +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 { shallowEquals as arrayShallowEquals } from "./array.ts"; + +/** + * Determine whether two objects are equal by shallow comparison of their + * top-level properties. Array-valued properties are themselves compared + * shallowly, so two objects holding distinct arrays with the same elements are + * considered equal. + */ +export function shallowEquals( + a: Readonly, + b: Readonly, +): boolean { + // If a and b have the same number of keys and every key in a is also in b, + // then they have the same keys. + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!(key in b)) return false; + + // Now check that they have the same values. + const aValue = (a as any)[key]; + const bValue = (b as any)[key]; + if (Array.isArray(aValue) && Array.isArray(bValue)) { + if (!arrayShallowEquals(aValue, bValue)) return false; + } else if (aValue !== bValue) return false; + } + + return true; +}