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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
39377fb6e1
commit
444af5f7ac
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<string, unknown>).catch((e) => {
|
||||||
|
logger.debug(`[lotus] host did not ack ${action}`, e);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
|||||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
|
import { startLotusCallState } from "../lotus/lotusCallState";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { GridTile } from "../tile/GridTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
@@ -279,6 +280,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
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$);
|
const fatalCallError = useBehavior(vm.fatalError$);
|
||||||
// Stop the rendering and throw for the error boundary
|
// Stop the rendering and throw for the error boundary
|
||||||
if (fatalCallError) {
|
if (fatalCallError) {
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ export interface CallViewModel {
|
|||||||
* multiple devices.
|
* multiple devices.
|
||||||
*/
|
*/
|
||||||
participantCount$: Behavior<number>;
|
participantCount$: Behavior<number>;
|
||||||
|
/** [lotus] All participants' user media, exposed for the Lotus call-state widget bridge. */
|
||||||
|
userMedia$: Behavior<WrappedUserMediaViewModel[]>;
|
||||||
allConnections$: Behavior<ConnectionManagerData>;
|
allConnections$: Behavior<ConnectionManagerData>;
|
||||||
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
||||||
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
|
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
|
||||||
@@ -1721,6 +1723,7 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
allConnections$,
|
allConnections$,
|
||||||
participantCount$: participantCount$,
|
participantCount$: participantCount$,
|
||||||
|
userMedia$,
|
||||||
handsRaised$: handsRaised$,
|
handsRaised$: handsRaised$,
|
||||||
reactions$: reactions$,
|
reactions$: reactions$,
|
||||||
joinSoundEffect$: joinSoundEffect$,
|
joinSoundEffect$: joinSoundEffect$,
|
||||||
|
|||||||
Reference in New Issue
Block a user