diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 7c2544a8..d6af3998 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -19,6 +19,7 @@ import { } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; import { audioInput as audioInputSetting, @@ -35,7 +36,6 @@ import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; import { type Behavior, constant } from "./Behavior"; -// import { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; // This hardcoded id is used in EX ios! It can only be changed in coordination with // the ios swift team. @@ -257,6 +257,8 @@ class AudioInput implements MediaDevice { public constructor( private readonly usingNames$: Behavior, private readonly scope: ObservableScope, + private readonly initialCallIntent: RTCCallIntent | undefined = undefined, + private platform: "android" | "ios" | "desktop" | undefined = undefined, ) { this.available$.subscribe((available) => { this.logger.info("[audio-input] available devices:", available); @@ -368,16 +370,13 @@ class ControlledAudioOutput implements MediaDevice< const availableIds = Array.from(available.entries()) .map(([id, label]) => `${id}:${JSON.stringify(label)}`) .join(", "); - this.logger.info( - "[MediaDevices ControlledAudioOutput] available devices:", - availableIds, - ); + this.logger.info("available devices:", availableIds); // 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( - `[MediaDevices ControlledAudioOutput] IOS Add virtual earpiece device with id ${EARPIECE_CONFIG_ID}`, + `IOS Add virtual earpiece device with id ${EARPIECE_CONFIG_ID}`, ); available.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); } @@ -390,9 +389,7 @@ class ControlledAudioOutput implements MediaDevice< private readonly deviceSelection$ = new Subject(); public select(id: string): void { - this.logger.info( - `[MediaDevices ControlledAudioOutput] select device: ${id}`, - ); + this.logger.info(`select device: ${id}`); this.deviceSelection$.next(id); } @@ -407,18 +404,73 @@ class ControlledAudioOutput implements MediaDevice< ], (available, preferredId) => { this.logger.debug( - `[MediaDevices ControlledAudioOutput] selecting device. Preferred: ${preferredId}, Available: ${Array.from(available.keys()).join(",")}`, + `selecting device: Preferred:${preferredId}: platform ${this.platform}, intent:${this.initialIntent}: Available: ${Array.from(available.keys()).join(",")}`, ); - const id = preferredId ?? available.keys().next().value; - return id === undefined - ? undefined - : { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; + let id: string | undefined = preferredId; + if (id === undefined) { + // No preferred device, so pick a default. + // The logic for the default is different based on the call type. + // For example for a voice call we want to default to the earpiece if it's available, + // but for a video call we want to default to the speaker. + // If the user is using a BT headset we want to default to that, as it's likely what they want to use for both video and voice calls. + // The audio routing / selection is also different on iOS and Android, so we need to take that into account as well. + if (this.platform == "android") { + this.logger.debug( + `Android routing logic intent: ${this.initialIntent} finding best default...`, + ); + if (this.initialIntent === "audio") { + const systemProposed = available.keys().next().value; + // If no headset is connected, android will route to the speaker by default, + // but for a voice call we want to route to the earpiece instead, + // so override the system proposed routing in that case. + if ( + systemProposed && + available.get(systemProposed)?.type === "speaker" + ) { + // search for the headset + const headsetEntry = Array.from(available.entries()).find( + ([_, label]) => label.type === "earpiece", + ); + if (headsetEntry) { + this.logger.debug( + `Android routing: Switch to earpiece instead of speaker for voice call`, + ); + id = headsetEntry[0]; + } else { + this.logger.debug( + `Android routing: no earpiece found, cannot switch, use system proposed routing`, + ); + id = systemProposed; + } + } else { + this.logger.debug( + `Android routing: Use system proposed routing`, + ); + id = systemProposed; + } + } else { + // Use the system best proposed best routing. + id = available.keys().next().value; + } + } else { + // Pick the first one, based on the assumption that the OS will have already routed to the "best" available output device. + id = available.keys().next().value; + } + } + + if (id !== undefined) { + return { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; + } else { + return undefined; + } + // id = preferredId ?? available.keys().next().value; + // return id === undefined + // ? undefined + // : { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; }, ).pipe( tap((selected) => { - this.logger.debug( - `[MediaDevices ControlledAudioOutput] selected device: ${selected?.id}`, - ); + this.logger.debug(`selected device: ${selected?.id}`); }), ), ); @@ -426,7 +478,8 @@ class ControlledAudioOutput implements MediaDevice< public constructor( private readonly usingNames$: Behavior, private readonly scope: ObservableScope, - // private initialIntent: RTCCallIntent | undefined = undefined, + private initialIntent: RTCCallIntent | undefined = undefined, + private platform: "android" | "ios" | "desktop" | undefined = undefined, ) { this.selected$.subscribe((device) => { // Let the hosting application know which output device has been selected. @@ -434,26 +487,17 @@ class ControlledAudioOutput implements MediaDevice< // 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( - "[MediaDevices controlled-output] onAudioDeviceSelect called:", - device, - ); + 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.info( - "[MediaDevices controlled-output] available devices:", - available, - ); + this.logger.debug("available devices:", available); }); this.availableRaw$.subscribe((availableRaw) => { - this.logger.info( - "[MediaDevices controlled-output] available raw devices:", - availableRaw, - ); + this.logger.debug("available raw devices:", availableRaw); }); } } @@ -520,7 +564,12 @@ export class MediaDevices { AudioOutputDeviceLabel, SelectedAudioOutputDevice > = getUrlParams().controlledAudioDevices - ? new ControlledAudioOutput(this.usingNames$, this.scope) + ? new ControlledAudioOutput( + this.usingNames$, + this.scope, + getUrlParams().callIntent, + platform, + ) : new AudioOutput(this.usingNames$, this.scope); public readonly videoInput: MediaDevice =