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:
Lotus CI
2026-09-18 23:54:25 -04:00
co-authored by Claude Opus 5
parent bc0e5ed432
commit ea579cb998
6 changed files with 149 additions and 118 deletions
+25 -43
View File
@@ -7,13 +7,11 @@ Please see LICENSE in the repository root for full details.
import { EventEmitter } from "events";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { of } from "rxjs";
import { Track } from "livekit-client";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { setAudioEnabled$ } from "../controls";
import { startLotusDeafen } from "./lotusDeafen";
import { LotusWidgetActions } from "./lotusActions";
import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio";
const lazyActions = new EventEmitter();
@@ -27,18 +25,6 @@ vi.mock("../widget", () => ({
},
}));
/** Minimal CallViewModel stub: no connections, so no livekit rooms. */
function mockVm(participants: unknown[] = []): CallViewModel {
const livekitRoom = {
remoteParticipants: new Map(participants.map((p, i) => [String(i), p])),
on: vi.fn(),
off: vi.fn(),
};
return {
allConnections$: of({ getConnections: () => [{ livekitRoom }] }),
} as unknown as CallViewModel;
}
function send(data: unknown): void {
lazyActions.emit(LotusWidgetActions.SetDeafen, {
detail: { data },
@@ -60,7 +46,7 @@ afterEach(() => {
});
test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
const stop = startLotusDeafen(mockVm());
const stop = startLotusDeafen();
send({ deafened: true, screenshareAudioMuted: false });
expect(emissions).toEqual([false]);
@@ -73,7 +59,7 @@ test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
});
test("re-sending the same state is idempotent (host resendForkState)", () => {
const stop = startLotusDeafen(mockVm());
const stop = startLotusDeafen();
send({ deafened: true, screenshareAudioMuted: false });
send({ deafened: true, screenshareAudioMuted: false });
@@ -86,7 +72,7 @@ test("re-sending the same state is idempotent (host resendForkState)", () => {
});
test("undeafen does not re-enable audio the user had muted themselves", () => {
const stop = startLotusDeafen(mockVm());
const stop = startLotusDeafen();
// The user mutes all audio through EC's own control first.
setAudioEnabled$.next(false);
@@ -101,48 +87,44 @@ test("undeafen does not re-enable audio the user had muted themselves", () => {
expect(emissions).toEqual([false]);
});
test("deafen never touches per-participant volume", () => {
const participant = { setVolume: vi.fn() };
const stop = startLotusDeafen(mockVm([participant]));
test("deafen never touches the screenshare-audio flag", () => {
const stop = startLotusDeafen();
send({ deafened: true, screenshareAudioMuted: false });
send({ deafened: false, screenshareAudioMuted: false });
expect(participant.setVolume).not.toHaveBeenCalled();
expect(setScreenshareAudioMuted$.value).toBe(false);
stop();
});
test("screenshare-audio mute is applied per source and only undone for participants we muted", () => {
const participant = { setVolume: vi.fn() };
const stop = startLotusDeafen(mockVm([participant]));
test("screenshare-audio mute is a global flag the renderer reads, reset on teardown", () => {
const seen: boolean[] = [];
const flagSub = setScreenshareAudioMuted$.subscribe((v) => seen.push(v));
const stop = startLotusDeafen();
// Not muted yet: no volume writes at all.
// Not muted yet: nothing pushed beyond the BehaviorSubject's initial value.
send({ deafened: false, screenshareAudioMuted: false });
expect(participant.setVolume).not.toHaveBeenCalled();
expect(seen).toEqual([false]);
send({ deafened: false, screenshareAudioMuted: true });
expect(participant.setVolume).toHaveBeenCalledWith(
0,
Track.Source.ScreenShareAudio,
);
expect(setScreenshareAudioMuted$.value).toBe(true);
// Re-applying the same state (host resend after reconnect) is a no-op.
send({ deafened: false, screenshareAudioMuted: true });
expect(seen).toEqual([false, true]);
participant.setVolume.mockClear();
send({ deafened: false, screenshareAudioMuted: false });
expect(participant.setVolume).toHaveBeenCalledWith(
1,
Track.Source.ScreenShareAudio,
);
// Releasing again writes nothing: we no longer own that participant.
participant.setVolume.mockClear();
send({ deafened: false, screenshareAudioMuted: false });
expect(participant.setVolume).not.toHaveBeenCalled();
// Deafen + undeafen while screenshare audio is muted leaves it muted.
send({ deafened: true, screenshareAudioMuted: true });
send({ deafened: false, screenshareAudioMuted: true });
expect(setScreenshareAudioMuted$.value).toBe(true);
// Leaving the call clears it so the next call starts clean.
stop();
expect(setScreenshareAudioMuted$.value).toBe(false);
flagSub.unsubscribe();
});
test("a partial payload only moves the flag it names", () => {
const stop = startLotusDeafen(mockVm());
const stop = startLotusDeafen();
send({ deafened: true });
expect(emissions).toEqual([false]);