From 9d7784b9bc35199e89be9e8db3d61d0da97d8093 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 29 Jun 2026 23:10:16 -0400 Subject: [PATCH] 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 --- src/lotus/lotusActions.ts | 26 ++++++++++++++++ src/lotus/lotusFocus.ts | 38 ++++++++++++++++++++++++ src/lotus/lotusWidget.ts | 11 ++----- src/room/InCallView.tsx | 4 +++ src/state/CallViewModel/CallViewModel.ts | 32 +++++++++++++++++++- src/widget.ts | 3 ++ 6 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 src/lotus/lotusActions.ts create mode 100644 src/lotus/lotusFocus.ts diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts new file mode 100644 index 00000000..b19e9f37 --- /dev/null +++ b/src/lotus/lotusActions.ts @@ -0,0 +1,26 @@ +/* +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. +*/ + +/** + * Custom widget actions exchanged between the Lotus host (cinny) and this fork. + * Kept dependency-free so both `widget.ts` and `lotus/*` can import it without + * a circular dependency. + */ +export enum LotusWidgetActions { + /** fromWidget: in-call per-participant speaking / mute state. */ + CallState = "io.lotus.call_state", + /** toWidget: pin/spotlight (or clear, with userId=null) a participant. */ + FocusParticipant = "io.lotus.focus_participant", + /** toWidget: mix an audio clip into the local published mic track. */ + InjectAudio = "io.lotus.inject_audio", +} + +/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */ +export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [ + LotusWidgetActions.FocusParticipant, + LotusWidgetActions.InjectAudio, +]; diff --git a/src/lotus/lotusFocus.ts b/src/lotus/lotusFocus.ts new file mode 100644 index 00000000..77b8941a --- /dev/null +++ b/src/lotus/lotusFocus.ts @@ -0,0 +1,38 @@ +/* +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 IWidgetApiRequest } from "matrix-widget-api"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { widget } from "../widget"; +import { LotusWidgetActions } from "./lotusActions"; + +/** + * Handle the host's `io.lotus.focus_participant` toWidget action (#4): pin a + * participant to the spotlight by Matrix user id, or clear it with + * `{ userId: null }`. This replaces cinny's old DOM `.click()` tile-selector + * hack with a real, layout-aware spotlight override. + * + * No effect unless the host actually sends the action, so registering the + * handler whenever we're a widget is safe. Returns a teardown function. + */ +export function startLotusFocus(vm: CallViewModel): () => void { + const w = widget; + if (!w) return () => undefined; + + const handler = (ev: CustomEvent): void => { + // Always reply so the host transport doesn't time out. + void w.api.transport.reply(ev.detail, {}); + const data = ev.detail.data as { userId?: unknown } | undefined; + const userId = typeof data?.userId === "string" ? data.userId : null; + vm.setManualSpotlight(userId); + }; + + w.lazyActions.on(LotusWidgetActions.FocusParticipant, handler); + return () => + w.lazyActions.off(LotusWidgetActions.FocusParticipant, handler); +} diff --git a/src/lotus/lotusWidget.ts b/src/lotus/lotusWidget.ts index 00fe31ae..4144b315 100644 --- a/src/lotus/lotusWidget.ts +++ b/src/lotus/lotusWidget.ts @@ -17,16 +17,9 @@ Please see LICENSE in the repository root for full details. import { logger } from "matrix-js-sdk/lib/logger"; import { widget } from "../widget"; +import { LotusWidgetActions } from "./lotusActions"; -/** Custom widget actions used between the Lotus host and this fork. */ -export enum LotusWidgetActions { - /** fromWidget: in-call per-participant speaking / mute state. */ - CallState = "io.lotus.call_state", - /** toWidget: pin/spotlight (or clear) a participant. */ - FocusParticipant = "io.lotus.focus_participant", - /** toWidget: mix an audio clip into the local published mic track. */ - InjectAudio = "io.lotus.inject_audio", -} +export { LotusWidgetActions } from "./lotusActions"; let cachedParams: URLSearchParams | undefined; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 21edd77a..5bc2b115 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -30,6 +30,7 @@ import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { widget } from "../widget"; import { startLotusCallState } from "../lotus/lotusCallState"; +import { startLotusFocus } from "../lotus/lotusFocus"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -283,6 +284,9 @@ export const InCallView: FC = ({ // [lotus] Stream per-participant speaking/mute state to the host (cinny) over // the widget API when opted in via lotusCallState=1. No-op otherwise. useEffect(() => startLotusCallState(vm), [vm]); + // [lotus] Handle the host's io.lotus.focus_participant action to pin a + // participant to the spotlight (#4). No-op unless the host sends it. + useEffect(() => startLotusFocus(vm), [vm]); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 39d0c0c6..d9cc9245 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -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( + const autoSpotlightSpeaker$ = scope.behavior( 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(null); + const spotlightSpeaker$ = scope.behavior( + 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( 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(), diff --git a/src/widget.ts b/src/widget.ts index 462fc6e0..4c4e9d1d 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -22,6 +22,7 @@ import { LazyEventEmitter } from "./LazyEventEmitter"; import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; import { ElementCallReactionEventType } from "./reactions"; +import { LOTUS_TO_WIDGET_ACTIONS } from "./lotus/lotusActions"; // Subset of the actions in element-web export enum ElementWidgetActions { @@ -103,6 +104,8 @@ export const initializeWidget = ( ElementWidgetActions.JoinCall, ElementWidgetActions.HangupCall, ElementWidgetActions.DeviceMute, + // [lotus] custom toWidget actions handled by the fork (focus, audio-inject) + ...LOTUS_TO_WIDGET_ACTIONS, ].forEach((action) => { api.on(`action:${action}`, (ev: CustomEvent) => { ev.preventDefault();