Skip to content

Commit 4f51881

Browse files
committed
review: extract ControlledAudioOutput in its own file
1 parent c8b8d35 commit 4f51881

File tree

2 files changed

+138
-123
lines changed

2 files changed

+138
-123
lines changed

src/state/ControlledAudioOutput.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
Copyright 2026 Element Corp.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
9+
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
10+
11+
import {
12+
availableOutputDevices$ as controlledAvailableOutputDevices$,
13+
outputDevice$ as controlledOutputSelection$,
14+
} from "../controls.ts";
15+
import type { Behavior } from "./Behavior.ts";
16+
import type { ObservableScope } from "./ObservableScope.ts";
17+
import {
18+
type AudioOutputDeviceLabel,
19+
availableRawDevices$,
20+
iosDeviceMenu$,
21+
type MediaDevice,
22+
type SelectedAudioOutputDevice,
23+
} from "./MediaDevices.ts";
24+
25+
// This hardcoded id is used in EX ios! It can only be changed in coordination with
26+
// the ios swift team.
27+
const EARPIECE_CONFIG_ID = "earpiece-id";
28+
29+
/**
30+
* A special implementation of audio output that allows the hosting application
31+
* to have more control over the device selection process. This is used when the
32+
* `controlledAudioDevices` URL parameter is set, which is currently only true on mobile.
33+
*/
34+
export class ControlledAudioOutput implements MediaDevice<
35+
AudioOutputDeviceLabel,
36+
SelectedAudioOutputDevice
37+
> {
38+
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
39+
// We need to subscribe to the raw devices so that the OS does update the input
40+
// back to what it was before. otherwise we will switch back to the default
41+
// whenever we allocate a new stream.
42+
public readonly availableRaw$ = availableRawDevices$(
43+
"audiooutput",
44+
this.usingNames$,
45+
this.scope,
46+
this.logger,
47+
);
48+
49+
public readonly available$ = this.scope.behavior(
50+
combineLatest(
51+
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
52+
(availableRaw, iosDeviceMenu) => {
53+
const available = new Map<string, AudioOutputDeviceLabel>(
54+
availableRaw.map(
55+
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
56+
let deviceLabel: AudioOutputDeviceLabel;
57+
// if (isExternalHeadset) // Do we want this?
58+
if (isEarpiece) deviceLabel = { type: "earpiece" };
59+
else if (isSpeaker) deviceLabel = { type: "speaker" };
60+
else deviceLabel = { type: "name", name };
61+
return [id, deviceLabel];
62+
},
63+
),
64+
);
65+
66+
// Create a virtual earpiece device in case a non-earpiece device is
67+
// designated for this purpose
68+
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) {
69+
this.logger.info(
70+
`IOS Add virtual earpiece device with id ${EARPIECE_CONFIG_ID}`,
71+
);
72+
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
73+
}
74+
75+
return available;
76+
},
77+
),
78+
);
79+
80+
private readonly deviceSelection$ = new Subject<string>();
81+
82+
public select(id: string): void {
83+
this.logger.info(`select device: ${id}`);
84+
this.deviceSelection$.next(id);
85+
}
86+
87+
public readonly selected$ = this.scope.behavior(
88+
combineLatest(
89+
[
90+
this.available$,
91+
merge(
92+
controlledOutputSelection$.pipe(startWith(undefined)),
93+
this.deviceSelection$,
94+
),
95+
],
96+
(available, preferredId) => {
97+
const id = preferredId ?? available.keys().next().value;
98+
return id === undefined
99+
? undefined
100+
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
101+
},
102+
).pipe(
103+
tap((selected) => {
104+
this.logger.debug(`selected device: ${selected?.id}`);
105+
}),
106+
),
107+
);
108+
109+
public constructor(
110+
private readonly usingNames$: Behavior<boolean>,
111+
private readonly scope: ObservableScope,
112+
) {
113+
this.selected$.subscribe((device) => {
114+
// Let the hosting application know which output device has been selected.
115+
// This information is probably only of interest if the earpiece mode has
116+
// been selected - for example, Element X iOS listens to this to determine
117+
// whether it should enable the proximity sensor.
118+
if (device !== undefined) {
119+
this.logger.info("onAudioDeviceSelect called:", device);
120+
window.controls.onAudioDeviceSelect?.(device.id);
121+
// Also invoke the deprecated callback for backward compatibility
122+
window.controls.onOutputDeviceSelect?.(device.id);
123+
}
124+
});
125+
this.available$.subscribe((available) => {
126+
this.logger.debug("available devices:", available);
127+
});
128+
this.availableRaw$.subscribe((availableRaw) => {
129+
this.logger.debug("available raw devices:", availableRaw);
130+
});
131+
}
132+
}

src/state/MediaDevices.ts

Lines changed: 6 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,28 @@ import {
99
combineLatest,
1010
filter,
1111
map,
12-
merge,
12+
type Observable,
1313
pairwise,
14-
startWith,
1514
Subject,
1615
switchMap,
17-
type Observable,
18-
tap,
1916
} from "rxjs";
2017
import { createMediaDeviceObserver } from "@livekit/components-core";
2118
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
2219

