review: extract ControlledAudioOutput in its own file
This commit is contained in:
132
src/state/ControlledAudioOutput.ts
Normal file
132
src/state/ControlledAudioOutput.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
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 { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
||||||
|
outputDevice$ as controlledOutputSelection$,
|
||||||
|
} from "../controls.ts";
|
||||||
|
import type { Behavior } from "./Behavior.ts";
|
||||||
|
import type { ObservableScope } from "./ObservableScope.ts";
|
||||||
|
import {
|
||||||
|
type AudioOutputDeviceLabel,
|
||||||
|
availableRawDevices$,
|
||||||
|
iosDeviceMenu$,
|
||||||
|
type MediaDevice,
|
||||||
|
type SelectedAudioOutputDevice,
|
||||||
|
} from "./MediaDevices.ts";
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A special implementation of audio output that allows the hosting application
|
||||||
|
* to have more control over the device selection process. This is used when the
|
||||||
|
* `controlledAudioDevices` URL parameter is set, which is currently only true on mobile.
|
||||||
|
*/
|
||||||
|
export class ControlledAudioOutput implements MediaDevice<
|
||||||
|
AudioOutputDeviceLabel,
|
||||||
|
SelectedAudioOutputDevice
|
||||||
|
> {
|
||||||
|
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
|
||||||
|
// We need to subscribe to the raw devices so that the OS does update the input
|
||||||
|
// back to what it was before. otherwise we will switch back to the default
|
||||||
|
// whenever we allocate a new stream.
|
||||||
|
public readonly availableRaw$ = availableRawDevices$(
|
||||||
|
"audiooutput",
|
||||||
|
this.usingNames$,
|
||||||
|
this.scope,
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
public readonly available$ = this.scope.behavior(
|
||||||
|
combineLatest(
|
||||||
|
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||||
|
(availableRaw, iosDeviceMenu) => {
|
||||||
|
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||||
|
availableRaw.map(
|
||||||
|
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||||
|
let deviceLabel: AudioOutputDeviceLabel;
|
||||||
|
// if (isExternalHeadset) // Do we want this?
|
||||||
|
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||||
|
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||||
|
else deviceLabel = { type: "name", name };
|
||||||
|
return [id, deviceLabel];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a virtual earpiece device in case a non-earpiece device is
|
||||||
|
// designated for this purpose
|
||||||
|
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) {
|
||||||
|
this.logger.info(
|
||||||
|
`IOS Add virtual earpiece device with id ${EARPIECE_CONFIG_ID}`,
|
||||||
|
);
|
||||||
|
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return available;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly deviceSelection$ = new Subject<string>();
|
||||||
|
|
||||||
|
public select(id: string): void {
|
||||||
|
this.logger.info(`select device: ${id}`);
|
||||||
|
this.deviceSelection$.next(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly selected$ = this.scope.behavior(
|
||||||
|
combineLatest(
|
||||||
|
[
|
||||||
|
this.available$,
|
||||||
|
merge(
|
||||||
|
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||||
|
this.deviceSelection$,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
(available, preferredId) => {
|
||||||
|
const id = preferredId ?? available.keys().next().value;
|
||||||
|
return id === undefined
|
||||||
|
? undefined
|
||||||
|
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
tap((selected) => {
|
||||||
|
this.logger.debug(`selected device: ${selected?.id}`);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly usingNames$: Behavior<boolean>,
|
||||||
|
private readonly scope: ObservableScope,
|
||||||
|
) {
|
||||||
|
this.selected$.subscribe((device) => {
|
||||||
|
// Let the hosting application know which output device has been selected.
|
||||||
|
// This information is probably only of interest if the earpiece mode has
|
||||||
|
// been selected - for example, Element X iOS listens to this to determine
|
||||||
|
// whether it should enable the proximity sensor.
|
||||||
|
if (device !== undefined) {
|
||||||
|
this.logger.info("onAudioDeviceSelect called:", device);
|
||||||
|
window.controls.onAudioDeviceSelect?.(device.id);
|
||||||
|
// Also invoke the deprecated callback for backward compatibility
|
||||||
|
window.controls.onOutputDeviceSelect?.(device.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.available$.subscribe((available) => {
|
||||||
|
this.logger.debug("available devices:", available);
|
||||||
|
});
|
||||||
|
this.availableRaw$.subscribe((availableRaw) => {
|
||||||
|
this.logger.debug("available raw devices:", availableRaw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,37 +9,28 @@ import {
|
|||||||
combineLatest,
|
combineLatest,
|
||||||
filter,
|
filter,
|
||||||
map,
|
map,
|
||||||
merge,
|
type Observable,
|
||||||
pairwise,
|
pairwise,
|
||||||
startWith,
|
|
||||||
Subject,
|
Subject,
|
||||||
switchMap,
|
switchMap,
|
||||||
type Observable,
|
|
||||||
tap,
|
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||||
audioInput as audioInputSetting,
|
audioInput as audioInputSetting,
|
||||||
audioOutput as audioOutputSetting,
|
audioOutput as audioOutputSetting,
|
||||||
videoInput as videoInputSetting,
|
videoInput as videoInputSetting,
|
||||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import { type ObservableScope } from "./ObservableScope";
|
import { type ObservableScope } from "./ObservableScope";
|
||||||
import {
|
import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls";
|
||||||
outputDevice$ as controlledOutputSelection$,
|
|
||||||
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
|
||||||
} from "../controls";
|
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams } from "../UrlParams";
|
||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
import { switchWhen } from "../utils/observable";
|
import { switchWhen } from "../utils/observable";
|
||||||
import { type Behavior, constant } from "./Behavior";
|
import { type Behavior, constant } from "./Behavior";
|
||||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
||||||
|
import { ControlledAudioOutput } from "./ControlledAudioOutput.ts";
|
||||||
// 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 type DeviceLabel =
|
export type DeviceLabel =
|
||||||
| { type: "name"; name: string }
|
| { type: "name"; name: string }
|
||||||
@@ -127,7 +118,7 @@ export interface MediaDevice<Label, Selected> {
|
|||||||
export const iosDeviceMenu$ =
|
export const iosDeviceMenu$ =
|
||||||
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
|
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||||
|
|
||||||
function availableRawDevices$(
|
export function availableRawDevices$(
|
||||||
kind: MediaDeviceKind,
|
kind: MediaDeviceKind,
|
||||||
usingNames$: Behavior<boolean>,
|
usingNames$: Behavior<boolean>,
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
@@ -175,9 +166,6 @@ function buildDeviceMap(
|
|||||||
function selectDevice$<Label>(
|
function selectDevice$<Label>(
|
||||||
available$: Observable<Map<string, Label>>,
|
available$: Observable<Map<string, Label>>,
|
||||||
preferredId$: Observable<string | undefined>,
|
preferredId$: Observable<string | undefined>,
|
||||||
defaultPicker: (available: Map<string, Label>) => string | undefined = (
|
|
||||||
available,
|
|
||||||
) => available.keys().next().value,
|
|
||||||
): Observable<string | undefined> {
|
): Observable<string | undefined> {
|
||||||
return combineLatest([available$, preferredId$], (available, preferredId) => {
|
return combineLatest([available$, preferredId$], (available, preferredId) => {
|
||||||
if (available.size) {
|
if (available.size) {
|
||||||
@@ -196,7 +184,7 @@ function selectDevice$<Label>(
|
|||||||
return preferredId;
|
return preferredId;
|
||||||
} else {
|
} else {
|
||||||
// No preferred, so pick a default.
|
// No preferred, so pick a default.
|
||||||
return defaultPicker(available);
|
return available.keys().next().value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -319,111 +307,6 @@ export class AudioOutput implements MediaDevice<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A special implementation of audio output that allows the hosting application
|
|
||||||
* to have more control over the device selection process. This is used when the
|
|
||||||
* `controlledAudioDevices` URL parameter is set, which is currently only true on mobile.
|
|
||||||
*/
|
|
||||||
class ControlledAudioOutput implements MediaDevice<
|
|
||||||
AudioOutputDeviceLabel,
|
|
||||||
SelectedAudioOutputDevice
|
|
||||||
> {
|
|
||||||
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
|
|
||||||
// We need to subscribe to the raw devices so that the OS does update the input
|
|
||||||
// back to what it was before. otherwise we will switch back to the default
|
|
||||||
// whenever we allocate a new stream.
|
|
||||||
public readonly availableRaw$ = availableRawDevices$(
|
|
||||||
"audiooutput",
|
|
||||||
this.usingNames$,
|
|
||||||
this.scope,
|
|
||||||
this.logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
public readonly available$ = this.scope.behavior(
|
|
||||||
combineLatest(
|
|
||||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
|
||||||
(availableRaw, iosDeviceMenu) => {
|
|
||||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
|
||||||
availableRaw.map(
|
|
||||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
|
||||||
let deviceLabel: AudioOutputDeviceLabel;
|
|
||||||
// if (isExternalHeadset) // Do we want this?
|
|
||||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
|
||||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
|
||||||
else deviceLabel = { type: "name", name };
|
|
||||||
return [id, deviceLabel];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create a virtual earpiece device in case a non-earpiece device is
|
|
||||||
// designated for this purpose
|
|
||||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) {
|
|
||||||
this.logger.info(
|
|
||||||
`IOS Add virtual earpiece device with id ${EARPIECE_CONFIG_ID}`,
|
|
||||||
);
|
|
||||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return available;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly deviceSelection$ = new Subject<string>();
|
|
||||||
|
|
||||||
public select(id: string): void {
|
|
||||||
this.logger.info(`select device: ${id}`);
|
|
||||||
this.deviceSelection$.next(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly selected$ = this.scope.behavior(
|
|
||||||
combineLatest(
|
|
||||||
[
|
|
||||||
this.available$,
|
|
||||||
merge(
|
|
||||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
|
||||||
this.deviceSelection$,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
(available, preferredId) => {
|
|
||||||
const id = preferredId ?? available.keys().next().value;
|
|
||||||
return id === undefined
|
|
||||||
? undefined
|
|
||||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
|
||||||
},
|
|
||||||
).pipe(
|
|
||||||
tap((selected) => {
|
|
||||||
this.logger.debug(`selected device: ${selected?.id}`);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private readonly usingNames$: Behavior<boolean>,
|
|
||||||
private readonly scope: ObservableScope,
|
|
||||||
) {
|
|
||||||
this.selected$.subscribe((device) => {
|
|
||||||
// Let the hosting application know which output device has been selected.
|
|
||||||
// This information is probably only of interest if the earpiece mode has
|
|
||||||
// been selected - for example, Element X iOS listens to this to determine
|
|
||||||
// whether it should enable the proximity sensor.
|
|
||||||
if (device !== undefined) {
|
|
||||||
this.logger.info("onAudioDeviceSelect called:", device);
|
|
||||||
window.controls.onAudioDeviceSelect?.(device.id);
|
|
||||||
// Also invoke the deprecated callback for backward compatibility
|
|
||||||
window.controls.onOutputDeviceSelect?.(device.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.available$.subscribe((available) => {
|
|
||||||
this.logger.debug("available devices:", available);
|
|
||||||
});
|
|
||||||
this.availableRaw$.subscribe((availableRaw) => {
|
|
||||||
this.logger.debug("available raw devices:", availableRaw);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||||
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
|
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user