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', } 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('audio[data-lk-source="screen_share_audio"]') .forEach((el) => { el.muted = this.screenshareAudioMuted; }); } } public onMediaState(evt: CustomEvent) { 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 { const doc = this.document; if (!doc) return; // 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(`[aria-label="${escaped}"]`) ?? doc.querySelector(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ?? doc.querySelector(`[aria-label*="${escaped}"]`) ?? doc.querySelector(`[data-member-id="${escaped}"]`) ?? doc.querySelector(`[data-id="${escaped}"]`) ?? undefined; return ( el?.closest('[data-testid="videoTile"]') ?? el?.closest('[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 | 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(); } public dispose() { this.bodyMutationObserver.disconnect(); this.controlMutationObserver.disconnect(); } private emitStateUpdate() { this.emit(CallControlEvent.StateUpdate); } }