Files
cinny/src/app/plugins/call/CallControl.ts
T
root bd9dbb5e83 fix: correct settings and reactions button selectors for EC 0.19.3
EC 0.19.3 changed the toolbar layout. The old previousElementSibling
traversal from the leave button pointed at wrong elements:
- settingsButton was finding the raise-hand button
- reactionsButton was finding the screenshare button

Fix: use stable selectors instead:
- settingsButton: data-testid=settings-bottom-center (new in EC 0.19.3)
- reactionsButton: [class*=raiseHand] (CSS module class, consistent in 0.19.x)
2026-05-14 23:00:05 -04:00

256 lines
6.4 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',
}
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
private call: ClientWidgetApi;
private iframe: HTMLIFrameElement;
private controlMutationObserver: MutationObserver;
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 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) => {
// eslint-disable-next-line no-param-reassign
el.muted = !sound;
});
}
}
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.state = state;
this.emitStateUpdate();
if (this.microphone && !this.sound) {
this.toggleSound();
}
}
public onControlMutation() {
const prevScreenshare = this.screenshare;
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.emitStateUpdate();
// EC auto-switches to spotlight when screenshare starts — revert to grid
if (!prevScreenshare && screenshare) {
setTimeout(() => { if (this.spotlight) this.gridButton?.click(); }, 600);
}
}
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);
const state = new CallControlState(
this.microphone,
this.video,
sound,
this.screenshare,
this.spotlight
);
this.state = state;
this.emitStateUpdate();
if (!this.sound && this.microphone) {
this.toggleMicrophone();
}
}
public toggleScreenshare() {
this.screenshareButton?.click();
}
public toggleSpotlight() {
if (this.spotlight) {
this.gridButton?.click();
return;
}
this.spotlightButton?.click();
}
public toggleReactions() {
this.reactionsButton?.click();
}
public toggleSettings() {
this.settingsButton?.click();
}
public dispose() {
this.controlMutationObserver.disconnect();
}
private emitStateUpdate() {
this.emit(CallControlEvent.StateUpdate);
}
}