mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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.
This commit is contained in:
@@ -10,6 +10,7 @@ import * as ComponentsCore from "@livekit/components-core";
|
|||||||
|
|
||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
import { AudioOutput } from "./MediaDevices";
|
import { AudioOutput } from "./MediaDevices";
|
||||||
|
import { audioOutput as audioOutputSetting } from "../settings/settings";
|
||||||
import { withTestScheduler } from "../utils/test";
|
import { withTestScheduler } from "../utils/test";
|
||||||
|
|
||||||
const BT_SPEAKER = {
|
const BT_SPEAKER = {
|
||||||
@@ -93,6 +94,8 @@ describe("AudioOutput Tests", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
testScope = new ObservableScope();
|
testScope = new ObservableScope();
|
||||||
|
// Device preferences persist in localStorage across tests
|
||||||
|
audioOutputSetting.setValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -150,7 +153,9 @@ describe("AudioOutput Tests", () => {
|
|||||||
|
|
||||||
expectObservable(audioOutput.selected$).toBe("abcde", {
|
expectObservable(audioOutput.selected$).toBe("abcde", {
|
||||||
a: undefined,
|
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 },
|
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||||
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||||
e: { id: MONITOR_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", () => {
|
it("Test mappings", () => {
|
||||||
// In a real life setup there would be first a blanked list
|
// In a real life setup there would be first a blanked list
|
||||||
// then the real one.
|
// then the real one.
|
||||||
|
|||||||
@@ -257,14 +257,21 @@ export class AudioOutput implements MediaDevice<
|
|||||||
map((availableRaw) => {
|
map((availableRaw) => {
|
||||||
let available: Map<string, AudioOutputDeviceLabel> =
|
let available: Map<string, AudioOutputDeviceLabel> =
|
||||||
buildDeviceMap(availableRaw);
|
buildDeviceMap(availableRaw);
|
||||||
// Create a virtual default audio output for browsers that don't have one.
|
// 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
|
// (Firefox, Safari). Its device ID must be the empty string because
|
||||||
// recognizes.
|
// 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"))
|
if (available.size && !available.has("") && !available.has("default"))
|
||||||
available.set("", {
|
available = new Map<string, AudioOutputDeviceLabel>([
|
||||||
type: "default",
|
["", { type: "default", name: null }],
|
||||||
name: availableRaw[0]?.label || null,
|
...available,
|
||||||
});
|
]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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
|
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) {
|
if (isSafari) {
|
||||||
|
|||||||
Reference in New Issue
Block a user