diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 817140cb05..26598fb51a 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -43,7 +43,7 @@ const state = { isFrontFacing: false, encoder: new TextEncoder(), decoder: new TextDecoder(), - defaultDevices: new Map(), + defaultDevices: new Map([['audioinput', 'default']]), bitrateInterval: undefined as any, e2eeKeyProvider: new ExternalE2EEKeyProvider(), }; @@ -439,8 +439,6 @@ const appActions = { return; } - state.defaultDevices.set(kind, deviceId); - if (currentRoom) { await currentRoom.switchActiveDevice(kind, deviceId); } @@ -501,7 +499,6 @@ function handleChatMessage(msg: ChatMessage, participant?: LocalParticipant | Re function participantConnected(participant: Participant) { appendLog('participant', participant.identity, 'connected', participant.metadata); - console.log('tracks', participant.trackPublications); participant .on(ParticipantEvent.TrackMuted, (pub: TrackPublication) => { appendLog('track was muted', pub.trackSid, participant.identity); @@ -886,6 +883,7 @@ async function handleDevicesChanged() { } async function handleActiveDeviceChanged(kind: MediaDeviceKind, deviceId: string) { + state.defaultDevices.set(kind, deviceId); const devices = await Room.getLocalDevices(kind); const element = $( Object.entries(elementMapping) diff --git a/package.json b/package.json index a68c4f5a12..94140a97b7 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "build-docs": "typedoc && mkdir -p docs/assets/github && cp .github/*.png docs/assets/github/ && find docs -name '*.html' -type f -exec sed -i.bak 's|=\"/.github/|=\"assets/github/|g' {} + && find docs -name '*.bak' -delete", "proto": "protoc --es_out src/proto --es_opt target=ts -I./protocol ./protocol/livekit_rtc.proto ./protocol/livekit_models.proto", "examples:demo": "vite examples/demo -c vite.config.mjs", + "dev": "pnpm examples:demo", "lint": "eslint src", "test": "vitest run src", "deploy": "gh-pages -d examples/demo/dist", diff --git a/src/e2ee/worker/tsconfig.json b/src/e2ee/worker/tsconfig.json index 9d6482e450..f153669776 100644 --- a/src/e2ee/worker/tsconfig.json +++ b/src/e2ee/worker/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../../tsconfig.json", "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2017", "ES2018.Promise", "WebWorker"] + "lib": ["DOM", "DOM.Iterable", "ES2017", "ES2018.Promise", "WebWorker", "ES2021.WeakRef"] } } diff --git a/src/room/DeviceManager.ts b/src/room/DeviceManager.ts index f9abd58939..31123bae73 100644 --- a/src/room/DeviceManager.ts +++ b/src/room/DeviceManager.ts @@ -17,6 +17,12 @@ export default class DeviceManager { static userMediaPromiseMap: Map> = new Map(); + private _previousDevices: MediaDeviceInfo[] = []; + + get previousDevices() { + return this._previousDevices; + } + async getDevices( kind?: MediaDeviceKind, requestPermissions: boolean = true, @@ -60,10 +66,11 @@ export default class DeviceManager { }); } } + this._previousDevices = devices; + if (kind) { devices = devices.filter((device) => device.kind === kind); } - return devices; } diff --git a/src/room/Room.ts b/src/room/Room.ts index 4446fda35c..5b7f81c2f2 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -86,6 +86,7 @@ import { isBrowserSupported, isCloud, isReactNative, + isSafari, isWeb, supportsSetSinkId, toHttpUrl, @@ -234,6 +235,21 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (this.options.e2ee) { this.setupE2EE(); } + + if (isWeb()) { + const abortController = new AbortController(); + + // in order to catch device changes prior to room connection we need to register the event in the constructor + navigator.mediaDevices?.addEventListener('devicechange', this.handleDeviceChange, { + signal: abortController.signal, + }); + + if (Room.cleanupRegistry) { + Room.cleanupRegistry.register(this, () => { + abortController.abort(); + }); + } + } } /** @@ -434,6 +450,13 @@ class Room extends (EventEmitter as new () => TypedEmitter) return DeviceManager.getInstance().getDevices(kind, requestPermissions); } + static cleanupRegistry = + typeof FinalizationRegistry !== 'undefined' && + new FinalizationRegistry((cleanup: () => void) => { + cleanup(); + console.info('cleaning up room'); + }); + /** * prepareConnection should be called as soon as the page is loaded, in order * to speed up the connection attempt. This function will @@ -769,7 +792,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) } if (isWeb()) { document.addEventListener('freeze', this.onPageLeave); - navigator.mediaDevices?.addEventListener('devicechange', this.handleDeviceChange); } this.setAndEmitConnectionState(ConnectionState.Connected); this.emit(RoomEvent.Connected); @@ -1097,14 +1119,14 @@ class Room extends (EventEmitter as new () => TypedEmitter) * @param deviceId */ async switchActiveDevice(kind: MediaDeviceKind, deviceId: string, exact: boolean = false) { - let deviceHasChanged = false; let success = true; + let needsUpdateWithoutTracks = false; const deviceConstraint = exact ? { exact: deviceId } : deviceId; if (kind === 'audioinput') { + needsUpdateWithoutTracks = this.localParticipant.audioTrackPublications.size === 0; const prevDeviceId = this.getActiveDevice(kind) ?? this.options.audioCaptureDefaults!.deviceId; this.options.audioCaptureDefaults!.deviceId = deviceConstraint; - deviceHasChanged = prevDeviceId !== deviceConstraint; const tracks = Array.from(this.localParticipant.audioTrackPublications.values()).filter( (track) => track.source === Track.Source.Microphone, ); @@ -1117,10 +1139,10 @@ class Room extends (EventEmitter as new () => TypedEmitter) throw e; } } else if (kind === 'videoinput') { + needsUpdateWithoutTracks = this.localParticipant.videoTrackPublications.size === 0; const prevDeviceId = this.getActiveDevice(kind) ?? this.options.videoCaptureDefaults!.deviceId; this.options.videoCaptureDefaults!.deviceId = deviceConstraint; - deviceHasChanged = prevDeviceId !== deviceConstraint; const tracks = Array.from(this.localParticipant.videoTrackPublications.values()).filter( (track) => track.source === Track.Source.Camera, ); @@ -1147,7 +1169,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.options.audioOutput ??= {}; const prevDeviceId = this.getActiveDevice(kind) ?? this.options.audioOutput.deviceId; this.options.audioOutput.deviceId = deviceId; - deviceHasChanged = prevDeviceId !== deviceConstraint; try { if (this.options.webAudioMix) { @@ -1164,7 +1185,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) throw e; } } - if (deviceHasChanged && success) { + if (needsUpdateWithoutTracks) { this.localParticipant.activeDeviceMap.set(kind, deviceId); this.emit(RoomEvent.ActiveDeviceChanged, kind, deviceId); } @@ -1654,13 +1675,55 @@ class Room extends (EventEmitter as new () => TypedEmitter) }; private handleDeviceChange = async () => { + const previousDevices = DeviceManager.getInstance().previousDevices; // check for available devices, but don't request permissions in order to avoid prompts for kinds that haven't been used before const availableDevices = await DeviceManager.getInstance().getDevices(undefined, false); + + const browser = getBrowser(); + if (browser?.name === 'Chrome' && browser.os !== 'iOS') { + for (let availableDevice of availableDevices) { + const previousDevice = previousDevices.find( + (info) => info.deviceId === availableDevice.deviceId, + ); + if ( + previousDevice && + previousDevice.label !== '' && + previousDevice.kind === availableDevice.kind && + previousDevice.label !== availableDevice.label + ) { + // label has changed on device the same deviceId, indicating that the default device has changed on the OS level + if (this.getActiveDevice(availableDevice.kind) === 'default') { + // emit an active device change event only if the selected output device is actually on `default` + this.emit( + RoomEvent.ActiveDeviceChanged, + availableDevice.kind, + availableDevice.deviceId, + ); + } + } + } + } + // inputs are automatically handled via TrackEvent.Ended causing a TrackEvent.Restarted. Here we only need to worry about audiooutputs changing - const kinds: MediaDeviceKind[] = ['audiooutput']; + const kinds: MediaDeviceKind[] = ['audiooutput', 'audioinput', 'videoinput']; for (let kind of kinds) { - // switch to first available device if previously active device is not available any more const devicesOfKind = availableDevices.filter((d) => d.kind === kind); + const activeDevice = this.getActiveDevice(kind); + + if (activeDevice === previousDevices.filter((info) => info.kind === kind)[0]?.deviceId) { + // in Safari the first device is always the default, so we assume a user on the default device would like to switch to the default once it changes + // FF doesn't emit an event when the default device changes, so we perform the same best effort and switch to the new device once connected and if it's the first in the array + if (devicesOfKind.length > 0 && devicesOfKind[0]?.deviceId !== activeDevice) { + await this.switchActiveDevice(kind, devicesOfKind[0].deviceId); + continue; + } + } + + if ((kind === 'audioinput' && !isSafari()) || kind === 'videoinput') { + // airpods on Safari need special handling for audioinput as the track doesn't end as soon as you take them out + continue; + } + // switch to first available device if previously active device is not available any more if ( devicesOfKind.length > 0 && !devicesOfKind.find((deviceInfo) => deviceInfo.deviceId === this.getActiveDevice(kind)) @@ -2013,7 +2076,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.emit(RoomEvent.LocalAudioSilenceDetected, pub); } } - const deviceId = await pub.track?.getDeviceId(); + const deviceId = await pub.track?.getDeviceId(false); const deviceKind = sourceToKind(pub.source); if ( deviceKind && diff --git a/src/room/defaults.ts b/src/room/defaults.ts index 4d4443bec6..5dc686ae91 100644 --- a/src/room/defaults.ts +++ b/src/room/defaults.ts @@ -22,6 +22,7 @@ export const publishDefaults: TrackPublishDefaults = { } as const; export const audioDefaults: AudioCaptureOptions = { + deviceId: 'default', autoGainControl: true, echoCancellation: true, noiseSuppression: true, @@ -29,6 +30,7 @@ export const audioDefaults: AudioCaptureOptions = { }; export const videoDefaults: VideoCaptureOptions = { + deviceId: 'default', resolution: VideoPresets.h720.resolution, }; diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 2a3ff79268..8ae667ab84 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -154,7 +154,11 @@ export default class LocalParticipant extends Participant { this.engine = engine; this.roomOptions = options; this.setupEngine(engine); - this.activeDeviceMap = new Map(); + this.activeDeviceMap = new Map([ + ['audioinput', 'default'], + ['videoinput', 'default'], + ['audiooutput', 'default'], + ]); this.pendingSignalRequests = new Map(); } diff --git a/src/room/track/LocalAudioTrack.ts b/src/room/track/LocalAudioTrack.ts index 020705f664..db338be5ab 100644 --- a/src/room/track/LocalAudioTrack.ts +++ b/src/room/track/LocalAudioTrack.ts @@ -45,22 +45,6 @@ export default class LocalAudioTrack extends LocalTrack { this.checkForSilence(); } - async setDeviceId(deviceId: ConstrainDOMString): Promise { - if ( - this._constraints.deviceId === deviceId && - this._mediaStreamTrack.getSettings().deviceId === unwrapConstraint(deviceId) - ) { - return true; - } - this._constraints.deviceId = deviceId; - if (!this.isMuted) { - await this.restartTrack(); - } - return ( - this.isMuted || unwrapConstraint(deviceId) === this._mediaStreamTrack.getSettings().deviceId - ); - } - async mute(): Promise { const unlock = await this.muteLock.lock(); try { diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index e0526ca1e1..a736389958 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -5,7 +5,7 @@ import DeviceManager from '../DeviceManager'; import { DeviceUnsupportedError, TrackInvalidError } from '../errors'; import { TrackEvent } from '../events'; import type { LoggerOptions } from '../types'; -import { compareVersions, isMobile, sleep } from '../utils'; +import { compareVersions, isMobile, sleep, unwrapConstraint } from '../utils'; import { Track, attachToElement, detachTrack } from './Track'; import type { VideoCodec } from './options'; import type { TrackProcessor } from './processor/types'; @@ -221,6 +221,28 @@ export default abstract class LocalTrack< throw new TrackInvalidError('unable to get track dimensions after timeout'); } + async setDeviceId(deviceId: ConstrainDOMString): Promise { + if ( + this._constraints.deviceId === deviceId && + this._mediaStreamTrack.getSettings().deviceId === unwrapConstraint(deviceId) + ) { + return true; + } + this._constraints.deviceId = deviceId; + + // when track is muted, underlying media stream track is stopped and + // will be restarted later + if (this.isMuted) { + return true; + } + + await this.restartTrack(); + + return unwrapConstraint(deviceId) === this._mediaStreamTrack.getSettings().deviceId; + } + + abstract restartTrack(constraints?: unknown): Promise; + /** * @returns DeviceID of the device that is currently being used for this track */ diff --git a/src/room/track/LocalVideoTrack.ts b/src/room/track/LocalVideoTrack.ts index 942b5bb3b4..4d41b9d6a4 100644 --- a/src/room/track/LocalVideoTrack.ts +++ b/src/room/track/LocalVideoTrack.ts @@ -11,7 +11,7 @@ import { ScalabilityMode } from '../participant/publishUtils'; import type { VideoSenderStats } from '../stats'; import { computeBitrate, monitorFrequency } from '../stats'; import type { LoggerOptions } from '../types'; -import { isFireFox, isMobile, isWeb, unwrapConstraint } from '../utils'; +import { isFireFox, isMobile, isWeb } from '../utils'; import LocalTrack from './LocalTrack'; import { Track, VideoQuality } from './Track'; import type { VideoCaptureOptions, VideoCodec } from './options'; @@ -241,24 +241,6 @@ export default class LocalVideoTrack extends LocalTrack { this.setPublishingLayers(qualities); } - async setDeviceId(deviceId: ConstrainDOMString): Promise { - if ( - this._constraints.deviceId === deviceId && - this._mediaStreamTrack.getSettings().deviceId === unwrapConstraint(deviceId) - ) { - return true; - } - this._constraints.deviceId = deviceId; - // when video is muted, underlying media stream track is stopped and - // will be restarted later - if (!this.isMuted) { - await this.restartTrack(); - } - return ( - this.isMuted || unwrapConstraint(deviceId) === this._mediaStreamTrack.getSettings().deviceId - ); - } - async restartTrack(options?: VideoCaptureOptions) { let constraints: MediaTrackConstraints | undefined; if (options) { diff --git a/src/room/track/create.ts b/src/room/track/create.ts index 3a57834082..95cff22ace 100644 --- a/src/room/track/create.ts +++ b/src/room/track/create.ts @@ -32,8 +32,8 @@ export async function createLocalTracks( ): Promise> { // set default options to true options ??= {}; - options.audio ??= true; - options.video ??= true; + options.audio ??= { deviceId: 'default' }; + options.video ??= { deviceId: 'default' }; const { audioProcessor, videoProcessor } = extractProcessorsFromOptions(options); const opts = mergeDefaultOptions(options, audioDefaults, videoDefaults); diff --git a/src/room/track/utils.test.ts b/src/room/track/utils.test.ts index 7cafc56923..ad3b8b00b1 100644 --- a/src/room/track/utils.test.ts +++ b/src/room/track/utils.test.ts @@ -1,17 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { AudioCaptureOptions, VideoCaptureOptions, VideoPresets } from './options'; +import { audioDefaults, videoDefaults } from '../defaults'; +import { type AudioCaptureOptions, VideoPresets } from './options'; import { constraintsForOptions, diffAttributes, mergeDefaultOptions } from './utils'; describe('mergeDefaultOptions', () => { - const audioDefaults: AudioCaptureOptions = { - autoGainControl: true, - channelCount: 2, - }; - const videoDefaults: VideoCaptureOptions = { - deviceId: 'video123', - resolution: VideoPresets.h1080.resolution, - }; - it('does not enable undefined options', () => { const opts = mergeDefaultOptions(undefined, audioDefaults, videoDefaults); expect(opts.audio).toEqual(undefined); @@ -69,7 +61,7 @@ describe('constraintsForOptions', () => { const constraints = constraintsForOptions({ audio: true, }); - expect(constraints.audio).toEqual(true); + expect(constraints.audio).toEqual({ deviceId: audioDefaults.deviceId }); expect(constraints.video).toEqual(false); }); @@ -81,7 +73,7 @@ describe('constraintsForOptions', () => { }, }); const audioOpts = constraints.audio as MediaTrackConstraints; - expect(Object.keys(audioOpts)).toEqual(['noiseSuppression', 'echoCancellation']); + expect(Object.keys(audioOpts)).toEqual(['noiseSuppression', 'echoCancellation', 'deviceId']); expect(audioOpts.noiseSuppression).toEqual(true); expect(audioOpts.echoCancellation).toEqual(false); }); diff --git a/src/room/track/utils.ts b/src/room/track/utils.ts index 2035de5eab..ca7ae6324d 100644 --- a/src/room/track/utils.ts +++ b/src/room/track/utils.ts @@ -31,6 +31,7 @@ export function mergeDefaultOptions( clonedOptions.audio as Record, audioDefaults as Record, ); + clonedOptions.audio.deviceId ??= 'default'; if (audioProcessor) { clonedOptions.audio.processor = audioProcessor; } @@ -40,6 +41,7 @@ export function mergeDefaultOptions( clonedOptions.video as Record, videoDefaults as Record, ); + clonedOptions.video.deviceId ??= 'default'; if (videoProcessor) { clonedOptions.video.processor = videoProcessor; } @@ -77,8 +79,9 @@ export function constraintsForOptions(options: CreateLocalTracksOptions): MediaS } }); constraints.video = videoOptions; + constraints.video.deviceId ??= 'default'; } else { - constraints.video = options.video; + constraints.video = options.video ? { deviceId: 'default' } : false; } } else { constraints.video = false; @@ -87,8 +90,9 @@ export function constraintsForOptions(options: CreateLocalTracksOptions): MediaS if (options.audio) { if (typeof options.audio === 'object') { constraints.audio = options.audio; + constraints.audio.deviceId ??= 'default'; } else { - constraints.audio = true; + constraints.audio = { deviceId: 'default' }; } } else { constraints.audio = false; diff --git a/tsconfig.json b/tsconfig.json index 54622bcc39..3e8739afec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "types": ["sdp-transform", "ua-parser-js", "events"], "target": "ES2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "module": "ES2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, - "lib": ["DOM", "DOM.Iterable", "ES2017", "ES2018.Promise"], + "lib": ["DOM", "DOM.Iterable", "ES2017", "ES2018.Promise", "ES2021.WeakRef"], "rootDir": "./", "outDir": "dist", "declaration": true,