mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a64a911b23 | ||
|
|
bc772fadd3 | ||
|
|
11ef319536 | ||
|
|
dda549ffd2 | ||
|
|
4b8cfc4921 |
@@ -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: "" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
@@ -271,9 +299,6 @@ export class AudioOutput implements MediaDevice<
|
||||
// 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;
|
||||
}),
|
||||
),
|
||||
@@ -377,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