mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-02 19:49:23 +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 { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
|
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
|
||||||
|
|
||||||
|
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import {
|
import {
|
||||||
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
||||||
outputDevice$ as controlledOutputSelection$,
|
outputDevice$ as controlledOutputSelection$,
|
||||||
@@ -24,7 +25,7 @@ import {
|
|||||||
|
|
||||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||||
// the ios swift team.
|
// 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
|
* A special implementation of audio output that allows the hosting application
|
||||||
@@ -94,7 +95,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
(available, preferredId) => {
|
(available, preferredId) => {
|
||||||
const id = preferredId ?? available.keys().next().value;
|
const id = preferredId ?? this.chooseDefaultId(available);
|
||||||
return id === undefined
|
return id === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
: { 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(
|
public constructor(
|
||||||
private readonly usingNames$: Behavior<boolean>,
|
private readonly usingNames$: Behavior<boolean>,
|
||||||
private readonly scope: ObservableScope,
|
private readonly scope: ObservableScope,
|
||||||
|
private readonly initialIntent: RTCCallIntent | undefined = undefined,
|
||||||
) {
|
) {
|
||||||
this.selected$.subscribe((device) => {
|
this.selected$.subscribe((device) => {
|
||||||
// Let the hosting application know which output device has been selected.
|
// Let the hosting application know which output device has been selected.
|
||||||
|
|||||||
@@ -376,7 +376,11 @@ export class MediaDevices {
|
|||||||
getUrlParams().callIntent,
|
getUrlParams().callIntent,
|
||||||
window.controls,
|
window.controls,
|
||||||
)
|
)
|
||||||
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
|
: new IOSControlledAudioOutput(
|
||||||
|
this.usingNames$,
|
||||||
|
this.scope,
|
||||||
|
getUrlParams().callIntent,
|
||||||
|
)
|
||||||
: new AudioOutput(this.usingNames$, this.scope);
|
: new AudioOutput(this.usingNames$, this.scope);
|
||||||
|
|
||||||
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
||||||
|
|||||||
Reference in New Issue
Block a user