mirror of
https://github.com/vector-im/element-call.git
synced 2026-06-30 18:02:56 +00:00
fix(voice): Default to earpiece for voice only call
This commit is contained in:
162
src/state/IOSControlledAudioOutput.test.ts
Normal file
162
src/state/IOSControlledAudioOutput.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
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, expect, it, vi } from "vitest";
|
||||
import { type Observable, of } from "rxjs";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { constant } from "./Behavior";
|
||||
import { type SelectedAudioOutputDevice } from "./MediaDevices";
|
||||
import {
|
||||
availableOutputDevices$,
|
||||
type Controls,
|
||||
type OutputDevice,
|
||||
outputDevice$,
|
||||
} from "../controls";
|
||||
import {
|
||||
EARPIECE_CONFIG_ID,
|
||||
IOSControlledAudioOutput,
|
||||
} from "./IOSControlledAudioOutput";
|
||||
|
||||
// `vi.mock` calls are hoisted above all imports, so the static imports below
|
||||
// already see these mocks. Force the iOS platform so that the virtual earpiece
|
||||
// is available, and stub the livekit device observer (only subscribed for its
|
||||
// side effects).
|
||||
vi.mock("../Platform", () => ({ platform: "ios" }));
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
createMediaDeviceObserver: (): Observable<MediaDeviceInfo[]> => of([]),
|
||||
}));
|
||||
|
||||
// On iOS the host reports a single device for the current route. When output is
|
||||
// on the loudspeaker it is flagged `forEarpiece`, which makes the controller
|
||||
// expose a virtual earpiece device.
|
||||
const SPEAKER: OutputDevice = {
|
||||
id: "speaker",
|
||||
name: "Speaker",
|
||||
isSpeaker: true,
|
||||
forEarpiece: true,
|
||||
};
|
||||
|
||||
// A connected headset (e.g. Bluetooth) is reported as a plain named device,
|
||||
// with neither the speaker nor earpiece flag set.
|
||||
const HEADSET: OutputDevice = {
|
||||
id: "bt",
|
||||
name: "AirPods",
|
||||
};
|
||||
|
||||
let testScope: ObservableScope;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
window.controls = {
|
||||
onAudioDeviceSelect: vi.fn(),
|
||||
onOutputDeviceSelect: vi.fn(),
|
||||
} as unknown as Controls;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
});
|
||||
|
||||
/**
|
||||
* Subscribe to the controller's `selected$` and return a getter for the latest
|
||||
* emitted value.
|
||||
*/
|
||||
function latestSelection(
|
||||
output: InstanceType<typeof IOSControlledAudioOutput>,
|
||||
): () => SelectedAudioOutputDevice | undefined {
|
||||
let latest: SelectedAudioOutputDevice | undefined;
|
||||
output.selected$.subscribe((s) => {
|
||||
latest = s;
|
||||
});
|
||||
return () => latest;
|
||||
}
|
||||
|
||||
describe("Default selection", () => {
|
||||
it("defaults to the earpiece for voice (audio) calls", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
availableOutputDevices$.next([SPEAKER]);
|
||||
|
||||
expect(selected()).toEqual({
|
||||
id: EARPIECE_CONFIG_ID,
|
||||
virtualEarpiece: true,
|
||||
});
|
||||
expect(window.controls.onAudioDeviceSelect).toHaveBeenLastCalledWith(
|
||||
EARPIECE_CONFIG_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to the speaker for video calls", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"video",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
availableOutputDevices$.next([SPEAKER]);
|
||||
|
||||
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
|
||||
});
|
||||
|
||||
it("keeps a headset for voice calls instead of forcing the earpiece", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
// The host proposes the headset as the route (listed first), even though a
|
||||
// forEarpiece device is also present so the virtual earpiece exists.
|
||||
availableOutputDevices$.next([HEADSET, SPEAKER]);
|
||||
|
||||
expect(selected()).toEqual({ id: HEADSET.id, virtualEarpiece: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Explicit selection", () => {
|
||||
it("an explicit user selection overrides the earpiece default", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
availableOutputDevices$.next([SPEAKER]);
|
||||
// Earpiece by default for a voice call...
|
||||
expect(selected()).toEqual({
|
||||
id: EARPIECE_CONFIG_ID,
|
||||
virtualEarpiece: true,
|
||||
});
|
||||
|
||||
// ...until the user explicitly picks the speaker.
|
||||
output.select(SPEAKER.id);
|
||||
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
|
||||
});
|
||||
|
||||
it("a host selection overrides the earpiece default", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
availableOutputDevices$.next([SPEAKER]);
|
||||
outputDevice$.next(SPEAKER.id);
|
||||
|
||||
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
|
||||
|
||||
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
||||
outputDevice$ as controlledOutputSelection$,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
export const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
|
||||
/**
|
||||
* A special implementation of audio output that allows the hosting application
|
||||
@@ -94,7 +95,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
const id = preferredId ?? this.chooseDefaultId(available);
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
@@ -106,9 +107,41 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Chooses the default output device when no explicit selection (from the user
|
||||
* or the hosting application) has been made yet.
|
||||
*
|
||||
* For voice calls (`initialIntent === "audio"`) we want to start on the
|
||||
* earpiece rather than the speaker, like a regular phone call. We only
|
||||
* override when the device that would otherwise be the default is the
|
||||
* speaker: if the host already routed to a headset (e.g. Bluetooth) — which
|
||||
* is reported as a plain named device, not "speaker"/"earpiece" — we keep it.
|
||||
* This mirrors the Android behaviour in {@link AndroidControlledAudioOutput}.
|
||||
*/
|
||||
private chooseDefaultId(
|
||||
available: Map<string, AudioOutputDeviceLabel>,
|
||||
): string | undefined {
|
||||
const firstId = available.keys().next().value;
|
||||
if (this.initialIntent === "audio") {
|
||||
const firstLabel =
|
||||
firstId !== undefined ? available.get(firstId) : undefined;
|
||||
if (firstLabel?.type === "speaker") {
|
||||
for (const [id, label] of available)
|
||||
if (label.type === "earpiece") {
|
||||
this.logger.info(
|
||||
`IOS routing: default to earpiece ${id} instead of speaker for voice call`,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstId;
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly initialIntent: RTCCallIntent | undefined = undefined,
|
||||
) {
|
||||
this.selected$.subscribe((device) => {
|
||||
// Let the hosting application know which output device has been selected.
|
||||
|
||||
@@ -376,7 +376,11 @@ export class MediaDevices {
|
||||
getUrlParams().callIntent,
|
||||
window.controls,
|
||||
)
|
||||
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
|
||||
: new IOSControlledAudioOutput(
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
getUrlParams().callIntent,
|
||||
)
|
||||
: new AudioOutput(this.usingNames$, this.scope);
|
||||
|
||||
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
||||
|
||||
Reference in New Issue
Block a user