Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve default device handling #1357

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
6 changes: 2 additions & 4 deletions examples/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const state = {
isFrontFacing: false,
encoder: new TextEncoder(),
decoder: new TextDecoder(),
defaultDevices: new Map<MediaDeviceKind, string>(),
defaultDevices: new Map<MediaDeviceKind, string>([['audioinput', 'default']]),
bitrateInterval: undefined as any,
e2eeKeyProvider: new ExternalE2EEKeyProvider(),
};
Expand Down Expand Up @@ -439,8 +439,6 @@ const appActions = {
return;
}

state.defaultDevices.set(kind, deviceId);

if (currentRoom) {
await currentRoom.switchActiveDevice(kind, deviceId);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = <HTMLSelectElement>$(
Object.entries(elementMapping)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/e2ee/worker/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
9 changes: 8 additions & 1 deletion src/room/DeviceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export default class DeviceManager {

static userMediaPromiseMap: Map<MediaDeviceKind, Promise<MediaStream>> = new Map();

private _previousDevices: MediaDeviceInfo[] = [];

get previousDevices() {
return this._previousDevices;
}

async getDevices(
kind?: MediaDeviceKind,
requestPermissions: boolean = true,
Expand Down Expand Up @@ -60,10 +66,11 @@ export default class DeviceManager {
});
}
}
this._previousDevices = devices;

if (kind) {
devices = devices.filter((device) => device.kind === kind);
}

return devices;
}

Expand Down
81 changes: 72 additions & 9 deletions src/room/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
isBrowserSupported,
isCloud,
isReactNative,
isSafari,
isWeb,
supportsSetSinkId,
toHttpUrl,
Expand Down Expand Up @@ -234,6 +235,21 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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();
});
}
}
}

/**
Expand Down Expand Up @@ -434,6 +450,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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
Expand Down Expand Up @@ -769,7 +792,6 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
}
if (isWeb()) {
document.addEventListener('freeze', this.onPageLeave);
navigator.mediaDevices?.addEventListener('devicechange', this.handleDeviceChange);
}
this.setAndEmitConnectionState(ConnectionState.Connected);
this.emit(RoomEvent.Connected);
Expand Down Expand Up @@ -1097,14 +1119,14 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
* @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,
);
Expand All @@ -1117,10 +1139,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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,
);
Expand All @@ -1147,7 +1169,6 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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) {
Expand All @@ -1164,7 +1185,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
throw e;
}
}
if (deviceHasChanged && success) {
if (needsUpdateWithoutTracks) {
this.localParticipant.activeDeviceMap.set(kind, deviceId);
this.emit(RoomEvent.ActiveDeviceChanged, kind, deviceId);
}
Expand Down Expand Up @@ -1654,13 +1675,55 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
};

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))
Expand Down Expand Up @@ -2013,7 +2076,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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 &&
Expand Down
2 changes: 2 additions & 0 deletions src/room/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ export const publishDefaults: TrackPublishDefaults = {
} as const;

export const audioDefaults: AudioCaptureOptions = {
deviceId: 'default',
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
voiceIsolation: true,
};

export const videoDefaults: VideoCaptureOptions = {
deviceId: 'default',
resolution: VideoPresets.h720.resolution,
};

Expand Down
6 changes: 5 additions & 1 deletion src/room/participant/LocalParticipant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
16 changes: 0 additions & 16 deletions src/room/track/LocalAudioTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,6 @@ export default class LocalAudioTrack extends LocalTrack<Track.Kind.Audio> {
this.checkForSilence();
}

async setDeviceId(deviceId: ConstrainDOMString): Promise<boolean> {
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<typeof this> {
const unlock = await this.muteLock.lock();
try {
Expand Down
24 changes: 23 additions & 1 deletion src/room/track/LocalTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -221,6 +221,28 @@ export default abstract class LocalTrack<
throw new TrackInvalidError('unable to get track dimensions after timeout');
}

async setDeviceId(deviceId: ConstrainDOMString): Promise<boolean> {
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<void>;

/**
* @returns DeviceID of the device that is currently being used for this track
*/
Expand Down
20 changes: 1 addition & 19 deletions src/room/track/LocalVideoTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -241,24 +241,6 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
this.setPublishingLayers(qualities);
}

async setDeviceId(deviceId: ConstrainDOMString): Promise<boolean> {
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) {
Expand Down
4 changes: 2 additions & 2 deletions src/room/track/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export async function createLocalTracks(
): Promise<Array<LocalTrack>> {
// 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);
Expand Down
Loading
Loading