feat(lotus): io.lotus.set_deafen action — deafen remote audio at the source
New toWidget action { deafened, screenshareAudioMuted } that sets each remote
RemoteParticipant.setVolume per source (Microphone + ScreenShareAudio), applied
to existing participants + re-applied to late joiners via
RoomEvent.ParticipantConnected (subscribed through vm.livekitRoomItems$). Closure-
scoped state, matching the sibling lotus modules; the cinny host re-sends on join
so a fresh call never inherits stale deafen state.
Replaces cinny's brittle iframe-DOM <audio>.muted hack (which broke on EC
re-render / late tracks). Folded into unpublished 0.20.1-lotus.2.
Note: injected/soundboard audio (Track.Source.Unknown) is not silenced — the
livekit-client setVolume type only accepts Microphone|ScreenShareAudio.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d71d8d6799
commit
02666c0c04
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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<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);
|
||||
};
|
||||
}
|
||||
@@ -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<InCallViewProps> = ({
|
||||
// [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
|
||||
|
||||
Reference in New Issue
Block a user