livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
688 lines (593 loc) • 22.2 kB
text/typescript
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;
}