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
This commit is contained in:
co-authored by
Claude Opus 5
parent
bc0e5ed432
commit
ea579cb998
+17
-71
@@ -5,19 +5,13 @@ 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 { 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
|
||||
@@ -41,22 +35,23 @@ import { LotusWidgetActions } from "./lotusActions";
|
||||
* volume/mute state.
|
||||
*
|
||||
* `screenshareAudioMuted` is a NARROWER, independent host control (drop shared
|
||||
* tab/game audio while still hearing voices). EC has no global per-source mute,
|
||||
* so that one still has to go through
|
||||
* `RemoteParticipant.setVolume(volume, Track.Source.ScreenShareAudio)` — whose
|
||||
* verified signature in livekit-client ^2.18.1 is
|
||||
* `setVolume(volume, source?: Track.Source.Microphone | Track.Source.ScreenShareAudio)`.
|
||||
* It is re-applied to late joiners via `RoomEvent.ParticipantConnected`, and we
|
||||
* only ever restore participants we muted ourselves. Known limitation: EC's own
|
||||
* screenshare volume slider writes the same `volumeMap`, so a user who moves
|
||||
* that slider while screenshare audio is host-muted wins; the mic path (the
|
||||
* actual deafen) is no longer affected by that race at all.
|
||||
* 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).
|
||||
* 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
|
||||
@@ -64,7 +59,7 @@ import { LotusWidgetActions } from "./lotusActions";
|
||||
*
|
||||
* No effect unless the host sends the action. Returns a teardown function.
|
||||
*/
|
||||
export function startLotusDeafen(vm: CallViewModel): () => void {
|
||||
export function startLotusDeafen(): () => void {
|
||||
const w = widget;
|
||||
if (!w) return () => undefined;
|
||||
|
||||
@@ -107,53 +102,6 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
|
||||
}
|
||||
});
|
||||
|
||||
// Participants whose screenshare audio WE turned down, so undoing the host's
|
||||
// screenshare mute never writes a volume to anyone else.
|
||||
const screenshareMuted = new Set<RemoteParticipant>();
|
||||
|
||||
const applyToParticipant = (p: RemoteParticipant): void => {
|
||||
if (screenshareAudioMuted) {
|
||||
p.setVolume(0, Track.Source.ScreenShareAudio);
|
||||
screenshareMuted.add(p);
|
||||
} else if (screenshareMuted.delete(p)) {
|
||||
p.setVolume(1, Track.Source.ScreenShareAudio);
|
||||
}
|
||||
};
|
||||
|
||||
const applyToRoom = (room: LivekitRoom): void =>
|
||||
room.remoteParticipants.forEach(applyToParticipant);
|
||||
|
||||
// Per-room ParticipantConnected listeners, so LATE JOINERS pick up the
|
||||
// current screenshare-audio mute the moment they connect. Drive off the
|
||||
// local participant's connection(s), not `livekitRoomItems$` — that stream is
|
||||
// empty until a matrix-validated REMOTE member resolves, so listeners would
|
||||
// be attached too late for the first joiner (the sibling lotus modules all
|
||||
// use `allConnections$` for the same reason).
|
||||
const roomListeners = new Map<LivekitRoom, () => void>();
|
||||
let rooms: LivekitRoom[] = [];
|
||||
|
||||
const sub = vm.allConnections$.subscribe((data) => {
|
||||
const next = data.getConnections().map((c) => c.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 => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
const data = ev.detail.data as
|
||||
@@ -168,20 +116,18 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
|
||||
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
|
||||
);
|
||||
applyGlobalMute();
|
||||
rooms.forEach(applyToRoom);
|
||||
if (setScreenshareAudioMuted$.value !== screenshareAudioMuted)
|
||||
setScreenshareAudioMuted$.next(screenshareAudioMuted);
|
||||
};
|
||||
|
||||
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
for (const off of roomListeners.values()) off();
|
||||
roomListeners.clear();
|
||||
// Leave the user's own output state as they had it before deafen.
|
||||
if (deafened) {
|
||||
deafened = false;
|
||||
applyGlobalMute();
|
||||
}
|
||||
screenshareMuted.clear();
|
||||
if (setScreenshareAudioMuted$.value) setScreenshareAudioMuted$.next(false);
|
||||
audioSub.unsubscribe();
|
||||
w.lazyActions.off(LotusWidgetActions.SetDeafen, handler);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user