diff --git a/src/state/ControlledAudioOutput.ts b/src/state/ControlledAudioOutput.ts new file mode 100644 index 00000000..a3fe9387 --- /dev/null +++ b/src/state/ControlledAudioOutput.ts @@ -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( + 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(); + + 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, + 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); + }); + } +} diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 052f55e6..d74002b5 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -9,37 +9,28 @@ import { combineLatest, filter, map, - merge, + type Observable, pairwise, - startWith, Subject, switchMap, - type Observable, - tap, } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { + alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, audioInput as audioInputSetting, audioOutput as audioOutputSetting, videoInput as videoInputSetting, - alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, } from "../settings/settings"; import { type ObservableScope } from "./ObservableScope"; -import { - outputDevice$ as controlledOutputSelection$, - availableOutputDevices$ as controlledAvailableOutputDevices$, -} from "../controls"; +import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls"; import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; import { type Behavior, constant } from "./Behavior"; import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.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"; +import { ControlledAudioOutput } from "./ControlledAudioOutput.ts"; export type DeviceLabel = | { type: "name"; name: string } @@ -127,7 +118,7 @@ export interface MediaDevice { export const iosDeviceMenu$ = platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$; -function availableRawDevices$( +export function availableRawDevices$( kind: MediaDeviceKind, usingNames$: Behavior, scope: ObservableScope, @@ -175,9 +166,6 @@ function buildDeviceMap( function selectDevice$