From 426acd222ef9a7664fb0b732abc3219d44b89c2b Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 8 Sep 2026 23:22:44 +0100 Subject: [PATCH 1/4] Prefer the browser default audio output when no output has been chosen On browsers without a "default" pseudo-device (Firefox, Safari), the virtual default output entry was appended after the physical devices, so with no saved preference EC selected the first physical device and pinned every remote audio element to it with setSinkId. Pinned sinks are not re-routed by the browser: on Firefox/Linux a Bluetooth headset switching from A2DP to HFP when its microphone is opened (i.e. on unmute) destroys the pinned sink and all remote audio goes silent, with no error and no fallback (rageshake 17320). List the virtual default first so it is the fallback both when nothing was chosen and when the chosen output disappears, and stop labelling it with the first device's name since the browser default is not necessarily that device. --- src/state/AudioOutput.test.ts | 62 ++++++++++++++++++++++++++++++++++- src/state/MediaDevices.ts | 21 ++++++++---- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/state/AudioOutput.test.ts b/src/state/AudioOutput.test.ts index 9eb718410..6dafc841d 100644 --- a/src/state/AudioOutput.test.ts +++ b/src/state/AudioOutput.test.ts @@ -10,6 +10,7 @@ import * as ComponentsCore from "@livekit/components-core"; import { ObservableScope } from "./ObservableScope"; import { AudioOutput } from "./MediaDevices"; +import { audioOutput as audioOutputSetting } from "../settings/settings"; import { withTestScheduler } from "../utils/test"; const BT_SPEAKER = { @@ -93,6 +94,8 @@ describe("AudioOutput Tests", () => { beforeEach(() => { testScope = new ObservableScope(); + // Device preferences persist in localStorage across tests + audioOutputSetting.setValue(undefined); }); afterEach(() => { @@ -150,7 +153,9 @@ describe("AudioOutput Tests", () => { expectObservable(audioOutput.selected$).toBe("abcde", { a: undefined, - b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false }, + // No "default" pseudo-device (Firefox): the virtual browser default is + // selected until the user picks something. + b: { id: "", virtualEarpiece: false }, c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false }, d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false }, e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false }, @@ -158,6 +163,61 @@ describe("AudioOutput Tests", () => { }); }); + it("falls back to the browser default when the selected device disappears", () => { + withTestScheduler(({ behavior, cold, schedule, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + cold("a---b", { + a: DEVICE_LIST_B, + // The monitor is unplugged (or a Bluetooth sink changes profile) + b: [LAPTOP_SPEAKER], + }), + ); + + const audioOutput = new AudioOutput( + behavior("a", { a: true }), + testScope, + ); + + schedule("--a", { + a: () => audioOutput.select(MONITOR_SPEAKER.deviceId), + }); + + expectObservable(audioOutput.selected$).toBe("a-b-c", { + a: { id: "", virtualEarpiece: false }, + b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false }, + // Rather than pinning some other physical device, let the browser route + c: { id: "", virtualEarpiece: false }, + }); + }); + }); + + it("lists the virtual browser default first when there is no default pseudo-device", () => { + withTestScheduler(({ behavior, cold, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + cold("a", { a: DEVICE_LIST_B }), + ); + + const audioOutput = new AudioOutput( + behavior("a", { a: true }), + testScope, + ); + + expectObservable(audioOutput.available$).toBe("a", { + a: new Map([ + ["", { type: "default", name: null }], + [ + LAPTOP_SPEAKER.deviceId, + { type: "name", name: LAPTOP_SPEAKER.label }, + ], + [ + MONITOR_SPEAKER.deviceId, + { type: "name", name: MONITOR_SPEAKER.label }, + ], + ]), + }); + }); + }); + it("Test mappings", () => { // In a real life setup there would be first a blanked list // then the real one. diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 70a676cf5..ef96b00f4 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -257,14 +257,21 @@ export class AudioOutput implements MediaDevice< map((availableRaw) => { let available: Map = buildDeviceMap(availableRaw); - // Create a virtual default audio output for browsers that don't have one. - // Its device ID must be the empty string because that's what setSinkId - // recognizes. + // Create a virtual default audio output for browsers that don't have one + // (Firefox, Safari). Its device ID must be the empty string because + // that's what setSinkId recognizes. It goes first so that it is the + // fallback when no output has been explicitly chosen (or the chosen + // one disappears), rather than pinning the first physical device + // with setSinkId: pinned sinks are not re-routed by the browser, and + // Firefox leaves the audio elements silent when a pinned sink goes + // away (e.g. a Bluetooth headset switching profile when its + // microphone is opened). We can't know which physical device the + // browser default resolves to, so the entry carries no name. if (available.size && !available.has("") && !available.has("default")) - available.set("", { - type: "default", - name: availableRaw[0]?.label || null, - }); + available = new Map([ + ["", { type: "default", name: null }], + ...available, + ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility if (isSafari) { From 967a06ba395db824c28235d6d53a8b910ad4d5c5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:29:13 +0000 Subject: [PATCH 2/4] Update ghcr.io/element-hq/element-web:develop Docker digest to 506caaa (#4262) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index df79000c9..13b36bbad 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d + image: ghcr.io/element-hq/element-web:develop@sha256:506caaa98cf29ab0cb5f221d69612625350e70391937367a9f2f592816b14a62 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d + image: ghcr.io/element-hq/element-web:develop@sha256:506caaa98cf29ab0cb5f221d69612625350e70391937367a9f2f592816b14a62 From f56c74adf9590284a28d9a9900f7a7de516319f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:51:42 +0000 Subject: [PATCH 3/4] Update ghcr.io/element-hq/element-web:develop Docker digest to 3993d53 (#4265) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 13b36bbad..0b2de08e5 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:506caaa98cf29ab0cb5f221d69612625350e70391937367a9f2f592816b14a62 + image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:506caaa98cf29ab0cb5f221d69612625350e70391937367a9f2f592816b14a62 + image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167 From 9f6c8bb434202385a240bedf506beec3c5b6f9fe Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Thu, 17 Sep 2026 13:44:25 +0200 Subject: [PATCH 4/4] Make the memberId dependent on the matrixRTC mode. This outherwise creates to-device messages with the uuid for members that use the userId:deviceId memberId in the state event. --- src/state/CallViewModel/CallViewModel.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 957a56fad..b2c763c2e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -563,8 +563,14 @@ export function createCallViewModel$( const ownMembershipIdentity: CallMembershipIdentityParts = { userId, deviceId, - // This will only be consumed by the sticky membership manager. So it has no impact on legacy calls. - memberId: uuidv4(), + // Consumed by the sticky membership manager as `member.id`, *and* stamped + // into every to-device key event by the key transport. A pre-sticky + // membership advertises `${userId}:${deviceId}` as its `membershipID` + // instead, so a uuid there names a member no peer can resolve. + memberId: + matrixRTCMode === MatrixRTCMode.Matrix_2_0 + ? uuidv4() + : `${userId}:${deviceId}`, }; const localTransport =