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:
co-authored by
Claude Opus 4.8
parent
0aef1c5fe2
commit
bb3fb7e573
@@ -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<UserMediaViewModel | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [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<UserMediaViewModel | undefined>,
|
||||||
|
manualSpotlightUserId$: Observable<string | null>,
|
||||||
|
screenShares$: Observable<ScreenShareViewModel[]>,
|
||||||
|
localUserMediaForPip$: Observable<LocalUserMediaViewModel | undefined>,
|
||||||
|
userMedia$: Observable<UserMediaViewModel[]>,
|
||||||
|
): Observable<SpotlightAndPip> {
|
||||||
|
// 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)),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -151,6 +151,9 @@ import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts";
|
|||||||
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
||||||
import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.ts";
|
import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.ts";
|
||||||
import { type RemoteUserMediaViewModel } from "../media/RemoteUserMediaViewModel.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 {
|
import {
|
||||||
createRingingMedia,
|
createRingingMedia,
|
||||||
type RingingMediaViewModel,
|
type RingingMediaViewModel,
|
||||||
@@ -903,7 +906,7 @@ export function createCallViewModel$(
|
|||||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
||||||
).pipe(scope.share);
|
).pipe(scope.share);
|
||||||
|
|
||||||
const autoSpotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||||
userMedia$.pipe(
|
userMedia$.pipe(
|
||||||
switchMap((mediaItems) =>
|
switchMap((mediaItems) =>
|
||||||
mediaItems.length === 0
|
mediaItems.length === 0
|
||||||
@@ -939,28 +942,10 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// [lotus] Manual spotlight override (#4 focus-participant widget action).
|
// [lotus #4] Host-pinned spotlight target (io.lotus.focus_participant). null =
|
||||||
// When set to a userId still present in the call, that participant is
|
// follow the active speaker (upstream default), so this is inert unless the
|
||||||
// spotlighted instead of the auto-selected active speaker; null restores the
|
// host pins someone. Consumed by overrideSpotlight$ in spotlightAndPip$.
|
||||||
// default speaker-follows behaviour. Defaults to null, so without the host
|
|
||||||
// using io.lotus.focus_participant this is a no-op.
|
|
||||||
const manualSpotlightUserId$ = new BehaviorSubject<string | null>(null);
|
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[]>(
|
const grid$ = scope.behavior<UserMediaViewModel[]>(
|
||||||
userMedia$.pipe(
|
userMedia$.pipe(
|
||||||
@@ -1009,36 +994,17 @@ export function createCallViewModel$(
|
|||||||
if (ringingMedia.length > 0)
|
if (ringingMedia.length > 0)
|
||||||
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
|
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
|
||||||
|
|
||||||
return screenShares$.pipe(
|
// [lotus #4] Route the screenshare/spotlight computation through the
|
||||||
switchMap((screenShares) => {
|
// manual-spotlight wrapper. This is byte-for-byte upstream behaviour
|
||||||
if (screenShares.length > 0)
|
// unless the host pins a participant via io.lotus.focus_participant;
|
||||||
// [lotus #4/A5] During a screenshare, if the host has explicitly
|
// see src/lotus/lotusSpotlight.ts. Kept out-of-line so CallViewModel
|
||||||
// pinned a participant, surface that camera in the spotlight
|
// stays close to upstream and rebases cleanly.
|
||||||
// alongside the shared screen (the whole point of "focus camera
|
return overrideSpotlight$(
|
||||||
// during screenshare"). With no manual pin this is unchanged:
|
spotlightSpeaker$,
|
||||||
// the screenshare alone is spotlighted.
|
manualSpotlightUserId$,
|
||||||
return combineLatest([manualSpotlightUserId$, userMedia$]).pipe(
|
screenShares$,
|
||||||
map(([manualUserId, mediaItems]) => {
|
localUserMediaForPip$,
|
||||||
const pinned =
|
userMedia$,
|
||||||
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)),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user