From 5a8714805c8913c1c8e3e2a31cb9769b8da156c3 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 28 Sep 2026 17:29:41 +0200 Subject: [PATCH] Extract the spotlight and PiP choice into a pure function --- src/state/CallViewModel/CallViewModel.ts | 51 ++++++----------------- src/state/layoutMedia.test.ts | 52 +++++++++++++++++++++++- src/state/layoutMedia.ts | 35 ++++++++++++++++ 3 files changed, 99 insertions(+), 39 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 3e92c4e8e..f171f8947 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -104,7 +104,7 @@ import { type SpotlightPortraitLayoutMedia, type WindowMode, } from "../layout-types.ts"; -import { chooseSpotlightSpeaker } from "../layoutMedia.ts"; +import { chooseSpotlightSpeaker, computeSpotlight } from "../layoutMedia.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; @@ -1029,33 +1029,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(layoutShallowEquals)), ); const spotlight$ = scope.behavior( @@ -1147,16 +1126,12 @@ export function createCallViewModel$( edgeToEdge: boolean, ): Observable => spotlightAndPip$.pipe( - switchMap(({ spotlight, pip$ }) => - pip$.pipe( - map((pip) => ({ - type: "spotlight-expanded" as const, - edgeToEdge, - spotlight, - pip: pip ?? undefined, - })), - ), - ), + map(({ spotlight, pip }) => ({ + type: "spotlight-expanded" as const, + edgeToEdge, + spotlight, + pip, + })), ); const oneOnOneLayoutMedia$: Behavior<{ diff --git a/src/state/layoutMedia.test.ts b/src/state/layoutMedia.test.ts index 6936b1143..8e164cc9a 100644 --- a/src/state/layoutMedia.test.ts +++ b/src/state/layoutMedia.test.ts @@ -7,15 +7,22 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it } from "vitest"; -import { chooseSpotlightSpeaker } from "./layoutMedia"; +import { chooseSpotlightSpeaker, computeSpotlight } 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 = ( @@ -60,3 +67,46 @@ describe("chooseSpotlightSpeaker", () => { 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 }); + }); +}); diff --git a/src/state/layoutMedia.ts b/src/state/layoutMedia.ts index 3995f0216..64365b3a8 100644 --- a/src/state/layoutMedia.ts +++ b/src/state/layoutMedia.ts @@ -5,6 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +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"; + /** * Picks who to spotlight based on who is speaking, preferring the previous * pick so that the spotlight doesn't flip eagerly between speakers. @@ -31,3 +36,33 @@ export function chooseSpotlightSpeaker( 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, + }; +}