From 3d563e8b57b7be4aefb21fe9968ba154e507dfb6 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 8 Sep 2026 23:30:05 +0100 Subject: [PATCH] Re-apply a remote participant's playback volume when an audio element is attached livekit-client keeps the requested volume on the RemoteAudioTrack and re-applies it when an element is attached, except that a stored volume of 0 is dropped by a truthiness check (RemoteAudioTrack.attach: `if (this.elementVolume)`). So "mute for me" was lost whenever the muted participant's microphone track got a new subscription: their first unmute after joining muted (EC only publishes the track then), a reconnect on either side, or a leave/rejoin. The element played at full volume while the tile still showed them as muted for me (rageshake 17381). Watch the participant's microphone track for ElementAttached and push the current volume to the participant again at that point, which runs after the attach-time check and therefore also covers 0. Also log playbackMuted changes and the re-applications, since nothing about mute-for-me was visible in rageshakes so far. --- src/state/media/MediaViewModel.test.ts | 49 +++++++++++++++ src/state/media/RemoteUserMediaViewModel.ts | 66 ++++++++++++++++++--- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/state/media/MediaViewModel.test.ts b/src/state/media/MediaViewModel.test.ts index 9d873ccba..361544b9f 100644 --- a/src/state/media/MediaViewModel.test.ts +++ b/src/state/media/MediaViewModel.test.ts @@ -9,8 +9,12 @@ import { expect, onTestFinished, test, vi } from "vitest"; import { type LocalTrackPublication, LocalVideoTrack, + ParticipantEvent, + type RemoteAudioTrack, + type RemoteTrackPublication, Track, TrackEvent, + TrackPublication, } from "livekit-client"; import { waitFor } from "@testing-library/dom"; @@ -23,6 +27,7 @@ import { withTestScheduler, mockRemoteParticipant, mockRemoteScreenShare, + mockEmitter, } from "../../utils/test"; import { constant } from "../Behavior"; @@ -160,6 +165,50 @@ test("control a participant's screen share volume", () => { }); }); +test("re-applies the playback volume when an audio element is attached", () => { + // A participant whose microphone track does not exist yet (they joined muted) + const track = mockEmitter() as unknown as RemoteAudioTrack; + let micPublication: Partial = {}; + const setVolumeSpy = vi.fn(); + const participant = mockRemoteParticipant({ + setVolume: setVolumeSpy, + getTrackPublication: (source) => + (source === Track.Source.Microphone + ? micPublication + : {}) as RemoteTrackPublication, + }); + const vm = mockRemoteMedia(rtcMembership, {}, participant); + withTestScheduler(({ expectObservable, schedule }) => { + schedule("-a-b-c|", { + a() { + vm.togglePlaybackMuted(); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0); + setVolumeSpy.mockClear(); + }, + b() { + // The participant unmutes: their track gets published and subscribed, + // then the renderer attaches an audio element to it + micPublication = { track }; + participant.emit( + ParticipantEvent.TrackSubscriptionStatusChanged, + micPublication as RemoteTrackPublication, + TrackPublication.SubscriptionStatus.Subscribed, + ); + expect(setVolumeSpy).not.toHaveBeenCalled(); + track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0); + setVolumeSpy.mockClear(); + }, + c() { + // ...and again for every further attach (e.g. a re-render) + track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement); + expect(setVolumeSpy).toHaveBeenLastCalledWith(0); + }, + }); + expectObservable(vm.playbackMuted$).toBe("ab", { a: false, b: true }); + }); +}); + test("local media remembers whether it should always be shown", () => { const vm1 = mockLocalMedia( rtcMembership, diff --git a/src/state/media/RemoteUserMediaViewModel.ts b/src/state/media/RemoteUserMediaViewModel.ts index 6daa381a7..2cfade396 100644 --- a/src/state/media/RemoteUserMediaViewModel.ts +++ b/src/state/media/RemoteUserMediaViewModel.ts @@ -6,8 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type RemoteParticipant } from "livekit-client"; -import { combineLatest, map, of, switchMap } from "rxjs"; +import { type RemoteParticipant, Track, TrackEvent } from "livekit-client"; +import { observeParticipantMedia } from "@livekit/components-core"; +import { + combineLatest, + distinctUntilChanged, + fromEvent, + map, + NEVER, + of, + startWith, + switchMap, +} from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../Behavior"; @@ -60,14 +70,54 @@ export function createRemoteUserMedia( logger.info(`[RemoteUserMedia ${inputs.id}] waitingForMedia=${waiting}`); }); + // Emits whenever an audio element is attached to this participant's + // microphone track: a new subscription after they rejoin or reconnect, or + // their first unmute if they joined muted (EC only publishes the track + // then). The requested volume has to be applied again at that point. + // livekit-client stores the volume on the RemoteAudioTrack and re-applies + // it on attach, except that a stored volume of 0 is dropped by a truthiness + // check (RemoteAudioTrack.attach: `if (this.elementVolume)`), so "mute for + // me" would otherwise be lost as soon as a new track arrives. + const audioElementAttached$ = inputs.participant$.pipe( + switchMap((p) => + p === null + ? NEVER + : observeParticipantMedia(p).pipe( + map(() => p.getTrackPublication(Track.Source.Microphone)?.track), + distinctUntilChanged(), + switchMap((track) => + track === undefined + ? NEVER + : fromEvent(track, TrackEvent.ElementAttached), + ), + ), + ), + ); + + const volumeControls = createVolumeControls(scope, { + pretendToBeDisconnected$, + sink$: scope.behavior( + combineLatest([ + inputs.participant$, + audioElementAttached$.pipe(startWith(null)), + ]).pipe( + map(([p, attached]) => (volume) => { + if (attached !== null) + logger.info( + `[RemoteUserMedia ${inputs.id}] re-applying playback volume ${volume} after audio element attach`, + ); + p?.setVolume(volume); + }), + ), + ), + }); + volumeControls.playbackMuted$.pipe(scope.bind()).subscribe((muted) => { + logger.info(`[RemoteUserMedia ${inputs.id}] playbackMuted=${muted}`); + }); + return { ...baseUserMedia, - ...createVolumeControls(scope, { - pretendToBeDisconnected$, - sink$: scope.behavior( - inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))), - ), - }), + ...volumeControls, local: false, speaking$: scope.behavior( pretendToBeDisconnected$.pipe(