Compare commits

...
Author SHA1 Message Date
Matthew Hodgson 3d563e8b57 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.
2026-09-08 23:30:05 +01:00
Johannes Marbach 00d0adc3c4 Merge pull request #4222 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-09-08 11:40:20 +02:00
Johannes Marbach ddc5a50529 Disable new react compiler rules 2026-09-08 11:29:36 +02:00
renovate[bot] 27734a1a02 Update all non-major dependencies 2026-09-08 06:13:17 +00:00
5 changed files with 1570 additions and 1468 deletions
+11
View File
@@ -39,6 +39,17 @@
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"react/display-name": "error",
// TODO: These React Compiler-based rules joined oxlint's correctness
// category in v1.81 and flag many pre-existing patterns, including hooks
// like useLatest/useInitial/useReactiveState that intentionally access
// refs during render. Re-evaluate them when adopting the React Compiler.
"react/immutability": "off",
"react/no-deriving-state-in-effects": "off",
"react/preserve-manual-memoization": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/static-components": "off",
"react/use-memo": "off",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
+2 -2
View File
@@ -81,7 +81,7 @@
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.10",
"@vitest/ui": "4.1.11",
"babel-plugin-react-compiler": "^1.0.0",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
@@ -101,7 +101,7 @@
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.61.0",
"oxfmt": "^0.66.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0",
"pako": "^2.0.4",
+1450 -1458
View File
File diff suppressed because it is too large Load Diff
+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(