UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

1,475 lines (1,346 loc) 77.8 kB
import { ConnectionQualityUpdate, type DataPacket, DataPacket_Kind, DisconnectReason, JoinResponse, LeaveRequest, LeaveRequest_Action, ParticipantInfo, ParticipantInfo_State, ParticipantPermission, Room as RoomModel, ServerInfo, SimulateScenario, SipDTMF, SpeakerInfo, StreamStateUpdate, SubscriptionError, SubscriptionPermissionUpdate, SubscriptionResponse, TrackInfo, TrackSource, TrackType, Transcription as TranscriptionModel, TranscriptionSegment as TranscriptionSegmentModel, UserPacket, protoInt64, } from '@livekit/protocol'; import { EventEmitter } from 'events'; import type TypedEmitter from 'typed-emitter'; import 'webrtc-adapter'; import { EncryptionEvent } from '../e2ee'; import { E2EEManager } from '../e2ee/E2eeManager'; import log, { LoggerNames, getLogger } from '../logger'; import type { InternalRoomConnectOptions, InternalRoomOptions, RoomConnectOptions, RoomOptions, } from '../options'; import { getBrowser } from '../utils/browserParser'; import DeviceManager from './DeviceManager'; import RTCEngine from './RTCEngine'; import { RegionUrlProvider } from './RegionUrlProvider'; import { audioDefaults, publishDefaults, roomConnectOptionDefaults, roomOptionDefaults, videoDefaults, } from './defaults'; import { ConnectionError, ConnectionErrorReason, UnsupportedServer } from './errors'; import { EngineEvent, ParticipantEvent, RoomEvent, TrackEvent } from './events'; import LocalParticipant from './participant/LocalParticipant'; import type Participant from './participant/Participant'; import type { ConnectionQuality } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; import CriticalTimers from './timers'; import LocalAudioTrack from './track/LocalAudioTrack'; import LocalTrackPublication from './track/LocalTrackPublication'; import LocalVideoTrack from './track/LocalVideoTrack'; import type RemoteTrack from './track/RemoteTrack'; import RemoteTrackPublication from './track/RemoteTrackPublication'; import { Track } from './track/Track'; import type { TrackPublication } from './track/TrackPublication'; import type { TrackProcessor } from './track/processor/types'; import type { AdaptiveStreamSettings } from './track/types'; import { getNewAudioContext, sourceToKind } from './track/utils'; import type { SimulationOptions, SimulationScenario, TranscriptionSegment } from './types'; import { Future, Mutex, createDummyVideoStreamTrack, extractTranscriptionSegments, getEmptyAudioStreamTrack, isBrowserSupported, isCloud, isReactNative, isWeb, supportsSetSinkId, toHttpUrl, unpackStreamId, unwrapConstraint, } from './utils'; export enum ConnectionState { Disconnected = 'disconnected', Connecting = 'connecting', Connected = 'connected', Reconnecting = 'reconnecting', SignalReconnecting = 'signalReconnecting', } const connectionReconcileFrequency = 4 * 1000; /** * In LiveKit, a room is the logical grouping for a list of participants. * Participants in a room can publish tracks, and subscribe to others' tracks. * * a Room fires [[RoomEvent | RoomEvents]]. * * @noInheritDoc */ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>) { state: ConnectionState = ConnectionState.Disconnected; /** * map of identity: [[RemoteParticipant]] */ remoteParticipants: Map<string, RemoteParticipant>; /** * list of participants that are actively speaking. when this changes * a [[RoomEvent.ActiveSpeakersChanged]] event is fired */ activeSpeakers: Participant[] = []; /** @internal */ engine!: RTCEngine; /** the current participant */ localParticipant: LocalParticipant; /** options of room */ options: InternalRoomOptions; /** reflects the sender encryption status of the local participant */ isE2EEEnabled: boolean = false; private roomInfo?: RoomModel; private sidToIdentity: Map<string, string>; /** connect options of room */ private connOptions?: InternalRoomConnectOptions; private audioEnabled = true; private audioContext?: AudioContext; /** used for aborting pending connections to a LiveKit server */ private abortController?: AbortController; /** future holding client initiated connection attempt */ private connectFuture?: Future<void>; private disconnectLock: Mutex; private e2eeManager: E2EEManager | undefined; private connectionReconcileInterval?: ReturnType<typeof setInterval>; private regionUrlProvider?: RegionUrlProvider; private regionUrl?: string; private isVideoPlaybackBlocked: boolean = false; private log = log; private bufferedEvents: Array<any> = []; private isResuming: boolean = false; /** * map to store first point in time when a particular transcription segment was received */ private transcriptionReceivedTimes: Map<string, number>; /** * Creates a new Room, the primary construct for a LiveKit session. * @param options */ constructor(options?: RoomOptions) { super(); this.setMaxListeners(100); this.remoteParticipants = new Map(); this.sidToIdentity = new Map(); this.options = { ...roomOptionDefaults, ...options }; this.log = getLogger(this.options.loggerName ?? LoggerNames.Room); this.transcriptionReceivedTimes = new Map(); this.options.audioCaptureDefaults = { ...audioDefaults, ...options?.audioCaptureDefaults, }; this.options.videoCaptureDefaults = { ...videoDefaults, ...options?.videoCaptureDefaults, }; this.options.publishDefaults = { ...publishDefaults, ...options?.publishDefaults, }; this.maybeCreateEngine(); this.disconnectLock = new Mutex(); this.localParticipant = new LocalParticipant('', '', this.engine, this.options); if (this.options.videoCaptureDefaults.deviceId) { this.localParticipant.activeDeviceMap.set( 'videoinput', unwrapConstraint(this.options.videoCaptureDefaults.deviceId), ); } if (this.options.audioCaptureDefaults.deviceId) { this.localParticipant.activeDeviceMap.set( 'audioinput', unwrapConstraint(this.options.audioCaptureDefaults.deviceId), ); } if (this.options.audioOutput?.deviceId) { this.switchActiveDevice( 'audiooutput', unwrapConstraint(this.options.audioOutput.deviceId), ).catch((e) => this.log.warn(`Could not set audio output: ${e.message}`, this.logContext)); } if (this.options.e2ee) { this.setupE2EE(); } } /** * @experimental */ async setE2EEEnabled(enabled: boolean) { if (this.e2eeManager) { await Promise.all([this.localParticipant.setE2EEEnabled(enabled)]); if (this.localParticipant.identity !== '') { this.e2eeManager.setParticipantCryptorEnabled(enabled, this.localParticipant.identity); } } else { throw Error('e2ee not configured, please set e2ee settings within the room options'); } } private setupE2EE() { if (this.options.e2ee) { this.e2eeManager = new E2EEManager(this.options.e2ee); this.e2eeManager.on( EncryptionEvent.ParticipantEncryptionStatusChanged, (enabled, participant) => { if (participant instanceof LocalParticipant) { this.isE2EEEnabled = enabled; } this.emit(RoomEvent.ParticipantEncryptionStatusChanged, enabled, participant); }, ); this.e2eeManager.on(EncryptionEvent.EncryptionError, (error) => this.emit(RoomEvent.EncryptionError, error), ); this.e2eeManager?.setup(this); } } private get logContext() { return { room: this.name, roomID: this.roomInfo?.sid, participant: this.localParticipant.identity, pID: this.localParticipant.sid, }; } /** * if the current room has a participant with `recorder: true` in its JWT grant **/ get isRecording(): boolean { return this.roomInfo?.activeRecording ?? false; } /** * server assigned unique room id. * returns once a sid has been issued by the server. */ async getSid(): Promise<string> { if (this.state === ConnectionState.Disconnected) { return ''; } if (this.roomInfo && this.roomInfo.sid !== '') { return this.roomInfo.sid; } return new Promise((resolve, reject) => { const handleRoomUpdate = (roomInfo: RoomModel) => { if (roomInfo.sid !== '') { this.engine.off(EngineEvent.RoomUpdate, handleRoomUpdate); resolve(roomInfo.sid); } }; this.engine.on(EngineEvent.RoomUpdate, handleRoomUpdate); this.once(RoomEvent.Disconnected, () => { this.engine.off(EngineEvent.RoomUpdate, handleRoomUpdate); reject('Room disconnected before room server id was available'); }); }); } /** user assigned name, derived from JWT token */ get name(): string { return this.roomInfo?.name ?? ''; } /** room metadata */ get metadata(): string | undefined { return this.roomInfo?.metadata; } get numParticipants(): number { return this.roomInfo?.numParticipants ?? 0; } get numPublishers(): number { return this.roomInfo?.numPublishers ?? 0; } private maybeCreateEngine() { if (this.engine && !this.engine.isClosed) { return; } this.engine = new RTCEngine(this.options); this.engine .on(EngineEvent.ParticipantUpdate, this.handleParticipantUpdates) .on(EngineEvent.RoomUpdate, this.handleRoomUpdate) .on(EngineEvent.SpeakersChanged, this.handleSpeakersChanged) .on(EngineEvent.StreamStateChanged, this.handleStreamStateUpdate) .on(EngineEvent.ConnectionQualityUpdate, this.handleConnectionQualityUpdate) .on(EngineEvent.SubscriptionError, this.handleSubscriptionError) .on(EngineEvent.SubscriptionPermissionUpdate, this.handleSubscriptionPermissionUpdate) .on( EngineEvent.MediaTrackAdded, (mediaTrack: MediaStreamTrack, stream: MediaStream, receiver: RTCRtpReceiver) => { this.onTrackAdded(mediaTrack, stream, receiver); }, ) .on(EngineEvent.Disconnected, (reason?: DisconnectReason) => { this.handleDisconnect(this.options.stopLocalTrackOnUnpublish, reason); }) .on(EngineEvent.ActiveSpeakersUpdate, this.handleActiveSpeakersUpdate) .on(EngineEvent.DataPacketReceived, this.handleDataPacket) .on(EngineEvent.Resuming, () => { this.clearConnectionReconcile(); this.isResuming = true; this.log.info('Resuming signal connection', this.logContext); if (this.setAndEmitConnectionState(ConnectionState.SignalReconnecting)) { this.emit(RoomEvent.SignalReconnecting); } }) .on(EngineEvent.Resumed, () => { this.registerConnectionReconcile(); this.isResuming = false; this.log.info('Resumed signal connection', this.logContext); this.updateSubscriptions(); this.emitBufferedEvents(); if (this.setAndEmitConnectionState(ConnectionState.Connected)) { this.emit(RoomEvent.Reconnected); } }) .on(EngineEvent.SignalResumed, () => { this.bufferedEvents = []; if (this.state === ConnectionState.Reconnecting || this.isResuming) { this.sendSyncState(); } }) .on(EngineEvent.Restarting, this.handleRestarting) .on(EngineEvent.SignalRestarted, this.handleSignalRestarted) .on(EngineEvent.Offline, () => { if (this.setAndEmitConnectionState(ConnectionState.Reconnecting)) { this.emit(RoomEvent.Reconnecting); } }) .on(EngineEvent.DCBufferStatusChanged, (status, kind) => { this.emit(RoomEvent.DCBufferStatusChanged, status, kind); }) .on(EngineEvent.LocalTrackSubscribed, (subscribedSid) => { const trackPublication = this.localParticipant .getTrackPublications() .find(({ trackSid }) => trackSid === subscribedSid) as LocalTrackPublication | undefined; if (!trackPublication) { this.log.warn( 'could not find local track subscription for subscribed event', this.logContext, ); return; } this.localParticipant.emit(ParticipantEvent.LocalTrackSubscribed, trackPublication); this.emitWhenConnected( RoomEvent.LocalTrackSubscribed, trackPublication, this.localParticipant, ); }); if (this.localParticipant) { this.localParticipant.setupEngine(this.engine); } if (this.e2eeManager) { this.e2eeManager.setupEngine(this.engine); } } /** * getLocalDevices abstracts navigator.mediaDevices.enumerateDevices. * In particular, it handles Chrome's unique behavior of creating `default` * devices. When encountered, it'll be removed from the list of devices. * The actual default device will be placed at top. * @param kind * @returns a list of available local devices */ static getLocalDevices( kind?: MediaDeviceKind, requestPermissions: boolean = true, ): Promise<MediaDeviceInfo[]> { return DeviceManager.getInstance().getDevices(kind, requestPermissions); } /** * prepareConnection should be called as soon as the page is loaded, in order * to speed up the connection attempt. This function will * - perform DNS resolution and pre-warm the DNS cache * - establish TLS connection and cache TLS keys * * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ async prepareConnection(url: string, token?: string) { if (this.state !== ConnectionState.Disconnected) { return; } this.log.debug(`prepareConnection to ${url}`, this.logContext); try { if (isCloud(new URL(url)) && token) { this.regionUrlProvider = new RegionUrlProvider(url, token); const regionUrl = await this.regionUrlProvider.getNextBestRegionUrl(); // we will not replace the regionUrl if an attempt had already started // to avoid overriding regionUrl after a new connection attempt had started if (regionUrl && this.state === ConnectionState.Disconnected) { this.regionUrl = regionUrl; await fetch(toHttpUrl(regionUrl), { method: 'HEAD' }); this.log.debug(`prepared connection to ${regionUrl}`, this.logContext); } } else { await fetch(toHttpUrl(url), { method: 'HEAD' }); } } catch (e) { this.log.warn('could not prepare connection', { ...this.logContext, error: e }); } } connect = async (url: string, token: string, opts?: RoomConnectOptions): Promise<void> => { if (!isBrowserSupported()) { if (isReactNative()) { throw Error("WebRTC isn't detected, have you called registerGlobals?"); } else { throw Error( "LiveKit doesn't seem to be supported on this browser. Try to update your browser and make sure no browser extensions are disabling webRTC.", ); } } // In case a disconnect called happened right before the connect call, make sure the disconnect is completed first by awaiting its lock const unlockDisconnect = await this.disconnectLock.lock(); if (this.state === ConnectionState.Connected) { // when the state is reconnecting or connected, this function returns immediately this.log.info(`already connected to room ${this.name}`, this.logContext); unlockDisconnect(); return Promise.resolve(); } if (this.connectFuture) { unlockDisconnect(); return this.connectFuture.promise; } this.setAndEmitConnectionState(ConnectionState.Connecting); if (this.regionUrlProvider?.getServerUrl().toString() !== url) { this.regionUrl = undefined; this.regionUrlProvider = undefined; } if (isCloud(new URL(url))) { if (this.regionUrlProvider === undefined) { this.regionUrlProvider = new RegionUrlProvider(url, token); } else { this.regionUrlProvider.updateToken(token); } // trigger the first fetch without waiting for a response // if initial connection fails, this will speed up picking regional url // on subsequent runs this.regionUrlProvider .fetchRegionSettings() .then((settings) => { this.regionUrlProvider?.setServerReportedRegions(settings); }) .catch((e) => { this.log.warn('could not fetch region settings', { ...this.logContext, error: e }); }); } const connectFn = async ( resolve: () => void, reject: (reason: any) => void, regionUrl?: string, ) => { if (this.abortController) { this.abortController.abort(); } // explicit creation as local var needed to satisfy TS compiler when passing it to `attemptConnection` further down const abortController = new AbortController(); this.abortController = abortController; // at this point the intention to connect has been signalled so we can allow cancelling of the connection via disconnect() again unlockDisconnect?.(); try { await this.attemptConnection(regionUrl ?? url, token, opts, abortController); this.abortController = undefined; resolve(); } catch (e) { if ( this.regionUrlProvider && e instanceof ConnectionError && e.reason !== ConnectionErrorReason.Cancelled && e.reason !== ConnectionErrorReason.NotAllowed ) { let nextUrl: string | null = null; try { nextUrl = await this.regionUrlProvider.getNextBestRegionUrl( this.abortController?.signal, ); } catch (error) { if ( error instanceof ConnectionError && (error.status === 401 || error.reason === ConnectionErrorReason.Cancelled) ) { this.handleDisconnect(this.options.stopLocalTrackOnUnpublish); reject(error); return; } } if (nextUrl && !this.abortController?.signal.aborted) { this.log.info( `Initial connection failed with ConnectionError: ${e.message}. Retrying with another region: ${nextUrl}`, this.logContext, ); this.recreateEngine(); await connectFn(resolve, reject, nextUrl); } else { this.handleDisconnect(this.options.stopLocalTrackOnUnpublish); reject(e); } } else { this.handleDisconnect(this.options.stopLocalTrackOnUnpublish); reject(e); } } }; const regionUrl = this.regionUrl; this.regionUrl = undefined; this.connectFuture = new Future( (resolve, reject) => { connectFn(resolve, reject, regionUrl); }, () => { this.clearConnectionFutures(); }, ); return this.connectFuture.promise; }; private connectSignal = async ( url: string, token: string, engine: RTCEngine, connectOptions: InternalRoomConnectOptions, roomOptions: InternalRoomOptions, abortController: AbortController, ): Promise<JoinResponse> => { const joinResponse = await engine.join( url, token, { autoSubscribe: connectOptions.autoSubscribe, adaptiveStream: typeof roomOptions.adaptiveStream === 'object' ? true : roomOptions.adaptiveStream, maxRetries: connectOptions.maxRetries, e2eeEnabled: !!this.e2eeManager, websocketTimeout: connectOptions.websocketTimeout, }, abortController.signal, ); let serverInfo: Partial<ServerInfo> | undefined = joinResponse.serverInfo; if (!serverInfo) { serverInfo = { version: joinResponse.serverVersion, region: joinResponse.serverRegion }; } this.log.debug( `connected to Livekit Server ${Object.entries(serverInfo) .map(([key, value]) => `${key}: ${value}`) .join(', ')}`, { room: joinResponse.room?.name, roomSid: joinResponse.room?.sid, identity: joinResponse.participant?.identity, }, ); if (!joinResponse.serverVersion) { throw new UnsupportedServer('unknown server version'); } if (joinResponse.serverVersion === '0.15.1' && this.options.dynacast) { this.log.debug('disabling dynacast due to server version', this.logContext); // dynacast has a bug in 0.15.1, so we cannot use it then roomOptions.dynacast = false; } return joinResponse; }; private applyJoinResponse = (joinResponse: JoinResponse) => { const pi = joinResponse.participant!; this.localParticipant.sid = pi.sid; this.localParticipant.identity = pi.identity; this.localParticipant.setEnabledPublishCodecs(joinResponse.enabledPublishCodecs); if (this.options.e2ee && this.e2eeManager) { try { this.e2eeManager.setSifTrailer(joinResponse.sifTrailer); } catch (e: any) { this.log.error(e instanceof Error ? e.message : 'Could not set SifTrailer', { ...this.logContext, error: e, }); } } // populate remote participants, these should not trigger new events this.handleParticipantUpdates([pi, ...joinResponse.otherParticipants]); if (joinResponse.room) { this.handleRoomUpdate(joinResponse.room); } }; private attemptConnection = async ( url: string, token: string, opts: RoomConnectOptions | undefined, abortController: AbortController, ) => { if ( this.state === ConnectionState.Reconnecting || this.isResuming || this.engine?.pendingReconnect ) { this.log.info('Reconnection attempt replaced by new connection attempt', this.logContext); // make sure we close and recreate the existing engine in order to get rid of any potentially ongoing reconnection attempts this.recreateEngine(); } else { // create engine if previously disconnected this.maybeCreateEngine(); } if (this.regionUrlProvider?.isCloud()) { this.engine.setRegionUrlProvider(this.regionUrlProvider); } this.acquireAudioContext(); this.connOptions = { ...roomConnectOptionDefaults, ...opts } as InternalRoomConnectOptions; if (this.connOptions.rtcConfig) { this.engine.rtcConfig = this.connOptions.rtcConfig; } if (this.connOptions.peerConnectionTimeout) { this.engine.peerConnectionTimeout = this.connOptions.peerConnectionTimeout; } try { const joinResponse = await this.connectSignal( url, token, this.engine, this.connOptions, this.options, abortController, ); this.applyJoinResponse(joinResponse); // forward metadata changed for the local participant this.setupLocalParticipantEvents(); this.emit(RoomEvent.SignalConnected); } catch (err) { await this.engine.close(); this.recreateEngine(); const resultingError = new ConnectionError(`could not establish signal connection`); if (err instanceof Error) { resultingError.message = `${resultingError.message}: ${err.message}`; } if (err instanceof ConnectionError) { resultingError.reason = err.reason; resultingError.status = err.status; } this.log.debug(`error trying to establish signal connection`, { ...this.logContext, error: err, }); throw resultingError; } if (abortController.signal.aborted) { await this.engine.close(); this.recreateEngine(); throw new ConnectionError(`Connection attempt aborted`); } try { await this.engine.waitForPCInitialConnection( this.connOptions.peerConnectionTimeout, abortController, ); } catch (e) { await this.engine.close(); this.recreateEngine(); throw e; } // also hook unload event if (isWeb() && this.options.disconnectOnPageLeave) { // capturing both 'pagehide' and 'beforeunload' to capture broadest set of browser behaviors window.addEventListener('pagehide', this.onPageLeave); window.addEventListener('beforeunload', this.onPageLeave); } if (isWeb()) { document.addEventListener('freeze', this.onPageLeave); navigator.mediaDevices?.addEventListener('devicechange', this.handleDeviceChange); } this.setAndEmitConnectionState(ConnectionState.Connected); this.emit(RoomEvent.Connected); this.registerConnectionReconcile(); }; /** * disconnects the room, emits [[RoomEvent.Disconnected]] */ disconnect = async (stopTracks = true) => { const unlock = await this.disconnectLock.lock(); try { if (this.state === ConnectionState.Disconnected) { this.log.debug('already disconnected', this.logContext); return; } this.log.info('disconnect from room', { ...this.logContext, }); if ( this.state === ConnectionState.Connecting || this.state === ConnectionState.Reconnecting || this.isResuming ) { // try aborting pending connection attempt this.log.warn('abort connection attempt', this.logContext); this.abortController?.abort(); // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly this.connectFuture?.reject?.(new ConnectionError('Client initiated disconnect')); this.connectFuture = undefined; } // send leave if (!this.engine?.client.isDisconnected) { await this.engine.client.sendLeave(); } // close engine (also closes client) if (this.engine) { await this.engine.close(); } this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED); /* @ts-ignore */ this.engine = undefined; } finally { unlock(); } }; /** * retrieves a participant by identity * @param identity * @returns */ getParticipantByIdentity(identity: string): Participant | undefined { if (this.localParticipant.identity === identity) { return this.localParticipant; } return this.remoteParticipants.get(identity); } private clearConnectionFutures() { this.connectFuture = undefined; } /** * @internal for testing */ async simulateScenario(scenario: SimulationScenario, arg?: any) { let postAction = () => {}; let req: SimulateScenario | undefined; switch (scenario) { case 'signal-reconnect': // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate disconnect'); break; case 'speaker': req = new SimulateScenario({ scenario: { case: 'speakerUpdate', value: 3, }, }); break; case 'node-failure': req = new SimulateScenario({ scenario: { case: 'nodeFailure', value: true, }, }); break; case 'server-leave': req = new SimulateScenario({ scenario: { case: 'serverLeave', value: true, }, }); break; case 'migration': req = new SimulateScenario({ scenario: { case: 'migration', value: true, }, }); break; case 'resume-reconnect': this.engine.failNext(); // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate resume-disconnect'); break; case 'disconnect-signal-on-resume': postAction = async () => { // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate resume-disconnect'); }; req = new SimulateScenario({ scenario: { case: 'disconnectSignalOnResume', value: true, }, }); break; case 'disconnect-signal-on-resume-no-messages': postAction = async () => { // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate resume-disconnect'); }; req = new SimulateScenario({ scenario: { case: 'disconnectSignalOnResumeNoMessages', value: true, }, }); break; case 'full-reconnect': this.engine.fullReconnectOnNext = true; // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate full-reconnect'); break; case 'force-tcp': case 'force-tls': req = new SimulateScenario({ scenario: { case: 'switchCandidateProtocol', value: scenario === 'force-tls' ? 2 : 1, }, }); postAction = async () => { const onLeave = this.engine.client.onLeave; if (onLeave) { onLeave( new LeaveRequest({ reason: DisconnectReason.CLIENT_INITIATED, action: LeaveRequest_Action.RECONNECT, }), ); } }; break; case 'subscriber-bandwidth': if (arg === undefined || typeof arg !== 'number') { throw new Error('subscriber-bandwidth requires a number as argument'); } req = new SimulateScenario({ scenario: { case: 'subscriberBandwidth', value: BigInt(arg), }, }); break; case 'leave-full-reconnect': req = new SimulateScenario({ scenario: { case: 'leaveRequestFullReconnect', value: true, }, }); default: } if (req) { await this.engine.client.sendSimulateScenario(req); await postAction(); } } private onPageLeave = async () => { this.log.info('Page leave detected, disconnecting', this.logContext); await this.disconnect(); }; /** * Browsers have different policies regarding audio playback. Most requiring * some form of user interaction (click/tap/etc). * In those cases, audio will be silent until a click/tap triggering one of the following * - `startAudio` * - `getUserMedia` */ startAudio = async () => { const elements: Array<HTMLMediaElement> = []; const browser = getBrowser(); if (browser && browser.os === 'iOS') { /** * iOS blocks audio element playback if * - user is not publishing audio themselves and * - no other audio source is playing * * as a workaround, we create an audio element with an empty track, so that * silent audio is always playing */ const audioId = 'livekit-dummy-audio-el'; let dummyAudioEl = document.getElementById(audioId) as HTMLAudioElement | null; if (!dummyAudioEl) { dummyAudioEl = document.createElement('audio'); dummyAudioEl.id = audioId; dummyAudioEl.autoplay = true; dummyAudioEl.hidden = true; const track = getEmptyAudioStreamTrack(); track.enabled = true; const stream = new MediaStream([track]); dummyAudioEl.srcObject = stream; document.addEventListener('visibilitychange', () => { if (!dummyAudioEl) { return; } // set the srcObject to null on page hide in order to prevent lock screen controls to show up for it dummyAudioEl.srcObject = document.hidden ? null : stream; if (!document.hidden) { this.log.debug( 'page visible again, triggering startAudio to resume playback and update playback status', this.logContext, ); this.startAudio(); } }); document.body.append(dummyAudioEl); this.once(RoomEvent.Disconnected, () => { dummyAudioEl?.remove(); dummyAudioEl = null; }); } elements.push(dummyAudioEl); } this.remoteParticipants.forEach((p) => { p.audioTrackPublications.forEach((t) => { if (t.track) { t.track.attachedElements.forEach((e) => { elements.push(e); }); } }); }); try { await Promise.all([ this.acquireAudioContext(), ...elements.map((e) => { e.muted = false; return e.play(); }), ]); this.handleAudioPlaybackStarted(); } catch (err) { this.handleAudioPlaybackFailed(err); throw err; } }; startVideo = async () => { const elements: HTMLMediaElement[] = []; for (const p of this.remoteParticipants.values()) { p.videoTrackPublications.forEach((tr) => { tr.track?.attachedElements.forEach((el) => { if (!elements.includes(el)) { elements.push(el); } }); }); } await Promise.all(elements.map((el) => el.play())) .then(() => { this.handleVideoPlaybackStarted(); }) .catch((e) => { if (e.name === 'NotAllowedError') { this.handleVideoPlaybackFailed(); } else { this.log.warn( 'Resuming video playback failed, make sure you call `startVideo` directly in a user gesture handler', this.logContext, ); } }); }; /** * Returns true if audio playback is enabled */ get canPlaybackAudio(): boolean { return this.audioEnabled; } /** * Returns true if video playback is enabled */ get canPlaybackVideo(): boolean { return !this.isVideoPlaybackBlocked; } getActiveDevice(kind: MediaDeviceKind): string | undefined { return this.localParticipant.activeDeviceMap.get(kind); } /** * Switches all active devices used in this room to the given device. * * Note: setting AudioOutput is not supported on some browsers. See [setSinkId](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility) * * @param kind use `videoinput` for camera track, * `audioinput` for microphone track, * `audiooutput` to set speaker for all incoming audio tracks * @param deviceId */ async switchActiveDevice(kind: MediaDeviceKind, deviceId: string, exact: boolean = false) { let deviceHasChanged = false; let success = true; const deviceConstraint = exact ? { exact: deviceId } : deviceId; if (kind === 'audioinput') { const prevDeviceId = 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, ); try { success = ( await Promise.all(tracks.map((t) => t.audioTrack?.setDeviceId(deviceConstraint))) ).every((val) => val === true); } catch (e) { this.options.audioCaptureDefaults!.deviceId = prevDeviceId; throw e; } } else if (kind === 'videoinput') { const prevDeviceId = 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, ); try { success = ( await Promise.all(tracks.map((t) => t.videoTrack?.setDeviceId(deviceConstraint))) ).every((val) => val === true); } catch (e) { this.options.videoCaptureDefaults!.deviceId = prevDeviceId; throw e; } } else if (kind === 'audiooutput') { if ( (!supportsSetSinkId() && !this.options.webAudioMix) || (this.options.webAudioMix && this.audioContext && !('setSinkId' in this.audioContext)) ) { throw new Error('cannot switch audio output, setSinkId not supported'); } if (this.options.webAudioMix) { // setting `default` for web audio output doesn't work, so we need to normalize the id before deviceId = (await DeviceManager.getInstance().normalizeDeviceId('audiooutput', deviceId)) ?? ''; } this.options.audioOutput ??= {}; const prevDeviceId = this.options.audioOutput.deviceId; this.options.audioOutput.deviceId = deviceId; deviceHasChanged = prevDeviceId !== deviceConstraint; try { if (this.options.webAudioMix) { // @ts-expect-error setSinkId is not yet in the typescript type of AudioContext this.audioContext?.setSinkId(deviceId); } // also set audio output on all audio elements, even if webAudioMix is enabled in order to workaround echo cancellation not working on chrome with non-default output devices // see https://issues.chromium.org/issues/40252911#comment7 await Promise.all( Array.from(this.remoteParticipants.values()).map((p) => p.setAudioOutput({ deviceId })), ); } catch (e) { this.options.audioOutput.deviceId = prevDeviceId; throw e; } } if (deviceHasChanged && success) { this.localParticipant.activeDeviceMap.set(kind, deviceId); this.emit(RoomEvent.ActiveDeviceChanged, kind, deviceId); } return success; } private setupLocalParticipantEvents() { this.localParticipant .on(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged) .on(ParticipantEvent.ParticipantNameChanged, this.onLocalParticipantNameChanged) .on(ParticipantEvent.AttributesChanged, this.onLocalAttributesChanged) .on(ParticipantEvent.TrackMuted, this.onLocalTrackMuted) .on(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted) .on(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished) .on(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished) .on(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged) .on(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError) .on(ParticipantEvent.AudioStreamAcquired, this.startAudio) .on( ParticipantEvent.ParticipantPermissionsChanged, this.onLocalParticipantPermissionsChanged, ); } private recreateEngine() { this.engine?.close(); /* @ts-ignore */ this.engine = undefined; this.isResuming = false; // clear out existing remote participants, since they may have attached // the old engine this.remoteParticipants.clear(); this.sidToIdentity.clear(); this.bufferedEvents = []; this.maybeCreateEngine(); } private onTrackAdded( mediaTrack: MediaStreamTrack, stream: MediaStream, receiver: RTCRtpReceiver, ) { // don't fire onSubscribed when connecting // WebRTC fires onTrack as soon as setRemoteDescription is called on the offer // at that time, ICE connectivity has not been established so the track is not // technically subscribed. // We'll defer these events until when the room is connected or eventually disconnected. if (this.state === ConnectionState.Connecting || this.state === ConnectionState.Reconnecting) { const reconnectedHandler = () => { this.onTrackAdded(mediaTrack, stream, receiver); cleanup(); }; const cleanup = () => { this.off(RoomEvent.Reconnected, reconnectedHandler); this.off(RoomEvent.Connected, reconnectedHandler); this.off(RoomEvent.Disconnected, cleanup); }; this.once(RoomEvent.Reconnected, reconnectedHandler); this.once(RoomEvent.Connected, reconnectedHandler); this.once(RoomEvent.Disconnected, cleanup); return; } if (this.state === ConnectionState.Disconnected) { this.log.warn('skipping incoming track after Room disconnected', this.logContext); return; } const parts = unpackStreamId(stream.id); const participantSid = parts[0]; let streamId = parts[1]; let trackId = mediaTrack.id; // firefox will get streamId (pID|trackId) instead of (pID|streamId) as it doesn't support sync tracks by stream // and generates its own track id instead of infer from sdp track id. if (streamId && streamId.startsWith('TR')) trackId = streamId; if (participantSid === this.localParticipant.sid) { this.log.warn('tried to create RemoteParticipant for local participant', this.logContext); return; } const participant = Array.from(this.remoteParticipants.values()).find( (p) => p.sid === participantSid, ) as RemoteParticipant | undefined; if (!participant) { this.log.error( `Tried to add a track for a participant, that's not present. Sid: ${participantSid}`, this.logContext, ); return; } let adaptiveStreamSettings: AdaptiveStreamSettings | undefined; if (this.options.adaptiveStream) { if (typeof this.options.adaptiveStream === 'object') { adaptiveStreamSettings = this.options.adaptiveStream; } else { adaptiveStreamSettings = {}; } } participant.addSubscribedMediaTrack( mediaTrack, trackId, stream, receiver, adaptiveStreamSettings, ); } private handleRestarting = () => { this.clearConnectionReconcile(); // in case we went from resuming to full-reconnect, make sure to reflect it on the isResuming flag this.isResuming = false; // also unwind existing participants & existing subscriptions for (const p of this.remoteParticipants.values()) { this.handleParticipantDisconnected(p.identity, p); } if (this.setAndEmitConnectionState(ConnectionState.Reconnecting)) { this.emit(RoomEvent.Reconnecting); } }; private handleSignalRestarted = async (joinResponse: JoinResponse) => { this.log.debug(`signal reconnected to server, region ${joinResponse.serverRegion}`, { ...this.logContext, region: joinResponse.serverRegion, }); this.bufferedEvents = []; this.applyJoinResponse(joinResponse); try { // unpublish & republish tracks await this.localParticipant.republishAllTracks(undefined, true); } catch (error) { this.log.error('error trying to re-publish tracks after reconnection', { ...this.logContext, error, }); } try { await this.engine.waitForRestarted(); this.log.debug(`fully reconnected to server`, { ...this.logContext, region: joinResponse.serverRegion, }); } catch { // reconnection failed, handleDisconnect is being invoked already, just return here return; } this.setAndEmitConnectionState(ConnectionState.Connected); this.emit(RoomEvent.Reconnected); this.registerConnectionReconcile(); this.emitBufferedEvents(); }; private handleDisconnect(shouldStopTracks = true, reason?: DisconnectReason) { this.clearConnectionReconcile(); this.isResuming = false; this.bufferedEvents = []; this.transcriptionReceivedTimes.clear(); if (this.state === ConnectionState.Disconnected) { return; } this.regionUrl = undefined; try { this.remoteParticipants.forEach((p) => { p.trackPublications.forEach((pub) => { p.unpublishTrack(pub.trackSid); }); }); this.localParticipant.trackPublications.forEach((pub) => { if (pub.track) { this.localParticipant.unpublishTrack(pub.track, shouldStopTracks); } if (shouldStopTracks) { pub.track?.detach(); pub.track?.stop(); } }); this.localParticipant .off(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged) .off(ParticipantEvent.ParticipantNameChanged, this.onLocalParticipantNameChanged) .off(ParticipantEvent.AttributesChanged, this.onLocalAttributesChanged) .off(ParticipantEvent.TrackMuted, this.onLocalTrackMuted) .off(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted) .off(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished) .off(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished) .off(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged) .off(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError) .off(ParticipantEvent.AudioStreamAcquired, this.startAudio) .off( ParticipantEvent.ParticipantPermissionsChanged, this.onLocalParticipantPermissionsChanged, ); this.localParticipant.trackPublications.clear(); this.localParticipant.videoTrackPublications.clear(); this.localParticipant.audioTrackPublications.clear(); this.remoteParticipants.clear(); this.sidToIdentity.clear(); this.activeSpeakers = []; if (this.audioContext && typeof this.options.webAudioMix === 'boolean') { this.audioContext.close(); this.audioContext = undefined; } if (isWeb()) { window.removeEventListener('beforeunload', this.onPageLeave); window.removeEventListener('pagehide', this.onPageLeave); window.removeEventListener('freeze', this.onPageLeave); navigator.mediaDevices?.removeEventListener('devicechange', this.handleDeviceChange); } } finally { this.setAndEmitConnectionState(ConnectionState.Disconnected); this.emit(RoomEvent.Disconnected, reason); } } private handleParticipantUpdates = (participantInfos: ParticipantInfo[]) => { // handle changes to participant state, and send events participantInfos.forEach((info) => { if (info.identity === this.localParticipant.identity) { this.localParticipant.updateInfo(info); return; } // LiveKit server doesn't send identity info prior to version 1.5.2 in disconnect updates // so we try to map an empty identity to an already known sID manually if (info.identity === '') { info.identity = this.sidToIdentity.get(info.sid) ?? ''; } let remoteParticipant = this.remoteParticipants.get(info.identity); // when it's disconnected, send updates if (info.state === ParticipantInfo_State.DISCONNECTED) { this.handleParticipantDisconnected(info.identity, remoteParticipant); } else { // create participant if doesn't exist remoteParticipant = this.getOrCreateParticipant(info.identity, info); } }); }; private handleParticipantDisconnected(identity: string, participant?: RemoteParticipant) { // remove and send event this.remoteParticipants.delete(identity); if (!participant) { return; } participant.trackPublications.forEach((publication) => { participant.unpublishTrack(publication.trackSid, true); }); this.emit(RoomEvent.ParticipantDisconnected, participant); } // updates are sent only when there's a change to speaker ordering private handleActiveSpeakersUpdate = (speakers: SpeakerInfo[]) => { const activeSpeakers: Participant[] = []; const seenSids: any = {}; speakers.forEach((speaker) => { seenSids[speaker.sid] = true; if (speaker.sid === this.localParticipant.sid) { this.localParticipant.audioLevel = speaker.level; this.localParticipant.setIsSpeaking(true); activeSpeakers.push(this.localParticipant); } else { const p = this.getRemoteParticipantBySid(speaker.sid); if (p) { p.audioLevel = speaker.level; p.setIsSpeaking(true); activeSpeakers.push(p); } } }); if (!seenSids[this.localParticipant.sid]) { this.localParticipant.audioLevel = 0; this.localParticipant.setIsSpeaking(false); } this.remoteParticipants.forEach((p) => { if (!seenSids[p.sid]) { p.audioLevel = 0; p.setIsSpeaking(false); } }); this.activeSpeakers = activeSpeakers; this.emitWhenConnected(RoomEvent.ActiveSpeakersChanged, activeSpeakers); }; // process list of changed speakers private handleSpeakersChanged = (speakerUpdates: SpeakerInfo[]) => { const lastSpeakers = new Map<string, Participant>(); this.activeSpeakers.forEach((p) => { const remoteParticipant = this.remoteParticipants.get(p.identity); if (remoteParticipant && remoteParticipant.sid !== p.sid) { return; } lastSpeakers.set(p.sid, p); }); speakerUpdates.forEach((speaker) => { let p: Participant | undefined = this.getRemoteParticipantBySid(speaker.sid); if (speaker.sid === this.localParticipant.sid) { p = this.localParticipant; } if (!p) { return; } p.audioLevel = speaker.level; p.setIsSpeaking(speaker.active); if (speaker.active) { lastSpeakers.set(speaker.sid, p); } else { lastSpeakers.delete(speaker.sid); } }); const activeSpeakers = Array.from(lastSpeakers.values()); activeSpeakers.sort((a, b) => b.audioLevel - a.audioLevel); this.activeSpeakers = activeSpeakers; this.emitWhenConnected(RoomEvent.ActiveSpeakersChanged, activeSpeakers); }; private handleStreamStateUpdate = (streamStateUpdate: StreamStateUpdate) => { streamStateUpdate.streamStates.forEach((streamState) => { const participant = this.getRemoteParticipantBySid(streamState.participantSid); if (!p