diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index df79000c9..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:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d + 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:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d + image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167 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/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 = diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 4610bab66..8ab20acb7 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) {