twilio-video
Version:
Twilio Video JavaScript Library
439 lines (392 loc) • 14.6 kB
JavaScript
/* eslint new-cap:0 */
'use strict';
const { getUserMedia } = require('../../webrtc');
const { isIOS } = require('../../util/browserdetection');
const { capitalize, defer, waitForSometime, waitForEvent } = require('../../util');
const { typeErrors: { ILLEGAL_INVOKE } } = require('../../util/constants');
const detectSilentAudio = require('../../util/detectsilentaudio');
const detectSilentVideo = require('../../util/detectsilentvideo');
const documentVisibilityMonitor = require('../../util/documentvisibilitymonitor.js');
const localMediaRestartDeferreds = require('../../util/localmediarestartdeferreds');
const gUMSilentTrackWorkaround = require('../../webaudio/workaround180748');
const MediaTrackSender = require('./sender');
function mixinLocalMediaTrack(AudioOrVideoTrack) {
/**
* A {@link LocalMediaTrack} represents audio or video that your
* {@link LocalParticipant} is sending to a {@link Room}. As such, it can be
* enabled and disabled with {@link LocalMediaTrack#enable} and
* {@link LocalMediaTrack#disable} or stopped completely with
* {@link LocalMediaTrack#stop}.
* @emits LocalMediaTrack#muted
* @emits LocalMediaTrack#stopped
* @emits LocalMediaTrack#unmuted
*/
return class LocalMediaTrack extends AudioOrVideoTrack {
/**
* Construct a {@link LocalMediaTrack} from a MediaStreamTrack.
* @param {MediaStreamTrack} mediaStreamTrack - The underlying MediaStreamTrack
* @param {LocalTrackOptions} [options] - {@link LocalTrack} options
*/
constructor(mediaStreamTrack, options) {
const workaroundWebKitBug1208516 = isIOS()
&& typeof document === 'object'
&& typeof document.addEventListener === 'function'
&& typeof document.visibilityState === 'string';
options = Object.assign({
getUserMedia,
isCreatedByCreateLocalTracks: false,
workaroundWebKitBug1208516,
gUMSilentTrackWorkaround
}, options);
const mediaTrackSender = new MediaTrackSender(mediaStreamTrack);
const { kind } = mediaTrackSender;
super(mediaTrackSender, options);
Object.defineProperties(this, {
_constraints: {
value: typeof options[kind] === 'object'
? options[kind]
: {},
writable: true
},
_getUserMedia: {
value: options.getUserMedia
},
_gUMSilentTrackWorkaround: {
value: options.gUMSilentTrackWorkaround
},
_eventsToReemitters: {
value: new Map([
['muted', () => this.emit('muted', this)],
['unmuted', () => this.emit('unmuted', this)]
])
},
_workaroundWebKitBug1208516: {
value: options.workaroundWebKitBug1208516
},
_workaroundWebKitBug1208516Cleanup: {
value: null,
writable: true
},
_didCallEnd: {
value: false,
writable: true
},
_isCreatedByCreateLocalTracks: {
value: options.isCreatedByCreateLocalTracks
},
_noiseCancellation: {
value: options.noiseCancellation || null
},
_trackSender: {
value: mediaTrackSender
},
id: {
enumerable: true,
value: mediaTrackSender.id
},
isEnabled: {
enumerable: true,
get() {
return mediaTrackSender.enabled;
}
},
isMuted: {
enumerable: true,
get() {
return mediaTrackSender.muted;
}
},
isStopped: {
enumerable: true,
get() {
return mediaTrackSender.readyState === 'ended';
}
}
});
// NOTE(mpatwardhan): As a workaround for WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=208516,
// upon foregrounding, re-acquire new MediaStreamTrack if the existing one is ended or muted.
if (this._workaroundWebKitBug1208516) {
this._workaroundWebKitBug1208516Cleanup = restartWhenInadvertentlyStopped(this);
}
this._reemitTrackSenderEvents();
}
/**
* @private
*/
_end() {
if (this._didCallEnd) {
return;
}
super._end.call(this);
this._didCallEnd = true;
this._eventsToReemitters.forEach((reemitter, event) => this._trackSender.removeListener(event, reemitter));
this.emit('stopped', this);
}
/**
* @private
*/
_initialize() {
if (this._didCallEnd) {
this._didCallEnd = false;
}
if (this._eventsToReemitters) {
this._reemitTrackSenderEvents();
}
super._initialize.call(this);
}
/**
* @private
*/
_reacquireTrack(constraints) {
const {
_getUserMedia: getUserMedia,
_gUMSilentTrackWorkaround: gUMSilentTrackWorkaround,
_log: log,
mediaStreamTrack: { kind }
} = this;
log.info('Re-acquiring the MediaStreamTrack');
log.debug('Constraints:', constraints);
const gUMConstraints = Object.assign({
audio: false,
video: false
}, { [kind]: constraints });
const gUMPromise = this._workaroundWebKitBug1208516Cleanup
? gUMSilentTrackWorkaround(log, getUserMedia, gUMConstraints)
: getUserMedia(gUMConstraints);
return gUMPromise.then(mediaStream => {
return mediaStream.getTracks()[0];
});
}
/**
* @private
*/
_reemitTrackSenderEvents() {
this._eventsToReemitters.forEach((reemitter, event) => this._trackSender.on(event, reemitter));
this._trackSender.dequeue('muted');
this._trackSender.dequeue('unmuted');
}
/**
* @private
*/
_restart(constraints) {
const { _log: log } = this;
constraints = constraints || this._constraints;
// NOTE(mmalavalli): If we try and restart a silent MediaStreamTrack
// without stopping it first, then a NotReadableError is raised in case of
// video, or the restarted audio will still be silent. Hence, we stop the
// MediaStreamTrack here.
this._stop();
return this._reacquireTrack(constraints).catch(error => {
log.error('Failed to re-acquire the MediaStreamTrack:', { error, constraints });
throw error;
}).then(newMediaStreamTrack => {
log.info('Re-acquired the MediaStreamTrack');
log.debug('MediaStreamTrack:', newMediaStreamTrack);
this._constraints = Object.assign({}, constraints);
return this._setMediaStreamTrack(newMediaStreamTrack);
});
}
/**
* @private
*/
_setMediaStreamTrack(mediaStreamTrack) {
// NOTE(mpatwardhan): Preserve the value of the "enabled" flag.
mediaStreamTrack.enabled = this.mediaStreamTrack.enabled;
// NOTE(mmalavalli): Stop the current MediaStreamTrack. If not already
// stopped, this should fire a "stopped" event.
this._stop();
// NOTE(csantos): If there's an unprocessedTrack, this means RTCRtpSender has
// the processedTrack already set, we don't want to replace that.
return (this._unprocessedTrack ? Promise.resolve().then(() => {
this._unprocessedTrack = mediaStreamTrack;
}) : this._trackSender.setMediaStreamTrack(mediaStreamTrack).catch(error => {
this._log.warn('setMediaStreamTrack failed:', { error, mediaStreamTrack });
})).then(() => {
this._initialize();
this._getAllAttachedElements().forEach(el => this._attach(el));
});
}
/**
* @private
*/
_stop() {
this.mediaStreamTrack.stop();
this._end();
return this;
}
enable(enabled) {
enabled = typeof enabled === 'boolean' ? enabled : true;
if (enabled !== this.mediaStreamTrack.enabled) {
this._log.info(`${enabled ? 'En' : 'Dis'}abling`);
this.mediaStreamTrack.enabled = enabled;
this.emit(enabled ? 'enabled' : 'disabled', this);
}
return this;
}
disable() {
return this.enable(false);
}
restart(constraints) {
const { kind } = this;
if (!this._isCreatedByCreateLocalTracks) {
return Promise.reject(ILLEGAL_INVOKE('restart', 'can only be called on a'
+ ` Local${capitalize(kind)}Track that is created using createLocalTracks`
+ ` or createLocal${capitalize(kind)}Track.`));
}
if (this._workaroundWebKitBug1208516Cleanup) {
this._workaroundWebKitBug1208516Cleanup();
this._workaroundWebKitBug1208516Cleanup = null;
}
let promise = this._restart(constraints);
if (this._workaroundWebKitBug1208516) {
promise = promise.finally(() => {
this._workaroundWebKitBug1208516Cleanup = restartWhenInadvertentlyStopped(this);
});
}
return promise;
}
stop() {
this._log.info('Stopping');
if (this._workaroundWebKitBug1208516Cleanup) {
this._workaroundWebKitBug1208516Cleanup();
this._workaroundWebKitBug1208516Cleanup = null;
}
return this._stop();
}
};
}
/**
* Restart the given {@link LocalMediaTrack} if it has been inadvertently stopped.
* @private
* @param {LocalAudioTrack|LocalVideoTrack} localMediaTrack
* @returns {function} Clean up listeners attached by the workaround
*/
function restartWhenInadvertentlyStopped(localMediaTrack) {
const {
_log: log,
kind,
_noiseCancellation: noiseCancellation
} = localMediaTrack;
const detectSilence = {
audio: detectSilentAudio,
video: detectSilentVideo
}[kind];
const getSourceMediaStreamTrack = () => noiseCancellation
? noiseCancellation.sourceTrack
: localMediaTrack.mediaStreamTrack;
let { _dummyEl: el } = localMediaTrack;
let mediaStreamTrack = getSourceMediaStreamTrack();
let trackChangeInProgress = null;
function checkSilence() {
// The dummy element is paused, so play it and then detect silence.
return el.play().then(() => detectSilence(el)).then(isSilent => {
if (isSilent) {
log.warn('Silence detected');
} else {
log.info('Non-silence detected');
}
return isSilent;
}).catch(error => {
log.warn('Failed to detect silence:', error);
}).finally(() => {
// Pause the dummy element again, if there is no processed track.
if (!localMediaTrack.processedTrack) {
el.pause();
}
});
}
function shouldReacquireTrack() {
const {
_workaroundWebKitBug1208516Cleanup,
isStopped
} = localMediaTrack;
const isInadvertentlyStopped = isStopped && !!_workaroundWebKitBug1208516Cleanup;
const { muted } = getSourceMediaStreamTrack();
// NOTE(mmalavalli): Restart the LocalMediaTrack if:
// 1. The app is foregrounded, and
// 2. A restart is not already in progress, and
// 3. The LocalMediaTrack is either muted, inadvertently stopped or silent
return Promise.resolve().then(() => {
return document.visibilityState === 'visible'
&& !trackChangeInProgress
&& (muted || isInadvertentlyStopped || checkSilence());
});
}
function maybeRestart() {
return Promise.race([
waitForEvent(mediaStreamTrack, 'unmute'),
waitForSometime(50)
]).then(() => shouldReacquireTrack()).then(shouldReacquire => {
if (shouldReacquire && !trackChangeInProgress) {
trackChangeInProgress = defer();
localMediaTrack._restart().finally(() => {
el = localMediaTrack._dummyEl;
removeMediaStreamTrackListeners();
mediaStreamTrack = getSourceMediaStreamTrack();
addMediaStreamTrackListeners();
trackChangeInProgress.resolve();
trackChangeInProgress = null;
}).catch(error => {
log.error('failed to restart track: ', error);
});
}
// NOTE(mmalavalli): If the MediaStreamTrack ends before the DOM is visible,
// then this makes sure that visibility callback for phase 2 is called only
// after the MediaStreamTrack is re-acquired.
const promise = (trackChangeInProgress && trackChangeInProgress.promise) || Promise.resolve();
return promise.finally(() => localMediaRestartDeferreds.resolveDeferred(kind));
}).catch(ex => {
log.error(`error in maybeRestart: ${ex.message}`);
});
}
function onMute() {
const { _log: log, kind } = localMediaTrack;
log.info('Muted');
log.debug('LocalMediaTrack:', localMediaTrack);
// NOTE(mmalavalli): When a LocalMediaTrack is muted without the app being
// backgrounded, and the inadvertently paused elements are played before it
// is restarted, it never gets unmuted due to the WebKit Bug 213853. Hence,
// setting this Deferred will make sure that the inadvertently paused elements
// are played only after the LocalMediaTrack is unmuted.
//
// Bug: https://bugs.webkit.org/show_bug.cgi?id=213853
//
localMediaRestartDeferreds.startDeferred(kind);
}
function addMediaStreamTrackListeners() {
if (mediaStreamTrack.addEventListener) {
mediaStreamTrack.addEventListener('ended', maybeRestart);
mediaStreamTrack.addEventListener('mute', onMute);
mediaStreamTrack.addEventListener('unmute', maybeRestart);
} else {
mediaStreamTrack.onended = maybeRestart;
mediaStreamTrack.onmute = onMute;
mediaStreamTrack.onunmute = maybeRestart;
}
}
function removeMediaStreamTrackListeners() {
if (mediaStreamTrack.removeEventListener) {
mediaStreamTrack.removeEventListener('ended', maybeRestart);
mediaStreamTrack.removeEventListener('mute', onMute);
mediaStreamTrack.removeEventListener('unmute', maybeRestart);
} else {
mediaStreamTrack.onended = null;
mediaStreamTrack.onmute = null;
mediaStreamTrack.onunmute = null;
}
}
// NOTE(mpatwardhan): listen for document visibility callback on phase 1.
// this ensures that we acquire media tracks before RemoteMediaTrack
// tries to `play` them (in phase 2). This order is important because
// play can fail on safari if audio is not being captured.
let onVisibilityChange = isVisible => {
return isVisible ? maybeRestart() : false;
};
documentVisibilityMonitor.onVisibilityChange(1, onVisibilityChange);
addMediaStreamTrackListeners();
return () => {
documentVisibilityMonitor.offVisibilityChange(1, onVisibilityChange);
removeMediaStreamTrackListeners();
};
}
module.exports = mixinLocalMediaTrack;