mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-23 00:02:26 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a64a911b23 | ||
|
|
bc772fadd3 | ||
|
|
11ef319536 | ||
|
|
dda549ffd2 | ||
|
|
4b8cfc4921 |
@@ -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:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
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:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
|
||||
Generated
+8
-8
@@ -131,11 +131,11 @@ importers:
|
||||
specifier: ^10.2.11
|
||||
version: 10.3.1(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||
specifier: ^10.0.0
|
||||
version: 10.2.4(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-web':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
@@ -3229,8 +3229,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
|
||||
'@vector-im/compound-design-tokens@11.0.0':
|
||||
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
|
||||
'@vector-im/compound-design-tokens@10.2.4':
|
||||
resolution: {integrity: sha512-2aCjhSvJxktPWnvh5s9Xdc0Rd76AfHqbDbN4QjOxo2VIac6zYdLyQbxMUQ5ZeoR9G3vAxILd0H1UvApRfMilpQ==}
|
||||
peerDependencies:
|
||||
'@adobe/leonardo-contrast-colors': ^1.0.0
|
||||
'@types/react': '*'
|
||||
@@ -8695,12 +8695,12 @@ snapshots:
|
||||
'@use-gesture/core': 10.3.1
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)':
|
||||
'@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8)':
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8712,7 +8712,7 @@ snapshots:
|
||||
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.18)(react@19.2.8)
|
||||
classnames: 2.5.1
|
||||
react: 19.2.8
|
||||
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
|
||||
@@ -25,11 +25,11 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { MicButton, VideoButton } from "../button";
|
||||
import { type DeviceLabel } from "../state/MediaDevices";
|
||||
import { type AudioInputDeviceLabel } from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel;
|
||||
label: AudioInputDeviceLabel;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -180,6 +180,9 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
case "number":
|
||||
labelText = numberedLabel(label.number);
|
||||
break;
|
||||
case "default":
|
||||
labelText = t("settings.devices.default");
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
|
||||
@@ -135,7 +135,8 @@ export const LobbyView: FC<Props> = ({
|
||||
const initialAudioOptions = useInitial(
|
||||
() =>
|
||||
audioEnabled && {
|
||||
deviceId: getValue(devices.audioInput.selected$)?.id,
|
||||
// "" is the virtual browser default: no deviceId constraint
|
||||
deviceId: getValue(devices.audioInput.selected$)?.id || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright 2026 Element Corp.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, vi, it, expect } from "vitest";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { audioInput as audioInputSetting } from "../settings/settings";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
|
||||
const BUILT_IN_MIC = {
|
||||
deviceId: "b3d4cb5e0c8e1d7a2f9b6c4e5d8a7f1c3e2b9d6a5c4f8e7d1b2a3c4d5e6f7a8b",
|
||||
kind: "audioinput",
|
||||
label: "iPhone Microphone",
|
||||
groupId: "1",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
const HEADSET_MIC = {
|
||||
deviceId: "c4e5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5",
|
||||
kind: "audioinput",
|
||||
label: "Headset Microphone",
|
||||
groupId: "2",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
// Chrome exposes a synthetic "default" device that follows the OS default.
|
||||
const CHROME_DEFAULT = {
|
||||
...HEADSET_MIC,
|
||||
deviceId: "default",
|
||||
label: `Default - ${HEADSET_MIC.label}`,
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
createMediaDeviceObserver: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("AudioInput", () => {
|
||||
let testScope: ObservableScope;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
// Device preferences persist in localStorage across tests
|
||||
audioInputSetting.setValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
});
|
||||
|
||||
it("uses the browser default input when there is no default pseudo-device", () => {
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
// iOS / Firefox: physical devices only, built-in first
|
||||
cold("a", { a: [BUILT_IN_MIC, HEADSET_MIC] }),
|
||||
);
|
||||
|
||||
const { audioInput } = new MediaDevices(testScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
expectObservable(audioInput.available$).toBe("a", {
|
||||
a: new Map([
|
||||
["", { type: "default", name: null }],
|
||||
[BUILT_IN_MIC.deviceId, { type: "name", name: BUILT_IN_MIC.label }],
|
||||
[HEADSET_MIC.deviceId, { type: "name", name: HEADSET_MIC.label }],
|
||||
]),
|
||||
});
|
||||
expectObservable(audioInput.selected$.pipe()).toBe("a", {
|
||||
a: expect.objectContaining({ id: "" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps using Chrome's default device", () => {
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a", { a: [CHROME_DEFAULT, BUILT_IN_MIC, HEADSET_MIC] }),
|
||||
);
|
||||
|
||||
const { audioInput } = new MediaDevices(testScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
expectObservable(audioInput.selected$).toBe("a", {
|
||||
a: expect.objectContaining({ id: "default" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("honours an explicit choice and falls back to the browser default when it disappears", () => {
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a---b", {
|
||||
a: [BUILT_IN_MIC, HEADSET_MIC],
|
||||
b: [BUILT_IN_MIC],
|
||||
}),
|
||||
);
|
||||
|
||||
const { audioInput } = new MediaDevices(testScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
schedule("--a", { a: () => audioInput.select(HEADSET_MIC.deviceId) });
|
||||
|
||||
expectObservable(audioInput.selected$).toBe("a-b-c", {
|
||||
a: expect.objectContaining({ id: "" }),
|
||||
b: expect.objectContaining({ id: HEADSET_MIC.deviceId }),
|
||||
c: expect.objectContaining({ id: "" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ 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 = {
|
||||
@@ -94,8 +93,6 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
// Device preferences persist in localStorage across tests
|
||||
audioOutputSetting.setValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -153,9 +150,7 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("abcde", {
|
||||
a: undefined,
|
||||
// No "default" pseudo-device (Firefox): the virtual browser default is
|
||||
// selected until the user picks something.
|
||||
b: { id: "", virtualEarpiece: false },
|
||||
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
@@ -163,61 +158,6 @@ 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.
|
||||
|
||||
@@ -563,14 +563,8 @@ export function createCallViewModel$(
|
||||
const ownMembershipIdentity: CallMembershipIdentityParts = {
|
||||
userId,
|
||||
deviceId,
|
||||
// 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}`,
|
||||
// This will only be consumed by the sticky membership manager. So it has no impact on legacy calls.
|
||||
memberId: uuidv4(),
|
||||
};
|
||||
|
||||
const localTransport =
|
||||
|
||||
@@ -5,16 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type Mock,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
LocalParticipant,
|
||||
type LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope";
|
||||
@@ -23,10 +33,12 @@ import {
|
||||
flushPromises,
|
||||
mockLivekitRoom,
|
||||
mockMediaDevices,
|
||||
deviceStub,
|
||||
} from "../../../utils/test";
|
||||
import { Publisher } from "./Publisher";
|
||||
import { type Connection } from "../remoteMembers/Connection";
|
||||
import { type MuteStates } from "../../MuteStates";
|
||||
import { type MediaDevices } from "../../MediaDevices";
|
||||
|
||||
let scope: ObservableScope;
|
||||
|
||||
@@ -182,6 +194,159 @@ beforeEach(() => {
|
||||
} as unknown as Connection;
|
||||
});
|
||||
|
||||
describe("Publisher device sync", () => {
|
||||
type SelectedInput =
|
||||
| { id: string; hardwareDeviceChange$: typeof NEVER }
|
||||
| undefined;
|
||||
|
||||
function selectedInput(id: string): SelectedInput {
|
||||
return { id, hardwareDeviceChange$: NEVER };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Publisher whose LiveKit room starts out capturing from
|
||||
* capturedDeviceId (undefined meaning no deviceId constraint, i.e. the
|
||||
* browser default) and reports activeDeviceId as its active audio input.
|
||||
*/
|
||||
function createDeviceSyncPublisher({
|
||||
selected$,
|
||||
capturedDeviceId,
|
||||
activeDeviceId,
|
||||
switchActiveDevice = vi.fn().mockResolvedValue(true),
|
||||
}: {
|
||||
selected$: BehaviorSubject<SelectedInput>;
|
||||
capturedDeviceId?: string;
|
||||
activeDeviceId: string;
|
||||
switchActiveDevice?: Mock;
|
||||
}): { publisher: Publisher; switchActiveDevice: Mock } {
|
||||
const livekitRoom = mockLivekitRoom({
|
||||
localParticipant,
|
||||
state: LivekitConnectionState.Connected,
|
||||
// ConnectionFactory leaves the deviceId out for the browser default
|
||||
options: { audioCaptureDefaults: { deviceId: capturedDeviceId } },
|
||||
switchActiveDevice,
|
||||
getActiveDevice: () => activeDeviceId,
|
||||
} as unknown as Partial<LivekitRoom>);
|
||||
|
||||
const publisher = new Publisher(
|
||||
{ ...connection, livekitRoom },
|
||||
mockMediaDevices({
|
||||
audioInput: { ...deviceStub, selected$ },
|
||||
} as unknown as Partial<MediaDevices>),
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
|
||||
return { publisher, switchActiveDevice };
|
||||
}
|
||||
|
||||
it("does not pin a device for the virtual browser default input", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
|
||||
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
activeDeviceId: "hardware-id-of-whatever-the-browser-chose",
|
||||
});
|
||||
|
||||
// Already capturing from the browser default: nothing to switch, even
|
||||
// though LiveKit reports the physical device it ended up with.
|
||||
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||
|
||||
selected$.next(selectedInput("headset"));
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"headset",
|
||||
);
|
||||
|
||||
selected$.next(selectedInput(""));
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"default",
|
||||
false,
|
||||
);
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("pins the device the browser default resolved to when picked explicitly", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
|
||||
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
// The browser default happens to capture from the built-in microphone
|
||||
activeDeviceId: "built-in-mic",
|
||||
});
|
||||
|
||||
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||
|
||||
// Picking that same microphone has to replace the loose constraint with a
|
||||
// pin, or the capture would keep following the OS default
|
||||
selected$.next(selectedInput("built-in-mic"));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"built-in-mic",
|
||||
);
|
||||
|
||||
// Once pinned, re-emitting the same selection is a no-op again
|
||||
selected$.next(selectedInput("built-in-mic"));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("retries the same selection after a failed switch", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
|
||||
const switchActiveDevice = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("could not open the device"))
|
||||
.mockResolvedValue(true);
|
||||
const { publisher } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
// Pinned to the headset, so moving to the browser default is a switch
|
||||
capturedDeviceId: "headset",
|
||||
activeDeviceId: "headset",
|
||||
switchActiveDevice,
|
||||
});
|
||||
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
|
||||
await flushPromises();
|
||||
|
||||
// The failed switch left us on the headset, so selecting the browser
|
||||
// default again has to try once more rather than count as already done
|
||||
selected$.next(selectedInput(""));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"default",
|
||||
false,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
// This time it worked, so there is nothing left to do
|
||||
selected$.next(selectedInput(""));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("does not switch when the room already captures from the selected device", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(
|
||||
selectedInput("headset"),
|
||||
);
|
||||
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
capturedDeviceId: "headset",
|
||||
activeDeviceId: "headset",
|
||||
});
|
||||
|
||||
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Publisher", () => {
|
||||
let publisher: Publisher;
|
||||
|
||||
|
||||
@@ -355,8 +355,19 @@ export class Publisher {
|
||||
const syncDevice = (
|
||||
kind: MediaDeviceKind,
|
||||
selected$: Observable<SelectedDevice | undefined>,
|
||||
): Subscription =>
|
||||
selected$.pipe(scope.bind()).subscribe((device) => {
|
||||
): Subscription => {
|
||||
// The ID we last asked LiveKit for. Needed for the virtual browser
|
||||
// default input ("") because LiveKit reports the physical device it
|
||||
// ended up capturing from, which never equals "".
|
||||
// ConnectionFactory already applied the selection when the room was
|
||||
// created; a browser-default input is represented there by an absent
|
||||
// deviceId.
|
||||
let requestedId: string | undefined =
|
||||
kind === "audioinput" &&
|
||||
lkRoom.options.audioCaptureDefaults?.deviceId === undefined
|
||||
? ""
|
||||
: undefined;
|
||||
return selected$.pipe(scope.bind()).subscribe((device) => {
|
||||
if (lkRoom.state != LivekitConnectionState.Connected) return;
|
||||
// if (this.connectionState$.value !== ConnectionState.Connected) return;
|
||||
this.logger.info(
|
||||
@@ -365,20 +376,41 @@ export class Publisher {
|
||||
" !== ",
|
||||
device?.id,
|
||||
);
|
||||
if (
|
||||
device !== undefined &&
|
||||
lkRoom.getActiveDevice(kind) !== device.id
|
||||
if (device === undefined) return;
|
||||
const browserDefaultInput = device.id === "" && kind !== "audiooutput";
|
||||
// While we capture from the browser default, LiveKit reports the
|
||||
// physical device it resolved to, which can be any of the devices the
|
||||
// user could also pick explicitly. getActiveDevice therefore can't
|
||||
// tell an explicit pin apart from the default resolving to the same
|
||||
// device, and only requestedId can: without this, picking the device
|
||||
// the default happens to use would leave the loose constraint in
|
||||
// place, and the capture would keep following the OS default.
|
||||
const capturingBrowserDefault = requestedId === "";
|
||||
if (browserDefaultInput) {
|
||||
if (capturingBrowserDefault) return;
|
||||
} else if (
|
||||
!capturingBrowserDefault &&
|
||||
lkRoom.getActiveDevice(kind) === device.id
|
||||
) {
|
||||
lkRoom
|
||||
.switchActiveDevice(kind, device.id)
|
||||
.catch((e: Error) =>
|
||||
this.logger.error(
|
||||
`Failed to sync ${kind} device with LiveKit`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const previousRequestedId = requestedId;
|
||||
requestedId = device.id;
|
||||
// For the browser default input, ask for "default" as a non-exact
|
||||
// constraint: browsers that have no such device ignore it and capture
|
||||
// from the OS default input.
|
||||
(browserDefaultInput
|
||||
? lkRoom.switchActiveDevice(kind, "default", false)
|
||||
: lkRoom.switchActiveDevice(kind, device.id)
|
||||
).catch((e: Error) => {
|
||||
// LiveKit keeps capturing from the previous device, so record that
|
||||
// again to leave the next selection of this device something to do.
|
||||
// A selection made while the switch was in flight wins, though.
|
||||
if (requestedId === device.id) requestedId = previousRequestedId;
|
||||
this.logger.error(`Failed to sync ${kind} device with LiveKit`, e);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
syncDevice("audioinput", devices.audioInput.selected$);
|
||||
if (!controlledAudioDevices)
|
||||
|
||||
@@ -174,7 +174,9 @@ function generateRoomOption({
|
||||
publishDefaults,
|
||||
audioCaptureDefaults: {
|
||||
...liveKitOptions.audioCaptureDefaults,
|
||||
deviceId: devices.audioInput.selected$.value?.id,
|
||||
// "" is the virtual browser default: leave the constraint out so the
|
||||
// browser captures from the OS default input.
|
||||
deviceId: devices.audioInput.selected$.value?.id || undefined,
|
||||
echoCancellation: echoCancellationSetting.getValue(),
|
||||
noiseSuppression: noiseSuppressionSetting.getValue(),
|
||||
autoGainControl: autoGainControlSetting.getValue(),
|
||||
|
||||
+41
-23
@@ -36,12 +36,15 @@ export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
| { type: "number"; number: number };
|
||||
|
||||
export type AudioOutputDeviceLabel =
|
||||
export type AudioInputDeviceLabel =
|
||||
| DeviceLabel
|
||||
| { type: "speaker" }
|
||||
| { type: "earpiece" }
|
||||
| { type: "default"; name: string | null };
|
||||
|
||||
export type AudioOutputDeviceLabel =
|
||||
| AudioInputDeviceLabel
|
||||
| { type: "speaker" }
|
||||
| { type: "earpiece" };
|
||||
|
||||
/**
|
||||
* Base selected-device value shared by all media kinds.
|
||||
*
|
||||
@@ -191,7 +194,10 @@ function selectDevice$<Label>(
|
||||
});
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
class AudioInput implements MediaDevice<
|
||||
AudioInputDeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
> {
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
|
||||
|
||||
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
|
||||
@@ -203,7 +209,29 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
);
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
this.availableRaw$.pipe(map(buildDeviceMap)),
|
||||
this.availableRaw$.pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioInputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Browsers without a "default" pseudo-device (Firefox, Safari, and
|
||||
// in particular iOS) get a virtual default entry, listed first so
|
||||
// that it is what we use when the user has not chosen a microphone.
|
||||
// Its ID is the empty string, which consumers translate into "no
|
||||
// deviceId constraint": the browser then captures from whatever the
|
||||
// OS routes as the default input (a wired or Bluetooth headset when
|
||||
// one is connected) instead of EC pinning whichever device happens
|
||||
// to be enumerated first, which on iOS is the built-in microphone.
|
||||
// Unlike Chrome's "default" device, a stream opened this way does
|
||||
// not follow later changes of the OS default on desktop browsers;
|
||||
// iOS re-routes the audio session itself.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
return new Map<string, AudioInputDeviceLabel>([
|
||||
["", { type: "default", name: null }],
|
||||
...available,
|
||||
]);
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
@@ -257,30 +285,20 @@ export class AudioOutput implements MediaDevice<
|
||||
map((availableRaw) => {
|
||||
let available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// 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.
|
||||
// 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.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available = new Map<string, AudioOutputDeviceLabel>([
|
||||
["", { type: "default", name: null }],
|
||||
...available,
|
||||
]);
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// 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) {
|
||||
// set to empty map if we are on Safari, because it does not support setSinkId
|
||||
available = new Map();
|
||||
}
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
@@ -384,7 +402,7 @@ export class MediaDevices {
|
||||
false,
|
||||
);
|
||||
public readonly audioInput: MediaDevice<
|
||||
DeviceLabel,
|
||||
AudioInputDeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
> = new AudioInput(this.usingNames$, this.scope);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user