Files
cinny/src/app/plugins/call/CallControl.ts
T
jared bb2e25de2d feat: mute screenshare audio independently + fix CI lint/prettier
- Add screenshareAudioMuted state to CallControlState and CallControl
- setSound() now preserves screenshare audio mute when un-deafening
- Add toggleScreenshareAudio() targeting audio[data-lk-source="screen_share_audio"]
- Add ScreenshareAudioButton (volume icon, warns when muted) to controls bar
- Fix unused prevScreenshare variable (ESLint error from prior commit)
- Run Prettier on Controls.tsx and CallControl.ts (CI formatting failures)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:52:57 -04:00

299 lines
7.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 _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<HTMLAudioElement>('audio[data-lk-source="screen_share_audio"]')
.forEach((el) => {
el.muted = this.screenshareAudioMuted;
});
}
}
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.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);
}
}