Files
element-call/src/lotus/lotusDeafen.ts
T

121 lines
4.8 KiB
TypeScript
Raw Normal View History

/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type RemoteParticipant,
type Room as LivekitRoom,
RoomEvent,
Track,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { widget } from "../widget";
import { LotusWidgetActions } from "./lotusActions";
/**
* Handle the host's `io.lotus.set_deafen` toWidget action: silence remote audio
* at the LiveKit source, replacing cinny's brittle iframe-DOM `.muted` hack
* (which fought MatrixAudioRenderer and broke on re-render / late tracks).
*
* `deafened` mutes every remote participant's microphone AND screenshare audio;
* `screenshareAudioMuted` mutes only the screenshare audio (so the host can
* drop shared-tab/game audio while still hearing voices). Volume is set PER
* SOURCE via `RemoteParticipant.setVolume(volume, source)` — whose verified
* signature in livekit-client ^2.18.1 is
* `setVolume(volume, source?: Track.Source.Microphone | Track.Source.ScreenShareAudio)`
* and DEFAULTS `source` to `Microphone` (NOT "all audio"), so each source must
* be set explicitly. setVolume records the value in the participant's
* `volumeMap`, so a track that (re)subscribes later re-applies it automatically.
*
* State is closure-scoped (per invocation, matching the sibling lotus modules)
* and re-applied to every current room's participants on change, and to LATE
* JOINERS via `RoomEvent.ParticipantConnected`. The cinny host re-sends the
* current state on every call join (CallControl.forceState), so a fresh call
* never inherits a previous call's deafen state.
*
* No effect unless the host sends the action. Returns a teardown function.
*/
export function startLotusDeafen(vm: CallViewModel): () => void {
const w = widget;
if (!w) return () => undefined;
let deafened = false;
let screenshareAudioMuted = false;
const applyToParticipant = (p: RemoteParticipant): void => {
p.setVolume(deafened ? 0 : 1, Track.Source.Microphone);
p.setVolume(
deafened || screenshareAudioMuted ? 0 : 1,
Track.Source.ScreenShareAudio,
);
// NOTE: injected/soundboard audio (published as `Track.Source.Unknown`) is
// deliberately NOT silenced here. The verified `setVolume` type signature
// only accepts `Track.Source.Microphone | Track.Source.ScreenShareAudio`,
// so passing `Unknown` would fail the fork's `tsc` gate and require an
// unsafe cast. Soundboard clips are short, host-triggered content the host
// already controls at the inject source, so leaving them audible is the
// type-clean, safe choice (a full-parity "mute everything" is not needed
// for the deafen semantics: don't-hear-other-people's-voices).
};
const applyToRoom = (room: LivekitRoom): void =>
room.remoteParticipants.forEach(applyToParticipant);
// Per-room ParticipantConnected listeners, so LATE JOINERS pick up the
// current deafen state the moment they connect.
const roomListeners = new Map<LivekitRoom, () => void>();
let rooms: LivekitRoom[] = [];
const sub = vm.livekitRoomItems$.subscribe((items) => {
const next = items.map((i) => i.livekitRoom);
rooms = next;
// Detach listeners for rooms that went away.
for (const [room, off] of roomListeners) {
if (!next.includes(room)) {
off();
roomListeners.delete(room);
}
}
// Attach to new rooms + apply the current state to their participants.
for (const room of next) {
if (!roomListeners.has(room)) {
room.on(RoomEvent.ParticipantConnected, applyToParticipant);
roomListeners.set(room, () =>
room.off(RoomEvent.ParticipantConnected, applyToParticipant),
);
applyToRoom(room);
}
}
});
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
void w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as
| { deafened?: boolean; screenshareAudioMuted?: boolean }
| undefined;
// Missing fields default to their CURRENT value, so a partial payload only
// moves the flag it actually names.
if (typeof data?.deafened === "boolean") deafened = data.deafened;
if (typeof data?.screenshareAudioMuted === "boolean")
screenshareAudioMuted = data.screenshareAudioMuted;
logger.debug(
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
);
rooms.forEach(applyToRoom);
};
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
return () => {
sub.unsubscribe();
for (const off of roomListeners.values()) off();
roomListeners.clear();
w.lazyActions.off(LotusWidgetActions.SetDeafen, handler);
};
}