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(