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 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 settingsButton(): HTMLElement | undefined { // EC 0.19.3: settings button has data-testid="settings-bottom-center" return ( (this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ?? undefined ); } private get reactionsButton(): HTMLElement | undefined { // EC 0.19.3: reactions/raise-hand button has a CSS module class containing "raiseHand" return (this.document?.querySelector('[class*="raiseHand"]') as HTMLElement) ?? 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.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() { 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 setMediaState(state: ElementMediaStatePayload) { 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(); } } public 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(); } public dispose() { this.controlMutationObserver.disconnect(); } private emitStateUpdate() { this.emit(CallControlEvent.StateUpdate); } }