Files
element-call/src/lotus/lotusDeafen.ts
T
Lotus CIandClaude Opus 5 ea579cb998 fix(lotus): screenshare-audio mute survives a re-share — mute via the renderer, not setVolume
"Mute Screenshare Audio" (io.lotus.set_deafen screenshareAudioMuted) used
RemoteParticipant.setVolume(0, ScreenShareAudio). EC's own createVolumeControls
writes volume 1 through the same setter the moment a new screenshare media item
resolves, so when the sharer stopped and re-shared (or a late joiner shared)
the audio came back at full volume while the host button still said
"Unmute Screenshare Audio". Reproduced on the local calls stack with two
headless clients: after a re-share the screen_share_audio element read vol=1.

Now the flag is a global behavior (muteScreenshareAudio$) that
LivekitRoomAudioRenderer turns into the `muted` prop of every
Track.Source.ScreenShareAudio element — the exact mechanism deafen already uses
(pub.setEnabled(false): the server stops sending). Verified via the
RemoteTrackPublication behind each <audio>: the re-published track (new sid)
mounts with enabled=false while muted and re-enables on unmute; deafen +
undeafen leaves it muted; teardown resets the flag so the next call starts
clean. Unit tests updated; renderer test asserts only ScreenShareAudio
elements get muted by the new prop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 23:54:25 -04:00

135 lines
5.8 KiB
TypeScript

/*
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<IWidgetApiRequest>): 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);
};
}