- A non-null pin forces layout "spotlight" and remembers the displaced mode; clearing restores it only if the user hasn't switched since; gridLayoutMedia$ surfaces the pinned item for narrow mode (#3). - Pin clears when the user is gone for 5 s or on leave$ (#16). - Screenshare branch keeps pip$ = auto speaker unless it IS the pinned user (#29). - Payload accepts an optional media id (userId:deviceId) and prefers it; userId-only picks the speaking device (#30). 18 unit tests. Fixes #3 Fixes #16 Fixes #29 Fixes #30 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
262 lines
9.8 KiB
TypeScript
262 lines
9.8 KiB
TypeScript
/*
|
|
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<UserMediaViewModel | undefined>;
|
|
}
|
|
|
|
/**
|
|
* [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<ManualSpotlight | null>,
|
|
userMedia$: Observable<UserMediaViewModel[]>,
|
|
): Observable<UserMediaViewModel | undefined> {
|
|
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<Set<UserMediaViewModel>> =
|
|
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<UserMediaViewModel>());
|
|
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<UserMediaViewModel | undefined>,
|
|
manualSpotlight$: Observable<ManualSpotlight | null>,
|
|
screenShares$: Observable<ScreenShareViewModel[]>,
|
|
localUserMediaForPip$: Observable<LocalUserMediaViewModel | undefined>,
|
|
userMedia$: Observable<UserMediaViewModel[]>,
|
|
): Observable<SpotlightAndPip> {
|
|
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<never>;
|
|
}
|
|
|
|
/**
|
|
* [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<ManualSpotlight | null>,
|
|
userMedia$: Observable<{ userId: string }[]>,
|
|
leave$: Observable<unknown>,
|
|
layout: {
|
|
layout$: Behavior<LayoutMode>;
|
|
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$ };
|
|
}
|