UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

688 lines (593 loc) 22.2 kB
import { Mutex } from '@livekit/mutex'; import { getBrowser } from '../../utils/browserParser'; import DeviceManager from '../DeviceManager'; import { debounce } from '../debounce'; import { DeviceUnsupportedError, TrackInvalidError } from '../errors'; import { TrackEvent } from '../events'; import type { LoggerOptions } from '../types'; import { compareVersions, isMobile, sleep, unwrapConstraint } from '../utils'; import { Track, attachToElement, detachTrack } from './Track'; import type { VideoCodec } from './options'; import type { TrackProcessor } from './processor/types'; import { LocalTrackRecorder, isRecordingSupported } from './record'; import type { ReplaceTrackOptions } from './types'; const DEFAULT_DIMENSIONS_TIMEOUT = 1000; const PRE_CONNECT_BUFFER_TIMEOUT = 10_000; export default abstract class LocalTrack< TrackKind extends Track.Kind = Track.Kind, > extends Track<TrackKind> { protected _sender?: RTCRtpSender; private autoStopPreConnectBuffer: ReturnType<typeof setTimeout> | undefined; /** @internal */ get sender(): RTCRtpSender | undefined { return this._sender; } /** @internal */ set sender(sender: RTCRtpSender | undefined) { this._sender = sender; } /** @internal */ codec?: VideoCodec; get constraints() { return this._constraints; } get hasPreConnectBuffer() { return !!this.localTrackRecorder; } protected _constraints: MediaTrackConstraints; protected reacquireTrack: boolean; protected providedByUser: boolean; protected muteLock: Mutex; protected pauseUpstreamLock: Mutex; protected processorElement?: HTMLMediaElement; protected processor?: TrackProcessor<TrackKind, any>; protected audioContext?: AudioContext; protected manuallyStopped: boolean = false; protected localTrackRecorder: LocalTrackRecorder<typeof this> | undefined; protected trackChangeLock: Mutex; protected pendingDeviceChange: boolean = false; /** * * @param mediaTrack * @param kind * @param constraints MediaTrackConstraints that are being used when restarting or reacquiring tracks * @param userProvidedTrack Signals to the SDK whether or not the mediaTrack should be managed (i.e. released and reacquired) internally by the SDK */ protected constructor( mediaTrack: MediaStreamTrack, kind: TrackKind, constraints?: MediaTrackConstraints, userProvidedTrack = false, loggerOptions?: LoggerOptions, ) { super(mediaTrack, kind, loggerOptions); this.reacquireTrack = false; this.providedByUser = userProvidedTrack; this.muteLock = new Mutex(); this.pauseUpstreamLock = new Mutex(); this.trackChangeLock = new Mutex(); this.trackChangeLock.lock().then(async (unlock) => { try { await this.setMediaStreamTrack(mediaTrack, true); } finally { unlock(); } }); // added to satisfy TS compiler, constraints are synced with MediaStreamTrack this._constraints = mediaTrack.getConstraints(); if (constraints) { this._constraints = constraints; } } get id(): string { return this._mediaStreamTrack.id; } get dimensions(): Track.Dimensions | undefined { if (this.kind !== Track.Kind.Video) { return undefined; } const { width, height } = this._mediaStreamTrack.getSettings(); if (width && height) { return { width, height, }; } return undefined; } private _isUpstreamPaused: boolean = false; get isUpstreamPaused() { return this._isUpstreamPaused; } get isUserProvided() { return this.providedByUser; } get mediaStreamTrack() { return this.processor?.processedTrack ?? this._mediaStreamTrack; } get isLocal() { return true; } /** * @internal * returns mediaStreamTrack settings of the capturing mediastreamtrack source - ignoring processors */ getSourceTrackSettings() { return this._mediaStreamTrack.getSettings(); } private async setMediaStreamTrack( newTrack: MediaStreamTrack, force?: boolean, isUnmuting?: boolean, ) { if (newTrack === this._mediaStreamTrack && !force) { return; } if (this._mediaStreamTrack) { // detach this.attachedElements.forEach((el) => { detachTrack(this._mediaStreamTrack, el); }); this.debouncedTrackMuteHandler.cancel('new-track'); this._mediaStreamTrack.removeEventListener('ended', this.handleEnded); this._mediaStreamTrack.removeEventListener('mute', this.handleTrackMuteEvent); this._mediaStreamTrack.removeEventListener('unmute', this.handleTrackUnmuteEvent); } this.mediaStream = new MediaStream([newTrack]); if (newTrack) { newTrack.addEventListener('ended', this.handleEnded); // when underlying track emits mute, it indicates that the device is unable // to produce media. In this case we'll need to signal with remote that // the track is "muted" // note this is different from LocalTrack.mute because we do not want to // touch MediaStreamTrack.enabled newTrack.addEventListener('mute', this.handleTrackMuteEvent); newTrack.addEventListener('unmute', this.handleTrackUnmuteEvent); this._constraints = newTrack.getConstraints(); } let processedTrack: MediaStreamTrack | undefined; if (this.processor && newTrack) { this.log.debug('restarting processor', this.logContext); if (this.kind === 'unknown') { throw TypeError('cannot set processor on track of unknown kind'); } if (this.processorElement) { attachToElement(newTrack, this.processorElement); // ensure the processorElement itself stays muted this.processorElement.muted = true; } await this.processor.restart({ track: newTrack, kind: this.kind, element: this.processorElement, }); processedTrack = this.processor.processedTrack; } if (this.sender && this.sender.transport?.state !== 'closed') { await this.sender.replaceTrack(processedTrack ?? newTrack); } // if `newTrack` is different from the existing track, stop the // older track just before replacing it if (!this.providedByUser && this._mediaStreamTrack !== newTrack) { this._mediaStreamTrack.stop(); } this._mediaStreamTrack = newTrack; if (newTrack) { // sync muted state with the enabled state of the newly provided track // if restarting as part of an unmute, set enabled to true directly to avoid mute cycling this._mediaStreamTrack.enabled = isUnmuting ? true : !this.isMuted; // when a valid track is replace, we'd want to start producing await this.resumeUpstream(); this.attachedElements.forEach((el) => { attachToElement(processedTrack ?? newTrack, el); }); } } async waitForDimensions(timeout = DEFAULT_DIMENSIONS_TIMEOUT): Promise<Track.Dimensions> { if (this.kind === Track.Kind.Audio) { throw new Error('cannot get dimensions for audio tracks'); } if (getBrowser()?.os === 'iOS') { // browsers report wrong initial resolution on iOS. // when slightly delaying the call to .getSettings(), the correct resolution is being reported await sleep(10); } const started = Date.now(); while (Date.now() - started < timeout) { const dims = this.dimensions; if (dims) { return dims; } await sleep(50); } 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) { this.pendingDeviceChange = true; 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 */ async getDeviceId(normalize = true): Promise<string | undefined> { // screen share doesn't have a usable device id if (this.source === Track.Source.ScreenShare) { return; } const { deviceId, groupId } = this._mediaStreamTrack.getSettings(); const kind = this.kind === Track.Kind.Audio ? 'audioinput' : 'videoinput'; return normalize ? DeviceManager.getInstance().normalizeDeviceId(kind, deviceId, groupId) : deviceId; } async mute() { this.setTrackMuted(true); return this; } async unmute() { this.setTrackMuted(false); return this; } async replaceTrack(track: MediaStreamTrack, options?: ReplaceTrackOptions): Promise<typeof this>; async replaceTrack(track: MediaStreamTrack, userProvidedTrack?: boolean): Promise<typeof this>; async replaceTrack( track: MediaStreamTrack, userProvidedOrOptions: boolean | ReplaceTrackOptions | undefined, ) { const unlock = await this.trackChangeLock.lock(); try { if (!this.sender) { throw new TrackInvalidError('unable to replace an unpublished track'); } let userProvidedTrack: boolean | undefined; let stopProcessor: boolean | undefined; if (typeof userProvidedOrOptions === 'boolean') { userProvidedTrack = userProvidedOrOptions; } else if (userProvidedOrOptions !== undefined) { userProvidedTrack = userProvidedOrOptions.userProvidedTrack; stopProcessor = userProvidedOrOptions.stopProcessor; } this.providedByUser = userProvidedTrack ?? true; this.log.debug('replace MediaStreamTrack', this.logContext); await this.setMediaStreamTrack(track); // this must be synced *after* setting mediaStreamTrack above, since it relies // on the previous state in order to cleanup if (stopProcessor && this.processor) { await this.internalStopProcessor(); } return this; } finally { unlock(); } } protected async restart(constraints?: MediaTrackConstraints, isUnmuting?: boolean) { this.manuallyStopped = false; const unlock = await this.trackChangeLock.lock(); try { if (!constraints) { constraints = this._constraints; } const { deviceId, facingMode, ...otherConstraints } = constraints; this.log.debug('restarting track with constraints', { ...this.logContext, constraints }); const streamConstraints: MediaStreamConstraints = { audio: false, video: false, }; if (this.kind === Track.Kind.Video) { streamConstraints.video = deviceId || facingMode ? { deviceId, facingMode } : true; } else { streamConstraints.audio = deviceId ? { deviceId, ...otherConstraints } : true; } // these steps are duplicated from setMediaStreamTrack because we must stop // the previous tracks before new tracks can be acquired this.attachedElements.forEach((el) => { detachTrack(this.mediaStreamTrack, el); }); this._mediaStreamTrack.removeEventListener('ended', this.handleEnded); // on Safari, the old audio track must be stopped before attempting to acquire // the new track, otherwise the new track will stop with // 'A MediaStreamTrack ended due to a capture failure` this._mediaStreamTrack.stop(); // create new track and attach const mediaStream = await navigator.mediaDevices.getUserMedia(streamConstraints); const newTrack = mediaStream.getTracks()[0]; if (this.kind === Track.Kind.Video) { // we already captured the audio track with the constraints, so we only need to apply the video constraints await newTrack.applyConstraints(otherConstraints); } newTrack.addEventListener('ended', this.handleEnded); this.log.debug('re-acquired MediaStreamTrack', this.logContext); await this.setMediaStreamTrack(newTrack, false, isUnmuting); this._constraints = constraints; this.pendingDeviceChange = false; this.emit(TrackEvent.Restarted, this); if (this.manuallyStopped) { this.log.warn( 'track was stopped during a restart, stopping restarted track', this.logContext, ); this.stop(); } return this; } finally { unlock(); } } protected setTrackMuted(muted: boolean) { this.log.debug(`setting ${this.kind} track ${muted ? 'muted' : 'unmuted'}`, this.logContext); if (this.isMuted === muted && this._mediaStreamTrack.enabled !== muted) { return; } this.isMuted = muted; this._mediaStreamTrack.enabled = !muted; this.emit(muted ? TrackEvent.Muted : TrackEvent.Unmuted, this); } protected get needsReAcquisition(): boolean { return ( this._mediaStreamTrack.readyState !== 'live' || this._mediaStreamTrack.muted || !this._mediaStreamTrack.enabled || this.reacquireTrack ); } protected async handleAppVisibilityChanged() { await super.handleAppVisibilityChanged(); if (!isMobile()) return; this.log.debug(`visibility changed, is in Background: ${this.isInBackground}`, this.logContext); if (!this.isInBackground && this.needsReAcquisition && !this.isUserProvided && !this.isMuted) { this.log.debug(`track needs to be reacquired, restarting ${this.source}`, this.logContext); await this.restart(); this.reacquireTrack = false; } } private handleTrackMuteEvent = () => this.debouncedTrackMuteHandler().catch(() => this.log.debug('track mute bounce got cancelled by an unmute event', this.logContext), ); private debouncedTrackMuteHandler = debounce(async () => { await this.pauseUpstream(); }, 5000); private handleTrackUnmuteEvent = async () => { this.debouncedTrackMuteHandler.cancel('unmute'); await this.resumeUpstream(); }; private handleEnded = () => { if (this.isInBackground) { this.reacquireTrack = true; } this._mediaStreamTrack.removeEventListener('mute', this.handleTrackMuteEvent); this._mediaStreamTrack.removeEventListener('unmute', this.handleTrackUnmuteEvent); this.emit(TrackEvent.Ended, this); }; stop() { this.manuallyStopped = true; super.stop(); this._mediaStreamTrack.removeEventListener('ended', this.handleEnded); this._mediaStreamTrack.removeEventListener('mute', this.handleTrackMuteEvent); this._mediaStreamTrack.removeEventListener('unmute', this.handleTrackUnmuteEvent); this.processor?.destroy(); this.processor = undefined; } /** * pauses publishing to the server without disabling the local MediaStreamTrack * this is used to display a user's own video locally while pausing publishing to * the server. * this API is unsupported on Safari < 12 due to a bug **/ async pauseUpstream() { const unlock = await this.pauseUpstreamLock.lock(); try { if (this._isUpstreamPaused === true) { return; } if (!this.sender) { this.log.warn('unable to pause upstream for an unpublished track', this.logContext); return; } this._isUpstreamPaused = true; this.emit(TrackEvent.UpstreamPaused, this); const browser = getBrowser(); if (browser?.name === 'Safari' && compareVersions(browser.version, '12.0') < 0) { // https://bugs.webkit.org/show_bug.cgi?id=184911 throw new DeviceUnsupportedError('pauseUpstream is not supported on Safari < 12.'); } if (this.sender.transport?.state !== 'closed') { await this.sender.replaceTrack(null); } } finally { unlock(); } } async resumeUpstream() { const unlock = await this.pauseUpstreamLock.lock(); try { if (this._isUpstreamPaused === false) { return; } if (!this.sender) { this.log.warn('unable to resume upstream for an unpublished track', this.logContext); return; } this._isUpstreamPaused = false; this.emit(TrackEvent.UpstreamResumed, this); if (this.sender.transport?.state !== 'closed') { // this operation is noop if mediastreamtrack is already being sent await this.sender.replaceTrack(this.mediaStreamTrack); } } finally { unlock(); } } /** * Gets the RTCStatsReport for the LocalTrack's underlying RTCRtpSender * See https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport * * @returns Promise<RTCStatsReport> | undefined */ async getRTCStatsReport(): Promise<RTCStatsReport | undefined> { if (!this.sender?.getStats) { return; } const statsReport = await this.sender.getStats(); return statsReport; } /** * Sets a processor on this track. * See https://github.com/livekit/track-processors-js for example usage * * @param processor * @param showProcessedStreamLocally * @returns */ async setProcessor(processor: TrackProcessor<TrackKind>, showProcessedStreamLocally = true) { const unlock = await this.trackChangeLock.lock(); try { this.log.debug('setting up processor', this.logContext); const processorElement = document.createElement(this.kind) as HTMLMediaElement; const processorOptions = { kind: this.kind, track: this._mediaStreamTrack, element: processorElement, audioContext: this.audioContext, }; await processor.init(processorOptions); this.log.debug('processor initialized', this.logContext); if (this.processor) { await this.internalStopProcessor(); } if (this.kind === 'unknown') { throw TypeError('cannot set processor on track of unknown kind'); } attachToElement(this._mediaStreamTrack, processorElement); processorElement.muted = true; processorElement.play().catch((error) => { if (error instanceof DOMException && error.name === 'AbortError') { // This happens on Safari when the processor is restarted, try again after a delay this.log.warn('failed to play processor element, retrying', { ...this.logContext, error, }); setTimeout(() => { processorElement.play().catch((err) => { this.log.error('failed to play processor element', { ...this.logContext, err }); }); }, 100); } else { this.log.error('failed to play processor element', { ...this.logContext, error }); } }); this.processor = processor; this.processorElement = processorElement; if (this.processor.processedTrack) { for (const el of this.attachedElements) { if (el !== this.processorElement && showProcessedStreamLocally) { detachTrack(this._mediaStreamTrack, el); attachToElement(this.processor.processedTrack, el); } } await this.sender?.replaceTrack(this.processor.processedTrack); } this.emit(TrackEvent.TrackProcessorUpdate, this.processor); } finally { unlock(); } } getProcessor() { return this.processor; } /** * Stops the track processor * See https://github.com/livekit/track-processors-js for example usage * */ async stopProcessor(keepElement = true) { const unlock = await this.trackChangeLock.lock(); try { await this.internalStopProcessor(keepElement); } finally { unlock(); } } /** * @internal * This method assumes the caller has acquired a trackChangeLock already. * The public facing method for stopping the processor is `stopProcessor` and it wraps this method in the trackChangeLock. */ protected async internalStopProcessor(keepElement = true) { if (!this.processor) return; this.log.debug('stopping processor', this.logContext); this.processor.processedTrack?.stop(); await this.processor.destroy(); this.processor = undefined; if (!keepElement) { this.processorElement?.remove(); this.processorElement = undefined; } // apply original track constraints in case the processor changed them await this._mediaStreamTrack.applyConstraints(this._constraints); // force re-setting of the mediaStreamTrack on the sender await this.setMediaStreamTrack(this._mediaStreamTrack, true); this.emit(TrackEvent.TrackProcessorUpdate); } /** @internal */ startPreConnectBuffer(timeslice: number = 100) { if (!isRecordingSupported()) { this.log.warn( 'MediaRecorder is not available, cannot start preconnect buffer', this.logContext, ); return; } if (!this.localTrackRecorder) { let mimeType = 'audio/webm;codecs=opus'; if (!MediaRecorder.isTypeSupported(mimeType)) { // iOS currently only supports video/mp4 as a mime type - even for audio. mimeType = 'video/mp4'; } this.localTrackRecorder = new LocalTrackRecorder(this, { mimeType, }); } else { this.log.warn('preconnect buffer already started'); return; } this.localTrackRecorder.start(timeslice); this.autoStopPreConnectBuffer = setTimeout(() => { this.log.warn( 'preconnect buffer timed out, stopping recording automatically', this.logContext, ); this.stopPreConnectBuffer(); }, PRE_CONNECT_BUFFER_TIMEOUT); } /** @internal */ stopPreConnectBuffer() { clearTimeout(this.autoStopPreConnectBuffer); if (this.localTrackRecorder) { this.localTrackRecorder.stop(); this.localTrackRecorder = undefined; } } /** @internal */ getPreConnectBuffer(): ReadableStream<Uint8Array> | undefined { return this.localTrackRecorder?.byteStream; } getPreConnectBufferMimeType() { return this.localTrackRecorder?.mimeType; } protected abstract monitorSender(): void; }