diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index 888a8890..f11029a2 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -29,6 +29,8 @@ export enum LotusWidgetActions { SetQuality = "io.lotus.set_quality", /** toWidget: per-user avatar-decoration image URLs for in-call tiles. */ Decorations = "io.lotus.decorations", + /** toWidget: deafen remote audio (and optionally mute screenshare audio). */ + SetDeafen = "io.lotus.set_deafen", } /** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */ @@ -37,4 +39,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [ LotusWidgetActions.InjectAudio, LotusWidgetActions.SetQuality, LotusWidgetActions.Decorations, + LotusWidgetActions.SetDeafen, ]; diff --git a/src/lotus/lotusDeafen.ts b/src/lotus/lotusDeafen.ts new file mode 100644 index 00000000..2d0bc5b2 --- /dev/null +++ b/src/lotus/lotusDeafen.ts @@ -0,0 +1,120 @@ +/* +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 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): 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); + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d09763a4..f4c0813c 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -35,6 +35,7 @@ import { startLotusAudioInject } from "../lotus/lotusAudioInject"; import { startLotusQuality } from "../lotus/lotusQuality"; import { startLotusDecorations } from "../lotus/lotusDecorations"; import { startLotusDenoise } from "../lotus/lotusDenoise"; +import { startLotusDeafen } from "../lotus/lotusDeafen"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -303,6 +304,10 @@ export const InCallView: FC = ({ // [lotus] Apply ML denoise to the mic as a first-class audio processor that // survives reconnects (#1 / A7). No-op unless lotusDenoiseSource=1. useEffect(() => startLotusDenoise(vm), [vm]); + // [lotus] Handle the host's io.lotus.set_deafen action to silence remote + // audio (and optionally screenshare audio) at the LiveKit source. No-op + // unless the host sends the action. + useEffect(() => startLotusDeafen(vm), [vm]); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary