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.
This commit is contained in:
Matthew Hodgson
2026-09-08 23:30:05 +01:00
parent 00d0adc3c4
commit 3d563e8b57
2 changed files with 107 additions and 8 deletions
+49
View File
@@ -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<RemoteAudioTrack>() as unknown as RemoteAudioTrack;
let micPublication: Partial<RemoteTrackPublication> = {};
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,
+58 -8
View File
@@ -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(