7c06b27c73
Element Call is now consumed as our self-built fork (@lotusguild/element-call-embedded); wire up its previously-dormant capabilities and document the fork as live. Soundboard (P5-15): a call-bar button plays user-uploaded audio clips into the call as a real published track (io.lotus.inject_audio) plus local playback. Clips are uploadable like emoji/sticker packs, stored in io.lotus.soundboard account data (synced across devices). Gated by a Settings toggle + volume. Quality controls (P5-31): per-user mic/screenshare bitrate + screenshare framerate (Settings -> Calls), applied via io.lotus.set_quality clamped to any room cap. Room admins set caps and hard call-permissions (allow_screenshare / allow_camera) in Room Settings -> Voice; the call bar hides blocked buttons. - New: CallSoundboard, useSoundboard, soundboardClips; RoomQuality, useCallQuality, callQuality (+ unit tests). - Optimistic-write RoomQuality admin UI (no stale-state clobber). - Docs: mark EC fork live across README/FEATURES/TODO/BUGS/TESTING; add D2 manual-test steps. Numeric quality caps are client-cooperative; screenshare/camera permissions are hard-enforced server-side (see LotusGuild/matrix voice-limit-guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
import { ClientWidgetApi } from 'matrix-widget-api';
|
||
import { EventEmitter } from 'events';
|
||
import { CallControlState } from './CallControlState';
|
||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||
|
||
export enum CallControlEvent {
|
||
StateUpdate = 'state_update',
|
||
}
|
||
|
||
/**
|
||
* [lotus #7 / P5-31] Payload for the fork's `io.lotus.set_quality` action.
|
||
* All fields optional; `null` clears that cap. Bits/sec for bitrates, fps for
|
||
* framerate.
|
||
*/
|
||
export type LotusQualityPayload = {
|
||
audioMaxBitrate?: number | null;
|
||
screenshareMaxBitrate?: number | null;
|
||
screenshareMaxFramerate?: number | null;
|
||
};
|
||
|
||
export class CallControl extends EventEmitter implements CallControlState {
|
||
private state: CallControlState;
|
||
|
||
private call: ClientWidgetApi;
|
||
|
||
private iframe: HTMLIFrameElement;
|
||
|
||
private bodyMutationObserver: MutationObserver;
|
||
|
||
private controlMutationObserver: MutationObserver;
|
||
|
||
private _pipMode = false;
|
||
|
||
private get document(): Document | undefined {
|
||
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||
}
|
||
|
||
private get screenshareButton(): HTMLElement | undefined {
|
||
const screenshareBtn = this.document?.querySelector(
|
||
'[data-testid="incall_screenshare"]',
|
||
) as HTMLElement | null;
|
||
|
||
return screenshareBtn ?? undefined;
|
||
}
|
||
|
||
private get leaveButton(): Element | undefined {
|
||
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
|
||
|
||
return leaveBtn ?? undefined;
|
||
}
|
||
|
||
private get settingsButton(): HTMLElement | undefined {
|
||
// EC 0.20.1: settings button moved to bottom-left; fall back to bottom-center.
|
||
const settingsButtonLeft = this.document?.querySelector(
|
||
'[data-testid="settings-bottom-left"]',
|
||
) as HTMLButtonElement | undefined;
|
||
const settingsButtonCenter = this.document?.querySelector(
|
||
'[data-testid="settings-bottom-center"]',
|
||
) as HTMLButtonElement | undefined;
|
||
|
||
return settingsButtonLeft ?? settingsButtonCenter ?? undefined;
|
||
}
|
||
|
||
private get reactionsButton(): HTMLElement | undefined {
|
||
// EC 0.20.1: reactions/raise-hand button sits just before the leave button.
|
||
const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null;
|
||
|
||
return reactionsButton ?? undefined;
|
||
}
|
||
|
||
private get spotlightButton(): HTMLInputElement | undefined {
|
||
const spotlightButton = this.document?.querySelector(
|
||
'input[value="spotlight"]',
|
||
) as HTMLInputElement | null;
|
||
|
||
return spotlightButton ?? undefined;
|
||
}
|
||
|
||
private get gridButton(): HTMLInputElement | undefined {
|
||
const gridButton = this.document?.querySelector(
|
||
'input[value="grid"]',
|
||
) as HTMLInputElement | null;
|
||
|
||
return gridButton ?? undefined;
|
||
}
|
||
|
||
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||
super();
|
||
|
||
this.state = state;
|
||
this.call = call;
|
||
this.iframe = iframe;
|
||
|
||
this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this));
|
||
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
|
||
}
|
||
|
||
public getState(): CallControlState {
|
||
return this.state;
|
||
}
|
||
|
||
public get microphone(): boolean {
|
||
return this.state.microphone;
|
||
}
|
||
|
||
public get video(): boolean {
|
||
return this.state.video;
|
||
}
|
||
|
||
public get sound(): boolean {
|
||
return this.state.sound;
|
||
}
|
||
|
||
public get screenshare(): boolean {
|
||
return this.state.screenshare;
|
||
}
|
||
|
||
public get spotlight(): boolean {
|
||
return this.state.spotlight;
|
||
}
|
||
|
||
public get screenshareAudioMuted(): boolean {
|
||
return this.state.screenshareAudioMuted;
|
||
}
|
||
|
||
public async applyState() {
|
||
await this.setMediaState({
|
||
audio_enabled: this.microphone,
|
||
video_enabled: this.video,
|
||
});
|
||
this.setSound(this.sound);
|
||
this.emitStateUpdate();
|
||
}
|
||
|
||
public async forceState(desired: CallControlState) {
|
||
this.state = new CallControlState(
|
||
desired.microphone,
|
||
desired.video,
|
||
desired.sound,
|
||
this.screenshare,
|
||
this.spotlight,
|
||
);
|
||
await this.applyState();
|
||
}
|
||
|
||
public startObserving() {
|
||
if (!this.document) return;
|
||
|
||
this.bodyMutationObserver.observe(this.document.body, {
|
||
childList: true,
|
||
subtree: false, // only direct children of body
|
||
});
|
||
this.onBodyMutation();
|
||
}
|
||
|
||
private onBodyMutation() {
|
||
if (!this.document) return;
|
||
|
||
this.document.body.style.setProperty('background', 'none', 'important');
|
||
|
||
const controls = this.leaveButton?.parentElement?.parentElement;
|
||
if (controls) {
|
||
controls.style.setProperty('position', 'absolute');
|
||
controls.style.setProperty('visibility', 'hidden');
|
||
}
|
||
|
||
this.observeControls();
|
||
}
|
||
|
||
private observeControls() {
|
||
this.controlMutationObserver.disconnect();
|
||
|
||
const screenshareBtn = this.screenshareButton;
|
||
if (screenshareBtn) {
|
||
this.controlMutationObserver.observe(screenshareBtn, {
|
||
attributes: true,
|
||
attributeFilter: ['data-kind'],
|
||
});
|
||
}
|
||
const spotlightBtn = this.spotlightButton;
|
||
if (spotlightBtn) {
|
||
this.controlMutationObserver.observe(spotlightBtn, {
|
||
attributes: true,
|
||
});
|
||
}
|
||
|
||
this.onControlMutation();
|
||
}
|
||
|
||
public applySound() {
|
||
this.setSound(this.sound);
|
||
}
|
||
|
||
private async setMediaState(state: ElementMediaStatePayload) {
|
||
// transport.send resolves once EC has ACK'd the command, which is enough to
|
||
// consider the mute applied. We deliberately do NOT gate completion on a
|
||
// follow-up DeviceMute state-echo: EC may elide it (e.g. when the requested
|
||
// state already matches its current state) or skip it during teardown,
|
||
// which would strand this promise forever and block applyState(). The echo,
|
||
// when it does arrive, is still handled authoritatively by onMediaState().
|
||
return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
|
||
}
|
||
|
||
private setSound(sound: boolean): void {
|
||
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||
if (callDocument) {
|
||
callDocument.querySelectorAll('audio').forEach((el) => {
|
||
const isScreenshareAudio = el.getAttribute('data-lk-source') === 'screen_share_audio';
|
||
el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted);
|
||
});
|
||
}
|
||
}
|
||
|
||
private applyScreenshareAudioMuted(): void {
|
||
if (!this.sound) return;
|
||
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||
if (callDocument) {
|
||
callDocument
|
||
.querySelectorAll<HTMLAudioElement>('audio[data-lk-source="screen_share_audio"]')
|
||
.forEach((el) => {
|
||
el.muted = this.screenshareAudioMuted;
|
||
});
|
||
}
|
||
}
|
||
|
||
public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) {
|
||
const { data } = evt.detail;
|
||
if (!data) return;
|
||
|
||
const state = new CallControlState(
|
||
data.audio_enabled ?? this.microphone,
|
||
data.video_enabled ?? this.video,
|
||
this.sound,
|
||
this.screenshare,
|
||
this.spotlight,
|
||
this.screenshareAudioMuted,
|
||
);
|
||
|
||
this.state = state;
|
||
this.emitStateUpdate();
|
||
|
||
if (this.microphone && !this.sound) {
|
||
this.toggleSound();
|
||
}
|
||
}
|
||
|
||
private onControlMutation() {
|
||
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||
|
||
this.state = new CallControlState(
|
||
this.microphone,
|
||
this.video,
|
||
this.sound,
|
||
screenshare,
|
||
spotlight,
|
||
this.screenshareAudioMuted,
|
||
);
|
||
this.emitStateUpdate();
|
||
}
|
||
|
||
public setMicrophone(enabled: boolean) {
|
||
const payload: ElementMediaStatePayload = {
|
||
audio_enabled: enabled,
|
||
video_enabled: this.video,
|
||
};
|
||
return this.setMediaState(payload);
|
||
}
|
||
|
||
public toggleMicrophone() {
|
||
const payload: ElementMediaStatePayload = {
|
||
audio_enabled: !this.microphone,
|
||
video_enabled: this.video,
|
||
};
|
||
return this.setMediaState(payload);
|
||
}
|
||
|
||
public toggleVideo() {
|
||
const payload: ElementMediaStatePayload = {
|
||
audio_enabled: this.microphone,
|
||
video_enabled: !this.video,
|
||
};
|
||
return this.setMediaState(payload);
|
||
}
|
||
|
||
public toggleSound() {
|
||
const sound = !this.sound;
|
||
|
||
this.setSound(sound);
|
||
// After un-deafening, re-apply screenshare audio mute if active
|
||
if (sound) this.applyScreenshareAudioMuted();
|
||
|
||
const state = new CallControlState(
|
||
this.microphone,
|
||
this.video,
|
||
sound,
|
||
this.screenshare,
|
||
this.spotlight,
|
||
this.screenshareAudioMuted,
|
||
);
|
||
this.state = state;
|
||
this.emitStateUpdate();
|
||
|
||
if (!this.sound && this.microphone) {
|
||
this.toggleMicrophone();
|
||
}
|
||
}
|
||
|
||
public toggleScreenshareAudio() {
|
||
const screenshareAudioMuted = !this.screenshareAudioMuted;
|
||
this.state = new CallControlState(
|
||
this.microphone,
|
||
this.video,
|
||
this.sound,
|
||
this.screenshare,
|
||
this.spotlight,
|
||
screenshareAudioMuted,
|
||
);
|
||
this.emitStateUpdate();
|
||
this.applyScreenshareAudioMuted();
|
||
}
|
||
|
||
public toggleScreenshare() {
|
||
this.screenshareButton?.click();
|
||
}
|
||
|
||
public toggleSpotlight() {
|
||
if (this.spotlight) {
|
||
this.gridButton?.click();
|
||
return;
|
||
}
|
||
this.spotlightButton?.click();
|
||
}
|
||
|
||
public setPipMode(pip: boolean) {
|
||
this._pipMode = pip;
|
||
}
|
||
|
||
public toggleReactions() {
|
||
this.reactionsButton?.click();
|
||
}
|
||
|
||
public toggleSettings() {
|
||
this.settingsButton?.click();
|
||
}
|
||
|
||
/**
|
||
* Focus a specific participant's camera tile in Element Call.
|
||
*
|
||
* EC renders video tiles as `[data-testid="videoTile"]`. Each tile wraps a
|
||
* mute-status indicator with `aria-label` set to the participant's Matrix
|
||
* user ID. We find the tile containing that user, switch to spotlight mode
|
||
* if needed, then click the tile so EC's internal focus handler runs.
|
||
*
|
||
* Falls back to a plain spotlight toggle if the tile is not found (e.g. the
|
||
* participant has their camera off and EC didn't render a video tile for
|
||
* them yet).
|
||
*/
|
||
public focusCameraParticipant(userId: string): void {
|
||
// [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.
|
||
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
|
||
}
|
||
|
||
/** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
|
||
public clearFocusParticipant(): void {
|
||
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
|
||
}
|
||
|
||
/**
|
||
* [lotus #3 / P5-15] Inject a soundboard clip into the call so other
|
||
* participants hear it. The fork publishes it as a separate LiveKit audio
|
||
* track (`io.lotus.inject_audio`) rather than splicing the mic. `url` must be
|
||
* an https/blob URL the widget can fetch WITHOUT credentials — the host
|
||
* resolves an mxc clip to a `blob:` object URL first (authenticated media
|
||
* can't be fetched cross-realm by the widget). `volume` is 0–1.
|
||
*
|
||
* The local user does not hear their own published track, so callers should
|
||
* also play the clip locally for feedback.
|
||
*/
|
||
public injectAudio(url: string, volume = 1): void {
|
||
this.call.transport.send('io.lotus.inject_audio', { url, volume }).catch(() => undefined);
|
||
}
|
||
|
||
/**
|
||
* [lotus #7 / P5-31] Apply audio/screenshare encoding limits to the local
|
||
* published tracks (the fork's `io.lotus.set_quality` action, via
|
||
* `RTCRtpSender.setParameters` — no republish). Bitrates are bits/sec,
|
||
* framerate is fps. A field set to `null` clears that cap. Settings are
|
||
* sticky fork-side (re-applied on every re-publish / reconnect). Values are
|
||
* clamped fork-side, so out-of-range input can't brick the encoder.
|
||
*/
|
||
public setQuality(settings: LotusQualityPayload): void {
|
||
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
|
||
}
|
||
|
||
public dispose() {
|
||
this.bodyMutationObserver.disconnect();
|
||
this.controlMutationObserver.disconnect();
|
||
}
|
||
|
||
private emitStateUpdate() {
|
||
this.emit(CallControlEvent.StateUpdate);
|
||
}
|
||
}
|