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
+63 -2
View File
@@ -52,9 +52,16 @@ afterEach(() => {
vi.mock("@livekit/components-react", async (importOriginal) => {
return {
...(await importOriginal()),
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
AudioTrack: (props: {
trackRef: TrackReference;
muted?: boolean;
}): ReactNode => {
return (
<audio data-testid={"audio"}>
<audio
data-testid={"audio"}
data-source={props.trackRef.publication.source}
data-muted={String(!!props.muted)}
>
{getTrackReferenceId(props.trackRef)}
</audio>
);
@@ -83,6 +90,7 @@ function renderTestComponent(
kind: Track.Kind;
source: Track.Source;
}[],
props: { muted?: boolean; screenshareAudioMuted?: boolean } = {},
): RenderResult {
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
mockRemoteParticipant({ identity }),
@@ -117,6 +125,7 @@ function renderTestComponent(
validIdentities={participants.map((p) => p.identity)}
livekitRoom={livekitRoom}
url={""}
{...props}
/>
</MediaDevicesProvider>,
);
@@ -286,3 +295,55 @@ it("should setup audioContext gain and pan", () => {
expect(testAudioContext.gain.gain.value).toEqual(0.1);
expect(testAudioContext.pan.pan.value).toEqual(1);
});
// [lotus] The host's "Mute Screenshare Audio" mutes ONLY screenshare-audio
// elements, via the same `muted` prop path as deafen (so it survives re-renders
// and later-published shares); deafen still mutes everything.
it("screenshareAudioMuted mutes only ScreenShareAudio tracks", () => {
const explicitTracks = [
{
participantId: "@alice:DEV0",
kind: Track.Kind.Audio,
source: Track.Source.Microphone,
},
{
participantId: "@alice:DEV0",
kind: Track.Kind.Audio,
source: Track.Source.ScreenShareAudio,
},
];
const mutedBySource = (r: RenderResult): Record<string, string> =>
Object.fromEntries(
r
.queryAllByTestId("audio")
.map((el) => [el.dataset.source, el.dataset.muted]),
);
expect(
mutedBySource(
renderTestComponent(
[{ userId: "@alice", deviceId: "DEV0" }],
["@alice:DEV0"],
explicitTracks,
{ screenshareAudioMuted: true },
),
),
).toEqual({
[Track.Source.Microphone]: "false",
[Track.Source.ScreenShareAudio]: "true",
});
expect(
mutedBySource(
renderTestComponent(
[{ userId: "@alice", deviceId: "DEV0" }],
["@alice:DEV0"],
explicitTracks,
{ muted: true },
),
),
).toEqual({
[Track.Source.Microphone]: "true",
[Track.Source.ScreenShareAudio]: "true",
});
});