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 <noreply@anthropic.com>
This commit is contained in:
Lotus CI
2026-07-01 23:56:03 -04:00
co-authored by Claude Opus 4.8
parent 0aef1c5fe2
commit bb3fb7e573
2 changed files with 116 additions and 52 deletions
+18 -52
View File
@@ -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<UserMediaViewModel | undefined>(
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
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<string | null>(null);
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
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<UserMediaViewModel[]>(
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$,
);
}),
),