From 444af5f7acfa929b74bdbf42117aa9fe8f48e267 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 29 Jun 2026 23:05:20 -0400 Subject: [PATCH] lotus(#2): stream per-participant call state over widget API Opt-in (lotusCallState=1) bridge that emits io.lotus.call_state with each participant's speaking/audio/video state, so the Lotus host can drive speaking rings / mute badges / PiP from real events instead of scraping EC's rendered DOM. Exposes vm.userMedia$ on the public CallViewModel. Additive: no-op without the flag. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusCallState.ts | 70 ++++++++++++++++++++++++ src/lotus/lotusWidget.ts | 68 +++++++++++++++++++++++ src/room/InCallView.tsx | 5 ++ src/state/CallViewModel/CallViewModel.ts | 3 + 4 files changed, 146 insertions(+) create mode 100644 src/lotus/lotusCallState.ts create mode 100644 src/lotus/lotusWidget.ts diff --git a/src/lotus/lotusCallState.ts b/src/lotus/lotusCallState.ts new file mode 100644 index 00000000..ee60d38e --- /dev/null +++ b/src/lotus/lotusCallState.ts @@ -0,0 +1,70 @@ +/* +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, of, type Subscription } from "rxjs"; +import { distinctUntilChanged, map, switchMap, throttleTime } from "rxjs/operators"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget"; + +interface ParticipantState { + /** Opaque media id (stable per tile). */ + id: string; + /** Matrix user id this media belongs to. */ + userId: string; + speaking: boolean; + audioEnabled: boolean; + videoEnabled: boolean; +} + +/** + * Stream per-participant speaking / mute / camera state to the Lotus host + * (cinny) over the widget API, so the host can drive speaking rings, mute + * badges and PiP from real events instead of scraping Element Call's rendered + * DOM (`useCallSpeakers.ts`). + * + * Opt-in: does nothing unless the host set `lotusCallState=1` on the widget + * URL. Returns a teardown function. + */ +export function startLotusCallState(vm: CallViewModel): () => void { + if (!lotusFlag("lotusCallState")) return () => undefined; + + const sub: Subscription = vm.userMedia$ + .pipe( + switchMap((members) => + members.length === 0 + ? of([] as ParticipantState[]) + : combineLatest( + members.map((m) => + combineLatest([ + m.speaking$, + m.audioEnabled$, + m.videoEnabled$, + ]).pipe( + map( + ([speaking, audioEnabled, videoEnabled]): ParticipantState => ({ + id: m.id, + userId: m.userId, + speaking, + audioEnabled, + videoEnabled, + }), + ), + ), + ), + ), + ), + // `speaking` flips rapidly; cap the send rate and drop no-op repeats. + throttleTime(150, undefined, { leading: true, trailing: true }), + distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), + ) + .subscribe((participants) => { + lotusSendToHost(LotusWidgetActions.CallState, { participants }); + }); + + return () => sub.unsubscribe(); +} diff --git a/src/lotus/lotusWidget.ts b/src/lotus/lotusWidget.ts new file mode 100644 index 00000000..00fe31ae --- /dev/null +++ b/src/lotus/lotusWidget.ts @@ -0,0 +1,68 @@ +/* +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. +*/ + +/** + * Shared helpers for the Lotus fork's widget extensions. + * + * Everything here is **opt-in**: each feature is gated behind a `lotus*` URL + * param that the Lotus host (cinny) appends to the widget iframe URL. With no + * param present, none of this code changes Element Call's behaviour — which + * keeps the fork a minimal, additive, easy-to-rebase diff over upstream. + */ + +import { logger } from "matrix-js-sdk/lib/logger"; + +import { widget } from "../widget"; + +/** 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", +} + +let cachedParams: URLSearchParams | undefined; + +/** + * Read a URL param from either the query string or the hash fragment (Element + * Call passes widget params via both depending on host), without depending on + * EC's own `getUrlParams` parser (keeps the rebase surface small). + */ +export function lotusParam(name: string): string | null { + if (!cachedParams) { + cachedParams = new URLSearchParams(window.location.search); + const hash = window.location.hash.replace(/^#\/?/, ""); + const hashQuery = hash.includes("?") ? hash.slice(hash.indexOf("?") + 1) : ""; + for (const [k, v] of new URLSearchParams(hashQuery)) { + if (!cachedParams.has(k)) cachedParams.append(k, v); + } + } + return cachedParams.get(name); +} + +/** Whether a boolean-ish Lotus feature flag is enabled. */ +export function lotusFlag(name: string): boolean { + const v = lotusParam(name); + return v === "1" || v === "true"; +} + +/** + * Send a fromWidget message to the Lotus host, swallowing the inevitable + * rejection when the host hasn't (yet) registered a handler for it. Returns + * true if the widget transport was available to attempt the send. + */ +export function lotusSendToHost(action: LotusWidgetActions, data: unknown): boolean { + const api = widget?.api; + if (!api) return false; + void api.transport.send(action, data as Record).catch((e) => { + logger.debug(`[lotus] host did not ack ${action}`, e); + }); + return true; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e704eb39..21edd77a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -29,6 +29,7 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { widget } from "../widget"; +import { startLotusCallState } from "../lotus/lotusCallState"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -279,6 +280,10 @@ export const InCallView: FC = ({ const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); + // [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]); + const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary if (fatalCallError) { diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 2bbf6f4e..39d0c0c6 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -295,6 +295,8 @@ export interface CallViewModel { * multiple devices. */ participantCount$: Behavior; + /** [lotus] All participants' user media, exposed for the Lotus call-state widget bridge. */ + userMedia$: Behavior; allConnections$: Behavior; /** Participants sorted by livekit room so they can be used in the audio rendering */ livekitRoomItems$: Behavior; @@ -1721,6 +1723,7 @@ export function createCallViewModel$( ), allConnections$, participantCount$: participantCount$, + userMedia$, handsRaised$: handsRaised$, reactions$: reactions$, joinSoundEffect$: joinSoundEffect$,