call: consume self-built Element Call fork + activate Lotus features
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:
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user