lotus(#4): focus-participant spotlight via widget action

Adds io.lotus.focus_participant (toWidget): the host can pin a participant
to the spotlight by Matrix user id (or clear with userId:null), via a
manual override injected into CallViewModel.spotlightSpeaker$. Replaces
cinny's fragile DOM .click() tile-selector focus hack. Extracts the action
enum into lotusActions.ts (no circular import) and allow-lists Lotus
toWidget actions in initializeWidget. Additive: no-op unless the host
sends the action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lotus CI
2026-06-29 23:10:16 -04:00
co-authored by Claude Opus 4.8
parent 444af5f7ac
commit 9d7784b9bc
6 changed files with 104 additions and 10 deletions
+31 -1
View File
@@ -255,6 +255,11 @@ export interface CallViewModel {
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
toggleScreenSharing: (() => void) | null;
/**
* [lotus] Pin a participant to the spotlight by Matrix user id (#4
* focus-participant). Pass null to clear and restore speaker-follows.
*/
setManualSpotlight: (userId: string | null) => void;
/**
* Whether we are sharing our screen.
*/
@@ -897,7 +902,7 @@ export function createCallViewModel$(
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
).pipe(scope.share);
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
const autoSpotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
userMedia$.pipe(
switchMap((mediaItems) =>
mediaItems.length === 0
@@ -933,6 +938,29 @@ 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.
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(
switchMap((mediaItems) => {
@@ -1699,6 +1727,8 @@ export function createCallViewModel$(
join: localMembership.requestJoinAndPublish,
leave: localMembership.requestDisconnect,
toggleScreenSharing: toggleScreenSharing,
setManualSpotlight: (userId: string | null): void =>
manualSpotlightUserId$.next(userId),
sharingScreen$: sharingScreen$,
tapScreen: (): void => screenTap$.next(),