2026-03-07 18:03:32 +11:00
|
|
|
import { ClientWidgetApi } from 'matrix-widget-api';
|
2026-05-22 12:52:37 -04:00
|
|
|
import { EventEmitter } from 'events';
|
2026-03-07 18:03:32 +11:00
|
|
|
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;
|
|
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
private bodyMutationObserver: MutationObserver;
|
|
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
private controlMutationObserver: MutationObserver;
|
|
|
|
|
|
2026-05-24 23:16:43 -04:00
|
|
|
private _pipMode = false;
|
|
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
private mediaStatePromiseResolver: undefined | (() => void);
|
|
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
private get document(): Document | undefined {
|
|
|
|
|
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get screenshareButton(): HTMLElement | undefined {
|
|
|
|
|
const screenshareBtn = this.document?.querySelector(
|
2026-05-21 23:30:50 -04:00
|
|
|
'[data-testid="incall_screenshare"]',
|
2026-03-09 14:04:48 +11:00
|
|
|
) as HTMLElement | null;
|
|
|
|
|
|
|
|
|
|
return screenshareBtn ?? undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
private get leaveButton(): Element | undefined {
|
2026-03-09 14:04:48 +11:00
|
|
|
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
|
|
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
return leaveBtn ?? undefined;
|
|
|
|
|
}
|
2026-03-09 14:04:48 +11:00
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
private get settingsButton(): HTMLElement | undefined {
|
2026-06-23 04:11:41 -04:00
|
|
|
// EC 0.20.1: settings button moved to bottom-left; fall back to bottom-center.
|
2026-06-20 21:20:25 +10:00
|
|
|
const settingsButtonLeft = this.document?.querySelector(
|
2026-06-23 04:11:41 -04:00
|
|
|
'[data-testid="settings-bottom-left"]',
|
2026-06-20 21:20:25 +10:00
|
|
|
) as HTMLButtonElement | undefined;
|
|
|
|
|
const settingsButtonCenter = this.document?.querySelector(
|
2026-06-23 04:11:41 -04:00
|
|
|
'[data-testid="settings-bottom-center"]',
|
2026-06-20 21:20:25 +10:00
|
|
|
) as HTMLButtonElement | undefined;
|
|
|
|
|
|
|
|
|
|
return settingsButtonLeft ?? settingsButtonCenter ?? undefined;
|
2026-03-09 14:04:48 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get reactionsButton(): HTMLElement | undefined {
|
2026-06-23 04:11:41 -04:00
|
|
|
// EC 0.20.1: reactions/raise-hand button sits just before the leave button.
|
2026-06-20 21:20:25 +10:00
|
|
|
const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null;
|
2026-03-09 14:04:48 +11:00
|
|
|
|
|
|
|
|
return reactionsButton ?? undefined;
|
2026-03-09 14:04:48 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get spotlightButton(): HTMLInputElement | undefined {
|
|
|
|
|
const spotlightButton = this.document?.querySelector(
|
2026-05-21 23:30:50 -04:00
|
|
|
'input[value="spotlight"]',
|
2026-03-09 14:04:48 +11:00
|
|
|
) as HTMLInputElement | null;
|
|
|
|
|
|
|
|
|
|
return spotlightButton ?? undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get gridButton(): HTMLInputElement | undefined {
|
|
|
|
|
const gridButton = this.document?.querySelector(
|
2026-05-21 23:30:50 -04:00
|
|
|
'input[value="grid"]',
|
2026-03-09 14:04:48 +11:00
|
|
|
) as HTMLInputElement | null;
|
|
|
|
|
|
|
|
|
|
return gridButton ?? undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
|
|
|
|
super();
|
|
|
|
|
|
|
|
|
|
this.state = state;
|
|
|
|
|
this.call = call;
|
|
|
|
|
this.iframe = iframe;
|
2026-03-09 14:04:48 +11:00
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this));
|
2026-03-09 14:04:48 +11:00
|
|
|
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
|
2026-03-07 18:03:32 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
public get screenshare(): boolean {
|
|
|
|
|
return this.state.screenshare;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public get spotlight(): boolean {
|
|
|
|
|
return this.state.spotlight;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 23:52:57 -04:00
|
|
|
public get screenshareAudioMuted(): boolean {
|
|
|
|
|
return this.state.screenshareAudioMuted;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
public async applyState() {
|
|
|
|
|
await this.setMediaState({
|
|
|
|
|
audio_enabled: this.microphone,
|
|
|
|
|
video_enabled: this.video,
|
|
|
|
|
});
|
|
|
|
|
this.setSound(this.sound);
|
|
|
|
|
this.emitStateUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:14:29 -04:00
|
|
|
public async forceState(desired: CallControlState) {
|
2026-05-21 20:49:33 -04:00
|
|
|
this.state = new CallControlState(
|
|
|
|
|
desired.microphone,
|
|
|
|
|
desired.video,
|
|
|
|
|
desired.sound,
|
|
|
|
|
this.screenshare,
|
2026-05-21 23:30:50 -04:00
|
|
|
this.spotlight,
|
2026-05-21 20:49:33 -04:00
|
|
|
);
|
2026-05-14 19:14:29 -04:00
|
|
|
await this.applyState();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
public startObserving() {
|
2026-06-20 21:20:25 +10:00
|
|
|
if (!this.document) return;
|
|
|
|
|
|
|
|
|
|
this.bodyMutationObserver.observe(this.document.body, {
|
|
|
|
|
childList: true,
|
|
|
|
|
subtree: false, // only direct children of body
|
|
|
|
|
});
|
|
|
|
|
this.onBodyMutation();
|
|
|
|
|
}
|
2026-06-23 04:11:41 -04:00
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
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() {
|
2026-03-09 14:04:48 +11:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 14:22:11 +11:00
|
|
|
public applySound() {
|
|
|
|
|
this.setSound(this.sound);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
private async setMediaState(state: ElementMediaStatePayload) {
|
|
|
|
|
const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state);
|
2026-06-23 04:11:41 -04:00
|
|
|
return new Promise<typeof data>((resolve) => {
|
2026-06-20 21:20:25 +10:00
|
|
|
if (this.mediaStatePromiseResolver) {
|
|
|
|
|
this.mediaStatePromiseResolver();
|
|
|
|
|
}
|
|
|
|
|
this.mediaStatePromiseResolver = () => resolve(data);
|
|
|
|
|
});
|
2026-03-07 18:03:32 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setSound(sound: boolean): void {
|
|
|
|
|
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
|
|
|
|
if (callDocument) {
|
|
|
|
|
callDocument.querySelectorAll('audio').forEach((el) => {
|
2026-05-24 23:52:57 -04:00
|
|
|
const isScreenshareAudio = el.getAttribute('data-lk-source') === 'screen_share_audio';
|
|
|
|
|
el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted);
|
2026-03-07 18:03:32 +11:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 23:52:57 -04:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
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,
|
2026-03-09 14:04:48 +11:00
|
|
|
this.sound,
|
|
|
|
|
this.screenshare,
|
2026-05-21 23:30:50 -04:00
|
|
|
this.spotlight,
|
2026-05-24 23:52:57 -04:00
|
|
|
this.screenshareAudioMuted,
|
2026-03-07 18:03:32 +11:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.state = state;
|
|
|
|
|
this.emitStateUpdate();
|
|
|
|
|
|
|
|
|
|
if (this.microphone && !this.sound) {
|
|
|
|
|
this.toggleSound();
|
|
|
|
|
}
|
2026-06-20 21:20:25 +10:00
|
|
|
|
|
|
|
|
if (this.mediaStatePromiseResolver) {
|
|
|
|
|
this.mediaStatePromiseResolver();
|
|
|
|
|
this.mediaStatePromiseResolver = undefined;
|
|
|
|
|
}
|
2026-03-07 18:03:32 +11:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 21:20:25 +10:00
|
|
|
private onControlMutation() {
|
2026-03-09 14:04:48 +11:00
|
|
|
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,
|
2026-05-21 23:30:50 -04:00
|
|
|
spotlight,
|
2026-05-24 23:52:57 -04:00
|
|
|
this.screenshareAudioMuted,
|
2026-03-09 14:04:48 +11:00
|
|
|
);
|
|
|
|
|
this.emitStateUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 11:07:10 -04:00
|
|
|
public setMicrophone(enabled: boolean) {
|
|
|
|
|
const payload: ElementMediaStatePayload = {
|
|
|
|
|
audio_enabled: enabled,
|
|
|
|
|
video_enabled: this.video,
|
|
|
|
|
};
|
|
|
|
|
return this.setMediaState(payload);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
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);
|
2026-05-24 23:52:57 -04:00
|
|
|
// After un-deafening, re-apply screenshare audio mute if active
|
|
|
|
|
if (sound) this.applyScreenshareAudioMuted();
|
2026-03-07 18:03:32 +11:00
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
const state = new CallControlState(
|
|
|
|
|
this.microphone,
|
|
|
|
|
this.video,
|
|
|
|
|
sound,
|
|
|
|
|
this.screenshare,
|
2026-05-21 23:30:50 -04:00
|
|
|
this.spotlight,
|
2026-05-24 23:52:57 -04:00
|
|
|
this.screenshareAudioMuted,
|
2026-03-09 14:04:48 +11:00
|
|
|
);
|
2026-03-07 18:03:32 +11:00
|
|
|
this.state = state;
|
|
|
|
|
this.emitStateUpdate();
|
|
|
|
|
|
|
|
|
|
if (!this.sound && this.microphone) {
|
|
|
|
|
this.toggleMicrophone();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 23:52:57 -04:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
public toggleScreenshare() {
|
|
|
|
|
this.screenshareButton?.click();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public toggleSpotlight() {
|
|
|
|
|
if (this.spotlight) {
|
|
|
|
|
this.gridButton?.click();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.spotlightButton?.click();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 23:16:43 -04:00
|
|
|
public setPipMode(pip: boolean) {
|
|
|
|
|
this._pipMode = pip;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
public toggleReactions() {
|
|
|
|
|
this.reactionsButton?.click();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public toggleSettings() {
|
|
|
|
|
this.settingsButton?.click();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 20:39:58 -04:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2026-06-26 18:15:51 -04:00
|
|
|
// 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}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-06-18 20:39:58 -04:00
|
|
|
|
2026-06-26 18:15:51 -04:00
|
|
|
if (this.spotlight) {
|
|
|
|
|
// Already in spotlight — pin immediately.
|
|
|
|
|
applyFocus();
|
|
|
|
|
return;
|
2026-06-18 20:39:58 -04:00
|
|
|
}
|
2026-06-26 18:15:51 -04:00
|
|
|
|
|
|
|
|
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
|
|
|
|
|
// tile in the same tick would land in the old (grid) DOM. Toggle spotlight,
|
|
|
|
|
// then click on a later frame once the spotlight tiles have mounted.
|
|
|
|
|
this.spotlightButton?.click();
|
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(applyFocus));
|
2026-06-18 20:39:58 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 14:04:48 +11:00
|
|
|
public dispose() {
|
2026-06-20 21:20:25 +10:00
|
|
|
this.bodyMutationObserver.disconnect();
|
2026-03-09 14:04:48 +11:00
|
|
|
this.controlMutationObserver.disconnect();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
private emitStateUpdate() {
|
|
|
|
|
this.emit(CallControlEvent.StateUpdate);
|
|
|
|
|
}
|
|
|
|
|
}
|