2320
import {
21+
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
2422
audioInput as audioInputSetting,
2523
audioOutput as audioOutputSetting,
2624
videoInput as videoInputSetting,
27-
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
2825
} from "../settings/settings";
2926
import { type ObservableScope } from "./ObservableScope";
30-
import {
31-
outputDevice$ as controlledOutputSelection$,
32-
availableOutputDevices$ as controlledAvailableOutputDevices$,
33-
} from "../controls";
27+
import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls";
3428
import { getUrlParams } from "../UrlParams";
3529
import { platform } from "../Platform";
3630
import { switchWhen } from "../utils/observable";
3731
import { type Behavior, constant } from "./Behavior";
3832
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
39-
40-
// This hardcoded id is used in EX ios! It can only be changed in coordination with
41-
// the ios swift team.
42-
const EARPIECE_CONFIG_ID = "earpiece-id";
33+
import { ControlledAudioOutput } from "./ControlledAudioOutput.ts";
4334

4435
export type DeviceLabel =
4536
| { type: "name"; name: string }
@@ -127,7 +118,7 @@ export interface MediaDevice<Label, Selected> {
127118
export const iosDeviceMenu$ =
128119
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
129120

130-
function availableRawDevices$(
121+
export function availableRawDevices$(
131122
kind: MediaDeviceKind,
132123
usingNames$: Behavior<boolean>,
133124
scope: ObservableScope,
@@ -175,9 +166,6 @@ function buildDeviceMap(
175166
function selectDevice$<Label>(
176167
available$: Observable<Map<string, Label>>,
177168
preferredId$: Observable<string | undefined>,
178-
defaultPicker: (available: Map<string, Label>) => string | undefined = (
179-
available,
180-
) => available.keys().next().value,
181169
): Observable<string | undefined> {
182170
return combineLatest([available$, preferredId$], (available, preferredId) => {
183171
if (available.size) {
@@ -196,7 +184,7 @@ function selectDevice$<Label>(
196184
return preferredId;
197185
} else {
198186
// No preferred, so pick a default.
199-
return defaultPicker(available);
187+
return available.keys().next().value;
200188
}
201189
}
202190
return undefined;
@@ -319,111 +307,6 @@ export class AudioOutput implements MediaDevice<
319307
}
320308
}
321309

322-
/**
323-
* A special implementation of audio output that allows the hosting application
324-
* to have more control over the device selection process. This is used when the
325-
* `controlledAudioDevices` URL parameter is set, which is currently only true on mobile.
326-
*/
327-
class ControlledAudioOutput implements MediaDevice<
328-
AudioOutputDeviceLabel,
329-
SelectedAudioOutputDevice
330-
> {
331-
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
332-
// We need to subscribe to the raw devices so that the OS does update the input
333-
// back to what it was before. otherwise we will switch back to the default
334-
// whenever we allocate a new stream.
335-
public readonly availableRaw$ = availableRawDevices$(
336-
"audiooutput",
337-
this.usingNames$,
338-
this.scope,
339-
this.logger,
340-
);
341-
342-
public readonly available$ = this.scope.behavior(
343-
combineLatest(
344-
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
345-
(availableRaw, iosDeviceMenu) => {
346-
const available = new Map<string, AudioOutputDeviceLabel>(
347-
availableRaw.map(
348-
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
349-
let deviceLabel: AudioOutputDeviceLabel;
350-
// if (isExternalHeadset) // Do we want this?
351-
if (isEarpiece) deviceLabel = { type: "earpiece" };
352-
else if (isSpeaker) deviceLabel = { type: "speaker" };
353-
else deviceLabel = { type: "name", name };
354-
return [id, deviceLabel];
355-
},
356-
),
357-
);
358-
359-
// Create a virtual earpiece device in case a non-earpiece device is
360-
// designated for this purpose
361-
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) {
362-
this.logger.info(
363-
`IOS Add virtual earpiece device with id ${EARPIECE_CONFIG_ID}`,
364-
);
365-
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
366-
}
367-
368-
return available;
369-
},
370-
),
371-
);
372-
373-
private readonly deviceSelection$ = new Subject<string>();
374-
375-
public select(id: string): void {
376-
this.logger.info(`select device: ${id}`);
377-
this.deviceSelection$.next(id);
378-
}
379-
380-
public readonly selected$ = this.scope.behavior(
381-
combineLatest(
382-
[
383-
this.available$,
384-
merge(
385-
controlledOutputSelection$.pipe(startWith(undefined)),
386-
this.deviceSelection$,
387-
),
388-
],
389-
(available, preferredId) => {
390-
const id = preferredId ?? available.keys().next().value;
391-
return id === undefined
392-
? undefined
393-
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
394-
},
395-
).pipe(
396-
tap((selected) => {
397-
this.logger.debug(`selected device: ${selected?.id}`);
398-
}),
399-
),
400-
);
401-
402-
public constructor(
403-
private readonly usingNames$: Behavior<boolean>,
404-
private readonly scope: ObservableScope,
405-
) {
406-
this.selected$.subscribe((device) => {
407-
// Let the hosting application know which output device has been selected.
408-
// This information is probably only of interest if the earpiece mode has
409-
// been selected - for example, Element X iOS listens to this to determine
410-
// whether it should enable the proximity sensor.
411-
if (device !== undefined) {
412-
this.logger.info("onAudioDeviceSelect called:", device);
413-
window.controls.onAudioDeviceSelect?.(device.id);
414-
// Also invoke the deprecated callback for backward compatibility
415-
window.controls.onOutputDeviceSelect?.(device.id);
416-
}
417-
});
418-
this.available$.subscribe((available) => {
419-
this.logger.debug("available devices:", available);
420-
});
421-
this.availableRaw$.subscribe((availableRaw) => {
422-
this.logger.debug("available raw devices:", availableRaw);
423-
});
424-
}
425-
}
426-
427310
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
428311
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
429312

0 commit comments

Comments
 (0)