2025-06-20 12:37:25 -04:00
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
combineLatest ,
filter ,
map ,
merge ,
pairwise ,
startWith ,
Subject ,
switchMap ,
type Observable ,
2026-03-12 19:00:09 +01:00
tap ,
2025-06-20 12:37:25 -04:00
} from "rxjs" ;
import { createMediaDeviceObserver } from "@livekit/components-core" ;
2025-08-04 16:43:08 +02:00
import { type Logger , logger as rootLogger } from "matrix-js-sdk/lib/logger" ;
2025-06-20 12:37:25 -04:00
import {
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 { getUrlParams } from "../UrlParams" ;
2025-06-24 14:25:05 +02:00
import { platform } from "../Platform" ;
2025-06-25 20:12:54 +02:00
import { switchWhen } from "../utils/observable" ;
2025-06-18 18:33:35 -04:00
import { type Behavior , constant } from "./Behavior" ;
2026-03-12 19:00:09 +01:00
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts" ;
2025-06-20 12:37:25 -04:00
// 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 =
| { type : "name" ; name : string }
2025-06-26 05:08:57 -04:00
| { type : "number" ; number : number };
2025-06-20 12:37:25 -04:00
2025-06-26 05:08:57 -04:00
export type AudioOutputDeviceLabel =
| DeviceLabel
| { type : "speaker" }
| { type : "earpiece" }
| { type : "default" ; name : string | null };
2025-06-20 12:37:25 -04:00
2026-03-12 19:00:09 +01:00
/**
* Base selected-device value shared by all media kinds.
*
* `id` is the effective device identifier used by browser media APIs.
*/
2025-06-20 12:37:25 -04:00
export interface SelectedDevice {
id : string ;
}
2026-03-12 19:00:09 +01:00
/**
* Selected audio input value with audio-input-specific metadata.
*/
2025-06-20 12:37:25 -04:00
export interface SelectedAudioInputDevice extends SelectedDevice {
/**
* Emits whenever we think that this audio input device has logically changed
* to refer to a different hardware device.
*/
hardwareDeviceChange$ : Observable < void >;
}
2026-03-12 19:00:09 +01:00
/**
* Selected audio output value with output-routing-specific metadata.
*/
2025-06-20 12:37:25 -04:00
export interface SelectedAudioOutputDevice extends SelectedDevice {
/**
* Whether this device is a "virtual earpiece" device. If so, we should output
* on a single channel of the device at a reduced volume.
*/
virtualEarpiece : boolean ;
}
2026-03-12 19:00:09 +01:00
/**
* Common reactive contract for selectable input/output media devices (mic, speaker, camera).
*
* `Label` is the type used to represent a device in UI lists.
* `Selected` is the type used to represent the active selection for a device kind.
*/
2025-06-20 12:37:25 -04:00
export interface MediaDevice < Label , Selected > {
/**
2026-03-12 19:00:09 +01:00
* Reactive map of currently available devices keyed by device ID.
*
* `Label` defines the UI-facing label data structure for each device type.
2025-06-20 12:37:25 -04:00
*/
2025-06-18 18:33:35 -04:00
available$ : Behavior < Map < string , Label >>;
2026-03-12 19:00:09 +01:00
2025-06-20 12:37:25 -04:00
/**
2026-03-12 19:00:09 +01:00
* The active device selection.
* Can be `undefined` when no device is yet selected.
*
* When defined, `Selected` contains the selected device ID plus any
* type-specific metadata.
2025-06-20 12:37:25 -04:00
*/
2025-06-18 18:33:35 -04:00
selected$ : Behavior < Selected | undefined >;
2026-03-12 19:00:09 +01:00
2025-06-20 12:37:25 -04:00
/**
2026-03-12 19:00:09 +01:00
* Requests selection of a device by ID.
*
* Implementations typically persist this preference and let `selected$`
* converge to the effective device (which may differ if the requested ID is
* unavailable).
2025-06-20 12:37:25 -04:00
*/
select ( id : string ) : void ;
}
/**
* An observable that represents if we should display the devices menu for iOS.
2026-03-12 19:00:09 +01:00
*
2025-06-20 12:37:25 -04:00
* This implies the following
* - hide any input devices (they do not work anyhow on ios)
* - Show a button to show the native output picker instead.
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
2025-06-24 14:25:05 +02:00
export const iosDeviceMenu$ =
2025-06-18 18:33:35 -04:00
platform === "ios" ? constant ( true ) : alwaysShowIphoneEarpieceSetting . value$ ;
2025-06-20 12:37:25 -04:00
function availableRawDevices$ (
kind : MediaDeviceKind ,
2025-06-18 18:33:35 -04:00
usingNames$ : Behavior < boolean >,
2025-06-20 12:37:25 -04:00
scope : ObservableScope ,
2025-08-04 16:43:08 +02:00
logger : Logger ,
2025-06-18 18:33:35 -04:00
) : Behavior < MediaDeviceInfo [] > {
2025-06-25 20:12:54 +02:00
const logError = ( e : Error ) : void =>
logger . error ( "Error creating MediaDeviceObserver" , e );
const devices$ = createMediaDeviceObserver ( kind , logError , false );
const devicesWithNames$ = createMediaDeviceObserver ( kind , logError , true );
2025-07-12 00:20:44 -04:00
return scope . behavior (
usingNames$ . pipe (
2025-06-18 18:33:35 -04:00
switchMap (( withNames ) =>
withNames
? // It might be that there is already a media stream running somewhere,
// and so we can do without requesting a second one. Only switch to the
// device observer that explicitly requests the names if we see that
// names are in fact missing from the initial device enumeration.
devices$ . pipe (
switchWhen (
( devices , i ) => i === 0 && devices . every (( d ) => ! d . label ),
devicesWithNames$ ,
),
)
: devices$ ,
),
2025-07-12 00:20:44 -04:00
),
2025-07-12 00:28:24 -04:00
[],
2025-07-12 00:20:44 -04:00
);
2025-06-20 12:37:25 -04:00
}
function buildDeviceMap (
availableRaw : MediaDeviceInfo [],
) : Map < string , DeviceLabel > {
return new Map < string , DeviceLabel >(
availableRaw . map (( d , i ) => [
d . deviceId ,
d . label
? { type : "name" , name : d.label }
: { type : "number" , number : i + 1 },
]),
);
}
function selectDevice$ < Label >(
available$ : Observable < Map < string , Label >>,
preferredId$ : Observable < string | undefined >,
2026-03-12 19:00:09 +01:00
defaultPicker : ( available : Map < string , Label >) => string | undefined = (
available ,
) => available . keys (). next (). value ,
2025-06-20 12:37:25 -04:00
) : Observable < string | undefined > {
return combineLatest ([ available$ , preferredId$ ], ( available , preferredId ) => {
if ( available . size ) {
2026-03-12 19:00:09 +01:00
if ( preferredId !== undefined && available . has ( preferredId )) {
// If the preferred device is available, use it.
return preferredId ;
} else if ( available . size === 1 && available . has ( "" )) {
// In some cases the enumerateDevices will list the devices with empty string details:
// `{deviceId:'', kind:'audiooutput|audioinput|videoinput', label:'', groupId:''}`
// This can happen when:
// 1. The user has not yet granted permissions to microphone/devices
// 2. The page is not running in a secure context (e.g. localhost or https)
// 3. In embedded WebViews, restrictions are often tighter, need active capture..
// 3. The browser is blocking access to device details for privacy reasons (?)
// This is most likely transitional, so keep the current device selected until we get a more accurate enumerateDevices.
return preferredId ;
} else {
// No preferred, so pick a default.
return defaultPicker ( available );
}
2025-06-20 12:37:25 -04:00
}
return undefined ;
});
}
class AudioInput implements MediaDevice < DeviceLabel , SelectedAudioInputDevice > {
2025-08-04 16:43:08 +02:00
private logger = rootLogger . getChild ( "[MediaDevices AudioInput]" );
2025-06-18 18:33:35 -04:00
private readonly availableRaw$ : Behavior < MediaDeviceInfo [] > =
2025-08-04 16:43:08 +02:00
availableRawDevices$ (
"audioinput" ,
this . usingNames$ ,
this . scope ,
this . logger ,
);
2025-06-20 12:37:25 -04:00
2025-07-12 00:20:44 -04:00
public readonly available$ = this . scope . behavior (
this . availableRaw$ . pipe ( map ( buildDeviceMap )),
);
2025-06-20 12:37:25 -04:00
2025-07-12 00:20:44 -04:00
public readonly selected$ = this . scope . behavior (
selectDevice$ ( this . available$ , audioInputSetting . value$ ). pipe (
2025-06-18 18:33:35 -04:00
map (( id ) =>
id === undefined
? undefined
: {
id ,
// We can identify when the hardware device has changed by watching for
// changes in the group ID
hardwareDeviceChange$ : this.availableRaw$.pipe (
map (
( devices ) => devices . find (( d ) => d . deviceId === id ) ? . groupId ,
),
pairwise (),
filter (([ before , after ]) => before !== after ),
map (() => undefined ),
),
},
),
2025-07-12 00:20:44 -04:00
),
);
2025-06-20 12:37:25 -04:00
public select ( id : string ) : void {
audioInputSetting . setValue ( id );
}
public constructor (
2025-06-18 18:33:35 -04:00
private readonly usingNames$ : Behavior < boolean >,
2025-06-20 12:37:25 -04:00
private readonly scope : ObservableScope ,
2025-06-20 18:32:51 +02:00
) {
this . available$ . subscribe (( available ) => {
2025-08-04 16:43:08 +02:00
this . logger . info ( "[audio-input] available devices:" , available );
2025-06-20 18:32:51 +02:00
});
}
2025-06-20 12:37:25 -04:00
}
2026-03-18 17:54:05 +01:00
export class AudioOutput implements MediaDevice <
2026-01-05 19:35:09 +01:00
AudioOutputDeviceLabel ,
SelectedAudioOutputDevice
> {
2025-08-04 16:43:08 +02:00
private logger = rootLogger . getChild ( "[MediaDevices AudioOutput]" );
2025-07-12 00:20:44 -04:00
public readonly available$ = this . scope . behavior (
2025-08-04 16:43:08 +02:00
availableRawDevices$ (
"audiooutput" ,
this . usingNames$ ,
this . scope ,
this . logger ,
). pipe (
2025-06-18 18:33:35 -04:00
map (( availableRaw ) => {
2025-08-04 17:46:56 +02:00
let available : Map < string , AudioOutputDeviceLabel > =
2025-06-18 18:33:35 -04:00
buildDeviceMap ( availableRaw );
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
if ( available . size && ! available . has ( "" ) && ! available . has ( "default" ))
available . set ( "" , {
type : "default" ,
name : availableRaw [ 0 ] ? . label || null ,
});
2025-08-04 17:46:56 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSafari = !! ( window as any ). GestureEvent ; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
if ( isSafari ) {
// set to empty map if we are on Safari, because it does not support setSinkId
available = new Map ();
}
2025-06-18 18:33:35 -04:00
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available ;
}),
2025-07-12 00:20:44 -04:00
),
);
public readonly selected$ = this . scope . behavior (
selectDevice$ ( this . available$ , audioOutputSetting . value$ ). pipe (
2026-03-12 19:00:09 +01:00
map (( id ) => {
if ( id === undefined ) {
return undefined ;
} else {
return {
id ,
virtualEarpiece : false ,
};
}
}),
2025-07-12 00:20:44 -04:00
),
);
2025-06-20 12:37:25 -04:00
public select ( id : string ) : void {
audioOutputSetting . setValue ( id );
}
public constructor (
2025-06-18 18:33:35 -04:00
private readonly usingNames$ : Behavior < boolean >,
2025-06-20 12:37:25 -04:00
private readonly scope : ObservableScope ,
2025-06-20 18:32:51 +02:00
) {
this . available$ . subscribe (( available ) => {
2025-08-04 16:43:08 +02:00
this . logger . info ( "[audio-output] available devices:" , available );
2025-06-20 18:32:51 +02:00
});
}
2025-06-20 12:37:25 -04:00
}
2026-03-12 19:00:09 +01:00
/**
* 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.
*/
2026-01-05 19:35:09 +01:00
class ControlledAudioOutput implements MediaDevice <
AudioOutputDeviceLabel ,
SelectedAudioOutputDevice
> {
2025-08-04 16:43:08 +02:00
private logger = rootLogger . getChild ( "[MediaDevices ControlledAudioOutput]" );
2025-07-14 12:53:09 +02:00
// 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 ,
2025-08-04 16:43:08 +02:00
this . logger ,
2025-07-14 12:53:09 +02:00
);
2025-07-12 00:20:44 -04:00
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 ];
},
),
);
2025-06-20 12:37:25 -04:00
2025-07-12 00:20:44 -04:00
// Create a virtual earpiece device in case a non-earpiece device is
// designated for this purpose
2026-03-12 19:00:09 +01:00
if ( iosDeviceMenu && availableRaw . some (( d ) => d . forEarpiece )) {
this . logger . info (
`IOS Add virtual earpiece device with id ${ EARPIECE_CONFIG_ID } ` ,
);
2025-07-12 00:20:44 -04:00
available . set ( EARPIECE_CONFIG_ID , { type : "earpiece" });
2026-03-12 19:00:09 +01:00
}
2025-06-20 12:37:25 -04:00
2025-07-12 00:20:44 -04:00
return available ;
},
),
);
2025-06-20 12:37:25 -04:00
private readonly deviceSelection$ = new Subject < string >();
public select ( id : string ) : void {
2026-03-12 19:00:09 +01:00
this . logger . info ( `select device: ${ id } ` );
2025-06-20 12:37:25 -04:00
this . deviceSelection$ . next ( id );
}
2025-07-12 00:20:44 -04:00
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 };
},
2026-03-12 19:00:09 +01:00
). pipe (
tap (( selected ) => {
this . logger . debug ( `selected device: ${ selected ? . id } ` );
}),
2025-07-12 00:20:44 -04:00
),
);
2025-06-20 12:37:25 -04:00
2025-07-14 12:53:09 +02:00
public constructor (
2025-07-14 19:03:18 +02:00
private readonly usingNames$ : Behavior < boolean >,
2025-07-14 12:53:09 +02:00
private readonly scope : ObservableScope ,
) {
2025-06-20 12:37:25 -04:00
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 ) {
2026-03-12 19:00:09 +01:00
this . logger . info ( "onAudioDeviceSelect called:" , device );
2025-06-20 12:37:25 -04:00
window . controls . onAudioDeviceSelect ? .( device . id );
// Also invoke the deprecated callback for backward compatibility
window . controls . onOutputDeviceSelect ? .( device . id );
}
});
2025-06-20 18:32:51 +02:00
this . available$ . subscribe (( available ) => {
2026-03-12 19:00:09 +01:00
this . logger . debug ( "available devices:" , available );
2025-06-20 18:32:51 +02:00
});
2025-07-14 12:53:09 +02:00
this . availableRaw$ . subscribe (( availableRaw ) => {
2026-03-12 19:00:09 +01:00
this . logger . debug ( "available raw devices:" , availableRaw );
2025-07-14 12:53:09 +02:00
});
2025-06-20 12:37:25 -04:00
}
}
class VideoInput implements MediaDevice < DeviceLabel , SelectedDevice > {
2025-08-04 16:43:08 +02:00
private logger = rootLogger . getChild ( "[MediaDevices VideoInput]" );
2025-07-12 00:20:44 -04:00
public readonly available$ = this . scope . behavior (
2025-08-04 16:43:08 +02:00
availableRawDevices$ (
"videoinput" ,
this . usingNames$ ,
this . scope ,
this . logger ,
). pipe ( map ( buildDeviceMap )),
2025-07-12 00:20:44 -04:00
);
public readonly selected$ = this . scope . behavior (
selectDevice$ ( this . available$ , videoInputSetting . value$ ). pipe (
map (( id ) => ( id === undefined ? undefined : { id })),
),
);
2025-06-20 12:37:25 -04:00
public select ( id : string ) : void {
videoInputSetting . setValue ( id );
}
public constructor (
2025-06-18 18:33:35 -04:00
private readonly usingNames$ : Behavior < boolean >,
2025-06-20 12:37:25 -04:00
private readonly scope : ObservableScope ,
2025-06-20 18:32:51 +02:00
) {
// This also has the purpose of subscribing to the available devices
this . available$ . subscribe (( available ) => {
2025-08-04 16:43:08 +02:00
this . logger . info ( "[video-input] available devices:" , available );
2025-06-20 18:32:51 +02:00
});
}
2025-06-20 12:37:25 -04:00
}
export class MediaDevices {
2025-06-25 20:12:54 +02:00
private readonly deviceNamesRequest$ = new Subject < void >();
2025-06-20 12:37:25 -04:00
/**
* Requests that the media devices be populated with the names of each
* available device, rather than numbered identifiers. This may invoke a
* permissions pop-up, so it should only be called when there is a clear user
* intent to view the device list.
*/
public requestDeviceNames () : void {
2025-06-25 20:12:54 +02:00
this . deviceNamesRequest$ . next ();
2025-06-20 12:37:25 -04:00
}
2025-06-25 20:12:54 +02:00
// Start using device names as soon as requested. This will cause LiveKit to
// briefly request device permissions and acquire media streams for each
// device type while calling `enumerateDevices`, which is what browsers want
// you to do to receive device names in lieu of a more explicit permissions
// API. This flag never resets to false, because once permissions are granted
// the first time, the user won't be prompted again until reload of the page.
2025-07-12 00:20:44 -04:00
private readonly usingNames$ = this . scope . behavior (
2025-07-12 00:28:24 -04:00
this . deviceNamesRequest$ . pipe ( map (() => true )),
false ,
2025-07-12 00:20:44 -04:00
);
2025-06-20 12:37:25 -04:00
public readonly audioInput : MediaDevice <
DeviceLabel ,
SelectedAudioInputDevice
2025-06-25 20:12:54 +02:00
> = new AudioInput ( this . usingNames$ , this . scope );
2025-06-20 12:37:25 -04:00
public readonly audioOutput : MediaDevice <
AudioOutputDeviceLabel ,
SelectedAudioOutputDevice
> = getUrlParams (). controlledAudioDevices
2026-03-12 19:00:09 +01:00
? platform == "android"
? new AndroidControlledAudioOutput (
controlledAvailableOutputDevices$ ,
this . scope ,
getUrlParams (). callIntent ,
window . controls ,
)
: new ControlledAudioOutput ( this . usingNames$ , this . scope )
2025-06-25 20:12:54 +02:00
: new AudioOutput ( this . usingNames$ , this . scope );
2025-06-20 12:37:25 -04:00
public readonly videoInput : MediaDevice < DeviceLabel , SelectedDevice > =
2025-06-25 20:12:54 +02:00
new VideoInput ( this . usingNames$ , this . scope );
2025-06-20 12:37:25 -04:00
2025-06-25 20:12:54 +02:00
public constructor ( private readonly scope : ObservableScope ) {}
2025-06-20 12:37:25 -04:00
}