b129232f2b
CI / Build & Quality Checks (push) Successful in 10m28s
- RoomTimeline.tsx: add eslint-disable comment for intentional eventsLength dep on timelineSegments useMemo (needed to detect in-place timeline mutations) - Remove ~47 stale eslint-disable-next-line comments across 28 files for rules that are now off in the flat config (no-param-reassign, jsx-a11y/media-has-caption, react/no-array-index-key, etc); run prettier to reformat - vite.config.js: move manualChunks from rollupOptions.output to rolldownOptions.output so Rolldown (Vite 8) actually applies it; main bundle drops from 3.5 MB to 814 kB gzip-248 kB, matrix-sdk gets its own 1.16 MB cacheable chunk Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
266 lines
6.5 KiB
TypeScript
266 lines
6.5 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) => {
|
|
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);
|
|
}
|
|
}
|