From bb3fb7e573306a9abfdd0a93a4b6a741de91d0c7 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 1 Jul 2026 23:56:03 -0400 Subject: [PATCH] refactor(lotus-spotlight): extract manual-override into src/lotus/lotusSpotlight.ts Remove the rebase hazard from CallViewModel: upstream's spotlightSpeaker$ auto-selection had been renamed to autoSpotlightSpeaker$ and an inline manual-override + screenshare-coexistence block was spliced into spotlightAndPip$. Both diverge from upstream and would conflict on every rebase. Restore spotlightSpeaker$ to its byte-for-byte upstream form and move the [lotus #4] override into a pure wrapper, overrideSpotlight$(), invoked at a single call point in spotlightAndPip$. Behaviour is unchanged: identical to upstream while manualSpotlightUserId$ is null (the default), and preserves the "pin a participant" and "focus camera during screenshare" (#4 / A5) rules when the host sends io.lotus.focus_participant. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusSpotlight.ts | 98 ++++++++++++++++++++++++ src/state/CallViewModel/CallViewModel.ts | 70 +++++------------ 2 files changed, 116 insertions(+), 52 deletions(-) create mode 100644 src/lotus/lotusSpotlight.ts diff --git a/src/lotus/lotusSpotlight.ts b/src/lotus/lotusSpotlight.ts new file mode 100644 index 00000000..560d3185 --- /dev/null +++ b/src/lotus/lotusSpotlight.ts @@ -0,0 +1,98 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { combineLatest, map, type Observable, of, switchMap } from "rxjs"; + +import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; +import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel"; +import { type MediaViewModel } from "../state/media/MediaViewModel"; +import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; + +interface SpotlightAndPip { + spotlight: MediaViewModel[]; + pip$: Observable; +} + +/** + * [lotus #4] Manual spotlight override. + * + * Wraps upstream's auto-selected spotlight speaker so a host can pin a specific + * participant (via the `io.lotus.focus_participant` widget action). Kept as a + * pure function OUTSIDE CallViewModel so CallViewModel stays byte-close to + * upstream and rebases cleanly: CallViewModel keeps its original + * `spotlightSpeaker$` auto-selection unchanged and just routes the + * screenshare/spotlight computation through this wrapper at one call point. + * + * Behaviour is IDENTICAL to upstream whenever `manualSpotlightUserId$` stays + * `null` (the default). When it names a participant that is still present: + * - with no screenshare, that participant is spotlighted instead of the + * auto-selected active speaker; + * - during a screenshare, that participant's camera is surfaced in the + * spotlight ALONGSIDE the shared screen (#4 / A5 "focus camera during + * screenshare"), and the redundant PiP is hidden. + * + * @param autoSpotlightSpeaker$ upstream's speaker-follows auto selection + * @param manualSpotlightUserId$ host-pinned userId, or `null` for auto (default) + * @param screenShares$ current screen-share view models + * @param localUserMediaForPip$ local media suitable for the PiP + * @param userMedia$ all user media in the call (to resolve the pinned userId) + */ +export function overrideSpotlight$( + autoSpotlightSpeaker$: Observable, + manualSpotlightUserId$: Observable, + screenShares$: Observable, + localUserMediaForPip$: Observable, + userMedia$: Observable, +): Observable { + // The effective spotlight speaker: the host-pinned participant when set and + // still present, otherwise upstream's auto-selected speaker. + const spotlightSpeaker$ = combineLatest([ + autoSpotlightSpeaker$, + manualSpotlightUserId$, + userMedia$, + ]).pipe( + map(([auto, manualUserId, mediaItems]) => { + if (manualUserId !== null) { + const pinned = mediaItems.find((m) => m.userId === manualUserId); + if (pinned) return pinned; + } + return auto; + }), + ); + + return screenShares$.pipe( + switchMap((screenShares) => { + if (screenShares.length > 0) + // During a screenshare, if the host has explicitly pinned a + // participant, surface that camera in the spotlight alongside the + // shared screen (the whole point of "focus camera during screenshare"). + // With no manual pin this is unchanged: the screenshare alone is + // spotlighted. + return combineLatest([manualSpotlightUserId$, userMedia$]).pipe( + map(([manualUserId, mediaItems]) => { + const pinned = + manualUserId !== null + ? mediaItems.find((m) => m.userId === manualUserId) + : undefined; + return pinned + ? { spotlight: [...screenShares, pinned], pip$: of(undefined) } + : { 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)), + ), + })), + ); + }), + ); +} diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index b56ef1f5..17434b31 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -151,6 +151,9 @@ import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts"; import { type MediaViewModel } from "../media/MediaViewModel.ts"; import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.ts"; import { type RemoteUserMediaViewModel } from "../media/RemoteUserMediaViewModel.ts"; +// [lotus #4] Manual spotlight override, extracted so this file stays byte-close +// to upstream (see the file for the behaviour contract). +import { overrideSpotlight$ } from "../../lotus/lotusSpotlight"; import { createRingingMedia, type RingingMediaViewModel, @@ -903,7 +906,7 @@ export function createCallViewModel$( merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), ).pipe(scope.share); - const autoSpotlightSpeaker$ = scope.behavior( + const spotlightSpeaker$ = scope.behavior( userMedia$.pipe( switchMap((mediaItems) => mediaItems.length === 0 @@ -939,28 +942,10 @@ export function createCallViewModel$( ), ); - // [lotus] Manual spotlight override (#4 focus-participant widget action). - // When set to a userId still present in the call, that participant is - // spotlighted instead of the auto-selected active speaker; null restores the - // default speaker-follows behaviour. Defaults to null, so without the host - // using io.lotus.focus_participant this is a no-op. + // [lotus #4] Host-pinned spotlight target (io.lotus.focus_participant). null = + // follow the active speaker (upstream default), so this is inert unless the + // host pins someone. Consumed by overrideSpotlight$ in spotlightAndPip$. const manualSpotlightUserId$ = new BehaviorSubject(null); - const spotlightSpeaker$ = scope.behavior( - combineLatest([ - autoSpotlightSpeaker$, - manualSpotlightUserId$, - userMedia$, - ]).pipe( - map(([auto, manualUserId, mediaItems]) => { - if (manualUserId !== null) { - const pinned = mediaItems.find((m) => m.userId === manualUserId); - if (pinned) return pinned; - } - return auto; - }), - ), - undefined, - ); const grid$ = scope.behavior( userMedia$.pipe( @@ -1009,36 +994,17 @@ export function createCallViewModel$( if (ringingMedia.length > 0) return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ }); - return screenShares$.pipe( - switchMap((screenShares) => { - if (screenShares.length > 0) - // [lotus #4/A5] During a screenshare, if the host has explicitly - // pinned a participant, surface that camera in the spotlight - // alongside the shared screen (the whole point of "focus camera - // during screenshare"). With no manual pin this is unchanged: - // the screenshare alone is spotlighted. - return combineLatest([manualSpotlightUserId$, userMedia$]).pipe( - map(([manualUserId, mediaItems]) => { - const pinned = - manualUserId !== null - ? mediaItems.find((m) => m.userId === manualUserId) - : undefined; - return pinned - ? { spotlight: [...screenShares, pinned], pip$: of(undefined) } - : { 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)), - ), - })), - ); - }), + // [lotus #4] Route the screenshare/spotlight computation through the + // manual-spotlight wrapper. This is byte-for-byte upstream behaviour + // unless the host pins a participant via io.lotus.focus_participant; + // see src/lotus/lotusSpotlight.ts. Kept out-of-line so CallViewModel + // stays close to upstream and rebases cleanly. + return overrideSpotlight$( + spotlightSpeaker$, + manualSpotlightUserId$, + screenShares$, + localUserMediaForPip$, + userMedia$, ); }), ),