/* 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 { logger } from "matrix-js-sdk/lib/logger"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { setAudioEnabled$ } from "../controls"; import { widget } from "../widget"; import { LotusWidgetActions } from "./lotusActions"; import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio"; /** * Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's * brittle iframe-DOM `.muted` hack (which fought MatrixAudioRenderer and broke * on re-render / late tracks). * * `deafened` drives EC's OWN global audio output mute: `setAudioEnabled$` feeds * `muteAllAudio$` (`src/state/MuteAllAudioModel.ts`), which `InCallView` already * passes as `muted` to every `LivekitRoomAudioRenderer` (and hence * `MatrixAudioRenderer`), plus `CallEventAudioRenderer` and * `ReactionsAudioRenderer`. That silences EVERY remote source — microphone, * screenshare audio and `Track.Source.Unknown` soundboard clips — and needs no * per-participant bookkeeping for late joiners or reconnects. * * This deliberately does NOT use `RemoteParticipant.setVolume` for deafen any * more: EC's per-participant volume slider / per-tile mute writes the same * `volumeMap` from `createVolumeControls` (`src/state/VolumeControls.ts`) * whenever its `sink$` re-emits (every join, every participant re-resolution), * so a `setVolume(0)` deafen was silently undone for anyone joining while * deafened, and an undeafen `setVolume(1)` clobbered the user's own per-tile * volume/mute state. * * `screenshareAudioMuted` is a NARROWER, independent host control (drop shared * tab/game audio while still hearing voices). It goes through the same * mechanism as deafen — a global behavior (`muteScreenshareAudio$`) that * `LivekitRoomAudioRenderer` turns into the `muted` prop of every * `Track.Source.ScreenShareAudio` element — rather than * `RemoteParticipant.setVolume(0, ScreenShareAudio)`: EC's own * `createVolumeControls` writes volume 1 through that very setter whenever a * new screenshare media item resolves, so a sharer who stopped and re-shared * (or a late joiner's share) came back at full volume while the host's button * still said "Unmute Screenshare Audio". * * Undeafen restores the user's OWN output-enabled state as it was before the * deafen (and never touches the `mute-all-audio` setting), so a user who had * already muted all audio themselves stays muted. * * State is closure-scoped (per invocation, matching the sibling lotus modules); * the screenshare-audio flag additionally lives in `setScreenshareAudioMuted$` * so the renderer can read it, and is reset on teardown. * Applying the same state twice is a no-op, so the host's * `CallControl.resendForkState()` after a reconnect is safe. The 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(): () => void { const w = widget; if (!w) return () => undefined; let deafened = false; let screenshareAudioMuted = false; // The user's own audio-output state, tracked from `setAudioEnabled$` so an // undeafen restores it rather than blindly enabling output. Defaults to // `true`, matching `muteAllAudio$`'s `startWith(true)`. let userAudioEnabled = true; // Ignore our own (synchronous) emissions while tracking the user's state, and // skip pushes that would not change anything so re-applying the same state // (the host's `resendForkState()` after a reconnect) is a no-op. let selfEmitting = false; let lastApplied: boolean | null = null; const applyGlobalMute = (): void => { const desired = deafened ? false : userAudioEnabled; // `lastApplied` is our own override, if any; otherwise the live state is // whatever the user last set. const current = lastApplied ?? userAudioEnabled; lastApplied = desired; if (desired === current) return; selfEmitting = true; try { setAudioEnabled$.next(desired); } finally { selfEmitting = false; } }; const audioSub = setAudioEnabled$.subscribe((enabled) => { if (selfEmitting) return; userAudioEnabled = enabled; if (deafened && enabled) { // Something else (the native output controls) re-enabled audio while // deafened — re-assert the mute instead of silently losing deafen. lastApplied = true; applyGlobalMute(); } }); const handler = (ev: CustomEvent): 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}`, ); applyGlobalMute(); if (setScreenshareAudioMuted$.value !== screenshareAudioMuted) setScreenshareAudioMuted$.next(screenshareAudioMuted); }; w.lazyActions.on(LotusWidgetActions.SetDeafen, handler); return () => { // Leave the user's own output state as they had it before deafen. if (deafened) { deafened = false; applyGlobalMute(); } if (setScreenshareAudioMuted$.value) setScreenshareAudioMuted$.next(false); audioSub.unsubscribe(); w.lazyActions.off(LotusWidgetActions.SetDeafen, handler); }; }