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 ,
} 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" ;
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
export interface SelectedDevice {
id : string ;
}
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 > ;
}
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 ;
}
export interface MediaDevice < Label , Selected > {
/ * *
* A map from available device IDs to labels .
* /
2025-06-18 18:33:35 -04:00
available$ : Behavior < Map < string , Label > > ;
2025-06-20 12:37:25 -04:00
/ * *
* The selected device .
* /
2025-06-18 18:33:35 -04:00
selected$ : Behavior < Selected | undefined > ;
2025-06-20 12:37:25 -04:00
/ * *
* Selects a new device .
* /
select ( id : string ) : void ;
}
/ * *
* An observable that represents if we should display the devices menu for iOS .
* 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 > ,
) : Observable < string | undefined > {
return combineLatest ( [ available $ , preferredId $ ] , ( available , preferredId ) = > {
if ( available . size ) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
return ( preferredId !== undefined && available . has ( preferredId ) ) ||
( available . size === 1 && available . has ( "" ) )
? preferredId
: available . keys ( ) . next ( ) . value ;
}
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
}
class AudioOutput
implements MediaDevice < 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 (
2025-06-18 18:33:35 -04:00
map ( ( id ) = >
id === undefined
? undefined
: {
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
}
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
if ( iosDeviceMenu && availableRaw . some ( ( d ) = > d . forEarpiece ) )
available . set ( EARPIECE_CONFIG_ID , { type : "earpiece" } ) ;
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 {
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 } ;
} ,
) ,
) ;
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 ) {
2025-08-04 16:43:08 +02:00
this . logger . info (
"[controlled-output] 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 ) = > {
2025-08-04 16:43:08 +02:00
this . logger . info ( "[controlled-output] available devices:" , available ) ;
2025-06-20 18:32:51 +02:00
} ) ;
2025-07-14 12:53:09 +02:00
this . availableRaw $ . subscribe ( ( availableRaw ) = > {
2025-08-04 16:43:08 +02:00
this . logger . info (
"[controlled-output] 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
2025-07-14 12:53:09 +02:00
? 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
}