call: consume self-built Element Call fork + activate Lotus features
CI / Build & Quality Checks (push) Successful in 11m5s
CI / Trigger Desktop Build (push) Successful in 25s

Switch to @lotusguild/element-call-embedded@0.20.1-lotus.1 (our self-built
fork) and turn on the source-level features it adds:

- #1 denoise CUTOVER: in-source ML denoise (lotusDenoiseSource=1) replaces
  the build-time getUserMedia shim — removed the shim injection from
  vite.config.js (denoise/ assets still shipped; the processor loads them).
  Survives reconnects (fixes A7).
- #2 call-state: CallEmbed consumes io.lotus.call_state; useCallSpeakers /
  useRemoteAllMuted prefer it over scraping EC's DOM (DOM fallback kept;
  empty payloads ignored).
- #4 focus: CallControl.focusCameraParticipant sends io.lotus.focus_participant
  (works during screenshare), replacing the DOM tile-click hack.
- #5 theming: lotusTransparent=1 (native transparent background).
- #6 decorations: LotusDecorationPusher sends each member's decoration URL
  via io.lotus.decorations -> rendered on in-call tiles.

#3 soundboard / #7 quality ship dormant (EC-ready; no host UI sends them yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 01:33:52 -04:00
parent 149ec8e4e4
commit 89cf171efc
11 changed files with 640 additions and 135 deletions
+3
View File
@@ -17,6 +17,7 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
import { useStateEvent } from '../../hooks/useStateEvent';
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { CallMemberRenderer } from './CallMemberCard';
@@ -199,6 +200,8 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
{/* [lotus #6] push avatar decorations to EC's in-call tiles (post-join) */}
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
</Box>
);
}
@@ -0,0 +1,99 @@
import React, { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react';
import { type CallEmbed } from '../../plugins/call';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
import { decorationUrl } from './avatarDecorations';
/**
* [lotus #6] Pushes each call participant's avatar-decoration image URL to the
* forked Element Call (`io.lotus.decorations`), which renders it on the in-call
* video-tile avatars. Mounted only while joined, so the EC-side handler exists.
*
* The decoration roster is per-user slugs resolved via `useAvatarDecoration`;
* we render one invisible probe per member to reuse that hook + its cache, then
* debounce-send the aggregated `{ userId: url }` map whenever it changes.
*/
function DecorationProbe({
userId,
onResolve,
}: {
userId: string;
onResolve: (userId: string, url: string | null) => void;
}): null {
const slug = useAvatarDecoration(userId);
useEffect(() => {
onResolve(userId, slug ? decorationUrl(slug) : null);
}, [userId, slug, onResolve]);
return null;
}
export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }): ReactElement {
const session = useCallSession(callEmbed.room);
const members = useCallMembers(session);
const map = useRef<Map<string, string>>(new Map());
const pushTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const userIds = useMemo(
() => Array.from(new Set(members.map((m) => m.userId).filter((u): u is string => !!u))),
[members],
);
const push = useCallback(() => {
const decorations: Record<string, string> = {};
map.current.forEach((url, userId) => {
decorations[userId] = url;
});
void callEmbed.call.transport
.send('io.lotus.decorations', { decorations })
.catch(() => undefined);
}, [callEmbed]);
const schedulePush = useCallback(() => {
if (pushTimer.current) clearTimeout(pushTimer.current);
pushTimer.current = setTimeout(push, 300);
}, [push]);
const onResolve = useCallback(
(userId: string, url: string | null) => {
const prev = map.current.get(userId);
if (url) {
if (prev !== url) {
map.current.set(userId, url);
schedulePush();
}
} else if (prev !== undefined) {
map.current.delete(userId);
schedulePush();
}
},
[schedulePush],
);
// Drop decorations for participants who left the call.
useEffect(() => {
const present = new Set(userIds);
let changed = false;
map.current.forEach((_url, userId) => {
if (!present.has(userId)) {
map.current.delete(userId);
changed = true;
}
});
if (changed) schedulePush();
}, [userIds, schedulePush]);
useEffect(
() => () => {
if (pushTimer.current) clearTimeout(pushTimer.current);
},
[],
);
return (
<>
{userIds.map((userId) => (
<DecorationProbe key={userId} userId={userId} onResolve={onResolve} />
))}
</>
);
}
+27
View File
@@ -35,6 +35,19 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
const syncState = (): void => {
// [lotus #2] Prefer the fork's io.lotus.call_state events over scraping
// EC's rendered DOM. Falls back to the DOM path below when the fork hasn't
// sent yet (null) OR sent a spurious empty list (you're always present in
// your own joined call, so [] means "no usable data", not "nobody").
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const ls = new Set<string>();
lotus.forEach((p) => {
if (p.speaking && isUserId(p.userId)) ls.add(p.userId);
});
setSpeakers(ls);
return;
}
const doc = getDoc();
if (!doc) {
setSpeakers(new Set<string>());
@@ -91,6 +104,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
};
attachObserver();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined;
@@ -109,6 +124,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
return () => {
tileObserver?.disconnect();
bodyWatcher?.disconnect();
unsubLotus();
};
}, [callEmbed, callMembers, joined]);
@@ -137,6 +153,14 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
const localUserId = callEmbed.room.client?.getUserId() ?? '';
const syncState = (): void => {
// [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping;
// ignore a spurious empty list (fall back to DOM).
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const remote = lotus.filter((p) => p.userId !== localUserId);
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
return;
}
const doc = getDoc();
if (!doc) {
setMuted(false);
@@ -190,6 +214,8 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
};
attachObserver();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined;
@@ -208,6 +234,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
return () => {
tileObserver?.disconnect();
bodyWatcher?.disconnect();
unsubLotus();
};
}, [callEmbed]);
+13 -64
View File
@@ -346,71 +346,20 @@ export class CallControl extends EventEmitter implements CallControlState {
* them yet).
*/
public focusCameraParticipant(userId: string): void {
const doc = this.document;
if (!doc) return;
// [lotus #4] Pin the participant via the fork's widget action instead of
// DOM-poking tiles. EC's layout honors it — including surfacing the camera
// alongside a screenshare (A5) — and it's version-stable. The fork always
// acks, so the promise resolves regardless.
void this.call.transport
.send('io.lotus.focus_participant', { userId })
.catch(() => undefined);
}
// EC labels participant tiles inconsistently across versions — the user's
// matrix id may be the full aria-label, a substring of it, or carried on a
// data attribute (and sometimes the visible label is the display name, not
// the id at all). Try several strategies before giving up, then walk up to
// the enclosing video tile.
const findTile = (): HTMLElement | undefined => {
const escaped = CSS.escape(userId);
const el =
doc.querySelector<HTMLElement>(`[aria-label="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-member-id="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-id="${escaped}"]`) ??
undefined;
return (
el?.closest<HTMLElement>('[data-testid="videoTile"]') ??
el?.closest<HTMLElement>('[data-video-fit]') ??
el ??
undefined
);
};
const applyFocus = () => {
const tile = findTile();
if (tile) {
tile.click();
} else if (import.meta.env.DEV) {
console.warn(`[CallControl] focusCameraParticipant: no tile matched ${userId}`);
}
};
if (this.spotlight) {
// Already in spotlight — pin immediately.
applyFocus();
return;
}
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
// tile in the same tick would land in the old (grid) DOM. A fixed frame
// delay is unreliable (EC's React commit can exceed it on slow devices), so
// watch the iframe DOM for a spotlight video tile to mount, then focus —
// with a hard timeout so the click is always attempted at least once.
this.spotlightButton?.click();
const tileSelector = '[data-testid="videoTile"]';
let settled = false;
let observer: MutationObserver | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
observer?.disconnect();
applyFocus();
};
observer = new MutationObserver(() => {
if (doc.querySelector(tileSelector)) finish();
});
observer.observe(doc.body, { childList: true, subtree: true });
timer = setTimeout(finish, 600);
// A tile may already be present immediately after toggling spotlight.
if (doc.querySelector(tileSelector)) finish();
/** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
public clearFocusParticipant(): void {
void this.call.transport
.send('io.lotus.focus_participant', { userId: null })
.catch(() => undefined);
}
public dispose() {
+58 -6
View File
@@ -36,6 +36,15 @@ const CALL_LOAD_WATCHDOG_MS = 25_000;
export type CallLoadErrorReason = 'timeout' | 'iframe';
/** Payload entry of the fork's io.lotus.call_state widget event (#2). */
export interface LotusCallParticipant {
id: string;
userId: string;
speaking: boolean;
audioEnabled: boolean;
videoEnabled: boolean;
}
export class CallEmbed {
private mx: MatrixClient;
@@ -47,6 +56,13 @@ export class CallEmbed {
public joined = false;
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
// until the fork sends the first one. When non-null, the speaker/mute hooks
// read it instead of scraping the EC iframe DOM.
private lotusParticipants: LotusCallParticipant[] | null = null;
private lotusCallStateListeners = new Set<() => void>();
public readonly control: CallControl;
private readonly container: HTMLElement;
@@ -148,20 +164,30 @@ export class CallEmbed {
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
lang: 'en-EN',
theme: themeKind,
// EC's built-in WebRTC suppressor: on only for 'browser' tier. For 'ml' we
// disable it here so EC doesn't do its own extra processing, and let the
// Lotus denoise shim (which keeps native NS on) handle the pipeline.
// EC's built-in WebRTC suppressor: on only for 'browser' tier. For 'ml'
// we disable it so EC captures a raw mic and the fork's in-source denoise
// TrackProcessor (lotusDenoiseSource) handles the pipeline.
noiseSuppression: (denoiseMode === 'browser').toString(),
audio: initialAudio.toString(),
video: initialVideo.toString(),
header: 'none',
// [lotus] Activate the self-built fork's in-source features (each is a
// no-op on the EC side unless its flag/action is present):
// - call-state stream (speaking/mute events) -> useCallSpeakers
// - transparent background so the room wallpaper shows through natively
lotusCallState: 'true',
lotusTransparent: 'true',
});
if (denoiseMode === 'ml') {
// Signal the Lotus denoise shim to route the mic through the ML processors.
params.append('lotusDenoise', 'ml');
// [lotus] In-source ML denoise: the fork runs RNNoise/Speex/DTLN/DFN as a
// real LiveKit audio TrackProcessor (survives reconnects — fixes A7),
// replacing the old build-time getUserMedia shim. The shim injection was
// removed from vite.config.js; the denoise/ assets are still shipped and
// loaded by the processor. lotusDenoiseSource (not lotusDenoise=ml) gates
// it so the two engines can never both run.
params.append('lotusDenoiseSource', 'true');
params.append('lotusModel', denoiseModel);
params.append('lotusNativeNS', denoiseNativeNS.toString());
params.append('lotusGate', denoiseGate.toString());
params.append('lotusGateThreshold', denoiseGateThreshold.toString());
}
@@ -318,6 +344,18 @@ export class CallEmbed {
this.disposables.push(
this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, () => {}),
);
// [lotus #2] Consume the fork's per-participant call-state stream. listenAction
// auto-replies {} so the fork's transport doesn't time out. Stored for the
// speaker/mute hooks (which prefer this over DOM scraping).
this.disposables.push(
this.listenAction('io.lotus.call_state', (evt) => {
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
this.lotusParticipants = Array.isArray(data?.participants)
? (data!.participants as LotusCallParticipant[])
: [];
this.lotusCallStateListeners.forEach((l) => l());
}),
);
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
@@ -623,6 +661,20 @@ export class CallEmbed {
}
}
/** [lotus #2] Latest io.lotus.call_state participants, or null if the fork
* hasn't sent any yet (callers then fall back to DOM scraping). */
public getLotusParticipants(): LotusCallParticipant[] | null {
return this.lotusParticipants;
}
/** [lotus #2] Subscribe to io.lotus.call_state updates. Returns an unsubscribe. */
public onLotusCallState(cb: () => void): () => void {
this.lotusCallStateListeners.add(cb);
return () => {
this.lotusCallStateListeners.delete(cb);
};
}
public listenAction<T>(type: string, callback: (event: CustomEvent<T>) => void) {
const wrapped = (ev: CustomEvent<T>) => {
ev.preventDefault();