/* 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 { type BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, filter, ignoreElements, map, merge, type Observable, of, switchMap, tap, } 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"; import { type LayoutMode } from "../state/LayoutSwitchViewModel"; import { type Behavior } from "../state/Behavior"; interface SpotlightAndPip { spotlight: MediaViewModel[]; pip$: Observable; } /** * [lotus #4/#30] A host-requested spotlight pin. `id` is the EC media id * (`${userId}:${deviceId}`, the same value `io.lotus.call_state` sends the * host) and is preferred when present; `userId` alone resolves to the * currently-speaking device of that user, else their first device. */ export interface ManualSpotlight { userId: string; id: string | null; } /** * How long (ms) a pinned participant may be absent from `userMedia$` before * the pin is dropped (#16). Long enough to ride out a transient LiveKit * reconnect, short enough that a real leave restores speaker-follows promptly. */ export const MANUAL_SPOTLIGHT_GONE_MS = 5000; /** * Resolve the pin against the current media list (#30). Pure; the observable * wrapper below feeds it the live `speaking` state. */ export function resolveManualSpotlight< M extends { id: string; userId: string }, >( target: ManualSpotlight | null, mediaItems: M[], speaking: (m: M) => boolean, ): M | undefined { if (target === null) return undefined; if (target.id !== null) { const byId = mediaItems.find((m) => m.id === target.id); if (byId) return byId; // The pinned device is gone but the user may still be here on another // one — fall through to the userId match rather than dropping the pin. } const devices = mediaItems.filter((m) => m.userId === target.userId); if (devices.length <= 1) return devices[0]; return devices.find(speaking) ?? devices[0]; } /** * The pinned media item, or `undefined` when there is no pin or the pinned * participant is not (currently) in the call. */ export function pinnedMedia$( manualSpotlight$: Observable, userMedia$: Observable, ): Observable { return combineLatest([manualSpotlight$, userMedia$]).pipe( switchMap(([target, mediaItems]) => { if (target === null) return of(undefined); const devices = mediaItems.filter((m) => m.userId === target.userId); // Only subscribe to speaking$ when there is actually a choice to make. const speaking$: Observable> = devices.length > 1 && !devices.some((m) => m.id === target.id) ? combineLatest(devices.map((m) => m.speaking$)).pipe( map((flags) => new Set(devices.filter((_, i) => flags[i]))), ) : of(new Set()); return speaking$.pipe( map((speaking) => resolveManualSpotlight(target, mediaItems, (m) => speaking.has(m)), ), ); }), distinctUntilChanged(), ); } /** * [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 `manualSpotlight$` 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"); the PiP keeps showing the auto-selected speaker and is * only blanked when that speaker IS the pinned participant (#29). * * @param autoSpotlightSpeaker$ upstream's speaker-follows auto selection * @param manualSpotlight$ host pin, 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 pin) */ export function overrideSpotlight$( autoSpotlightSpeaker$: Observable, manualSpotlight$: Observable, screenShares$: Observable, localUserMediaForPip$: Observable, userMedia$: Observable, ): Observable { const pinned$ = pinnedMedia$(manualSpotlight$, userMedia$); // The effective spotlight speaker: the host-pinned participant when set and // still present, otherwise upstream's auto-selected speaker. const spotlightSpeaker$ = combineLatest( [autoSpotlightSpeaker$, pinned$], (auto, pinned) => pinned ?? 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 pinned$.pipe( map((pinned) => pinned ? { spotlight: [...screenShares, pinned], // [lotus #29] Keep the active-speaker PiP; it is only // redundant when the speaker is the pinned participant. pip$: autoSpotlightSpeaker$.pipe( map((auto) => (auto === pinned ? undefined : auto)), ), } : { 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)), ), })), ); }), ); } export interface ManualSpotlightController { /** Pin `target`, or clear with `null`. */ setManualSpotlight: (target: ManualSpotlight | null) => void; /** * Side effects that must run for the lifetime of the call. Emits nothing; * the caller subscribes it through its scope. */ effects$: Observable; } /** * [lotus #3/#16] State + side effects behind `CallViewModel.setManualSpotlight`. * * - #3: a pin only feeds `spotlight$`, which the default grid layout ignores * (and 1:1 layouts bypass entirely), so pinning forces the layout switch to * "spotlight" and remembers the mode it displaced; clearing the pin restores * that mode if the user has not switched layouts themselves meanwhile. * - #16: the pin is dropped when the pinned participant has been absent from * `userMedia$` for {@link MANUAL_SPOTLIGHT_GONE_MS} (a transient reconnect * is shorter than that), and on `leave$`. * * Pure with respect to its inputs so it can be unit-tested with fake * observables; CallViewModel only wires it up. * * @param manualSpotlight$ the pin state, owned by the caller (it has to exist * before the layout switch does, because `spotlightAndPip$` feeds into it) */ export function createManualSpotlight( manualSpotlight$: BehaviorSubject, userMedia$: Observable<{ userId: string }[]>, leave$: Observable, layout: { layout$: Behavior; setLayout: (mode: LayoutMode) => void; }, goneMs = MANUAL_SPOTLIGHT_GONE_MS, ): ManualSpotlightController { // The layout mode we displaced by forcing spotlight, if any. let displacedLayout: LayoutMode | null = null; const setManualSpotlight = (target: ManualSpotlight | null): void => { const hadPin = manualSpotlight$.value !== null; manualSpotlight$.next(target); if (target !== null) { if (!hadPin && layout.layout$.value !== "spotlight") { displacedLayout = layout.layout$.value; layout.setLayout("spotlight"); } } else if (displacedLayout !== null) { // Only restore if the user hasn't picked a layout themselves since. if (layout.layout$.value === "spotlight") layout.setLayout(displacedLayout); displacedLayout = null; } }; // #16: pinned user has left for good (absent for `goneMs`), or we hung up. const pinnedGone$ = manualSpotlight$.pipe( switchMap((target) => target === null ? of(false) : userMedia$.pipe( map((items) => !items.some((m) => m.userId === target.userId)), distinctUntilChanged(), // A transient absence shorter than goneMs collapses back to // `false` before the timer fires and is ignored. debounceTime(goneMs), filter((gone) => gone), ), ), filter((gone) => gone), ); const effects$ = merge(pinnedGone$, leave$).pipe( filter(() => manualSpotlight$.value !== null), tap(() => setManualSpotlight(null)), ignoreElements(), ); return { setManualSpotlight, effects$ }; }