twilio-video
Version:
Twilio Video JavaScript Library
567 lines (515 loc) • 17.8 kB
JavaScript
'use strict';
const { guessBrowser } = require('../../webrtc/util');
const PeerConnectionV2 = require('./peerconnection');
const MediaTrackSender = require('../../media/track/sender');
const QueueingEventEmitter = require('../../queueingeventemitter');
const util = require('../../util');
const { MediaConnectionError } = require('../../util/twilio-video-errors');
const isFirefox = guessBrowser() === 'firefox';
/**
* {@link PeerConnectionManager} manages multiple {@link PeerConnectionV2}s.
* @extends QueueingEventEmitter
* @emits PeerConnectionManager#candidates
* @emits PeerConnectionManager#connectionStateChanged
* @emits PeerConnectionManager#description
* @emits PeerConnectionManager#iceConnectionStateChanged
* @emits PeerConnectionManager#trackAdded
*/
class PeerConnectionManager extends QueueingEventEmitter {
/**
* Construct {@link PeerConnectionManager}.
* @param {EncodingParametersImpl} encodingParameters
* @param {PreferredCodecs} preferredCodecs
* @param {object} options
*/
constructor(encodingParameters, preferredCodecs, options) {
super();
options = Object.assign({
audioContextFactory: isFirefox
? require('../../webaudio/audiocontext')
: null,
PeerConnectionV2
}, options);
const audioContext = options.audioContextFactory
? options.audioContextFactory.getOrCreate(this)
: null;
// NOTE(mroberts): If we're using an AudioContext, we don't need to specify
// `offerToReceiveAudio` in RTCOfferOptions.
const offerOptions = audioContext
? { offerToReceiveVideo: true }
: { offerToReceiveAudio: true, offerToReceiveVideo: true };
Object.defineProperties(this, {
_audioContextFactory: {
value: options.audioContextFactory
},
_closedPeerConnectionIds: {
value: new Set()
},
_configuration: {
writable: true,
value: null
},
_configurationDeferred: {
writable: true,
value: util.defer()
},
_connectionState: {
value: 'new',
writable: true
},
_dummyAudioTrackSender: {
value: audioContext
? new MediaTrackSender(createDummyAudioMediaStreamTrack(audioContext))
: null
},
_encodingParameters: {
value: encodingParameters
},
_iceConnectionState: {
writable: true,
value: 'new'
},
_dataTrackSenders: {
writable: true,
value: new Set()
},
_lastConnectionState: {
value: 'new',
writable: true
},
_lastIceConnectionState: {
writable: true,
value: 'new'
},
_mediaTrackSenders: {
writable: true,
value: new Set()
},
_offerOptions: {
value: offerOptions
},
_peerConnections: {
value: new Map()
},
_preferredCodecs: {
value: preferredCodecs
},
_sessionTimeout: {
value: null,
writable: true
},
_PeerConnectionV2: {
value: options.PeerConnectionV2
}
});
}
setEffectiveAdaptiveSimulcast(effectiveAdaptiveSimulcast) {
this._peerConnections.forEach(pc => pc.setEffectiveAdaptiveSimulcast(effectiveAdaptiveSimulcast));
this._preferredCodecs.video.forEach(cs => {
if ('adaptiveSimulcast' in cs) {
cs.adaptiveSimulcast = effectiveAdaptiveSimulcast;
}
});
}
/**
* A summarized RTCPeerConnectionState across all the
* {@link PeerConnectionManager}'s underlying {@link PeerConnectionV2}s.
* @property {RTCPeerConnectionState}
*/
get connectionState() {
return this._connectionState;
}
/**
* A summarized RTCIceConnectionState across all the
* {@link PeerConnectionManager}'s underlying {@link PeerConnectionV2}s.
* @property {RTCIceConnectionState}
*/
get iceConnectionState() {
return this._iceConnectionState;
}
/**
* Close the {@link PeerConnectionV2}s which are no longer relevant.
* @param {Array<object>} peerConnectionStates
* @returns {this}
*/
_closeAbsentPeerConnections(peerConnectionStates) {
const peerConnectionIds = new Set(peerConnectionStates.map(peerConnectionState => peerConnectionState.id));
this._peerConnections.forEach(peerConnection => {
if (!peerConnectionIds.has(peerConnection.id)) {
peerConnection._close();
}
});
return this;
}
/**
* Get the {@link PeerConnectionManager}'s configuration.
* @private
* @returns {Promise<object>}
*/
_getConfiguration() {
return this._configurationDeferred.promise;
}
/**
* Get or create a {@link PeerConnectionV2}.
* @private
* @param {string} id
* @param {object} [configuration]
* @returns {PeerConnectionV2}
*/
_getOrCreate(id, configuration) {
const self = this;
let peerConnection = this._peerConnections.get(id);
if (!peerConnection) {
const PeerConnectionV2 = this._PeerConnectionV2;
const options = Object.assign({
dummyAudioMediaStreamTrack: this._dummyAudioTrackSender
? this._dummyAudioTrackSender.track
: null,
offerOptions: this._offerOptions
}, this._sessionTimeout ? {
sessionTimeout: this._sessionTimeout
} : {}, configuration);
try {
peerConnection = new PeerConnectionV2(id, this._encodingParameters, this._preferredCodecs, options);
} catch (e) {
throw new MediaConnectionError();
}
this._peerConnections.set(peerConnection.id, peerConnection);
peerConnection.on('candidates', this.queue.bind(this, 'candidates'));
peerConnection.on('description', this.queue.bind(this, 'description'));
peerConnection.on('trackAdded', this.queue.bind(this, 'trackAdded'));
peerConnection.on('stateChanged', function stateChanged(state) {
if (state === 'closed') {
peerConnection.removeListener('stateChanged', stateChanged);
self._dataTrackSenders.forEach(sender => peerConnection.removeDataTrackSender(sender));
self._mediaTrackSenders.forEach(sender => peerConnection.removeMediaTrackSender(sender));
self._peerConnections.delete(peerConnection.id);
self._closedPeerConnectionIds.add(peerConnection.id);
updateConnectionState(self);
updateIceConnectionState(self);
}
});
peerConnection.on('connectionStateChanged', () => updateConnectionState(this));
peerConnection.on('iceConnectionStateChanged', () => updateIceConnectionState(this));
this._dataTrackSenders.forEach(peerConnection.addDataTrackSender, peerConnection);
this._mediaTrackSenders.forEach(peerConnection.addMediaTrackSender, peerConnection);
updateIceConnectionState(this);
}
return peerConnection;
}
/**
* Close all the {@link PeerConnectionV2}s in this {@link PeerConnectionManager}.
* @returns {this}
*/
close() {
this._peerConnections.forEach(peerConnection => {
peerConnection.close();
});
if (this._dummyAudioTrackSender) {
this._dummyAudioTrackSender.stop();
}
if (this._audioContextFactory) {
this._audioContextFactory.release(this);
}
updateIceConnectionState(this);
return this;
}
/**
* Create a new {@link PeerConnectionV2} on this {@link PeerConnectionManager}.
* Then, create a new offer with the newly-created {@link PeerConnectionV2}.
* @return {Promise<this>}
*/
createAndOffer() {
return this._getConfiguration().then(configuration => {
let id;
do {
id = util.makeUUID();
} while (this._peerConnections.has(id));
return this._getOrCreate(id, configuration);
}).then(peerConnection => {
return peerConnection.offer();
}).then(() => {
return this;
});
}
/**
* Get the {@link DataTrackReceiver}s and {@link MediaTrackReceiver}s of all
* the {@link PeerConnectionV2}s.
* @returns {Array<DataTrackReceiver|MediaTrackReceiver>} trackReceivers
*/
getTrackReceivers() {
return util.flatMap(this._peerConnections, peerConnection => peerConnection.getTrackReceivers());
}
/**
* Get the states of all {@link PeerConnectionV2}s.
* @returns {Array<object>}
*/
getStates() {
const peerConnectionStates = [];
this._peerConnections.forEach(peerConnection => {
const peerConnectionState = peerConnection.getState();
if (peerConnectionState) {
peerConnectionStates.push(peerConnectionState);
}
});
return peerConnectionStates;
}
/**
* Set the {@link PeerConnectionManager}'s configuration.
* @param {object} configuration
* @returns {this}
*/
setConfiguration(configuration) {
if (this._configuration) {
this._configurationDeferred = util.defer();
this._peerConnections.forEach(peerConnection => {
peerConnection.setConfiguration(configuration);
});
}
this._configuration = configuration;
this._configurationDeferred.resolve(configuration);
return this;
}
/**
* Set the ICE reconnect timeout period for all {@link PeerConnectionV2}s.
* @param {number} period - Period in milliseconds.
* @returns {this}
*/
setIceReconnectTimeout(period) {
if (this._sessionTimeout === null) {
this._peerConnections.forEach(peerConnection => {
peerConnection.setIceReconnectTimeout(period);
});
this._sessionTimeout = period;
}
return this;
}
/**
* Set the {@link DataTrackSender}s and {@link MediaTrackSender}s on the
* {@link PeerConnectionManager}'s underlying {@link PeerConnectionV2}s.
* @param {Array<DataTrackSender|MediaTrackSender>} trackSenders
* @returns {this}
*/
setTrackSenders(trackSenders) {
const dataTrackSenders = new Set(trackSenders.filter(trackSender => trackSender.kind === 'data'));
const mediaTrackSenders = new Set(trackSenders
.filter(trackSender => trackSender && (trackSender.kind === 'audio' || trackSender.kind === 'video')));
const changes = getTrackSenderChanges(this, dataTrackSenders, mediaTrackSenders);
this._dataTrackSenders = dataTrackSenders;
this._mediaTrackSenders = mediaTrackSenders;
applyTrackSenderChanges(this, changes);
return this;
}
/**
* Update the {@link PeerConnectionManager}.
* @param {Array<object>} peerConnectionStates
* @param {boolean} [synced=false]
* @returns {Promise<this>}
*/
update(peerConnectionStates, synced = false) {
if (synced) {
this._closeAbsentPeerConnections(peerConnectionStates);
}
return this._getConfiguration().then(configuration => {
return Promise.all(peerConnectionStates.map(peerConnectionState => {
if (this._closedPeerConnectionIds.has(peerConnectionState.id)) {
return null;
}
const peerConnection = this._getOrCreate(peerConnectionState.id, configuration);
return peerConnection.update(peerConnectionState);
}));
}).then(() => {
return this;
});
}
/**
* Get the {@link PeerConnectionManager}'s media statistics.
* @returns {Promise.<Map<PeerConnectionV2#id, StandardizedStatsResponse>>}
*/
getStats() {
const peerConnections = Array.from(this._peerConnections.values());
return Promise.all(peerConnections.map(peerConnection => peerConnection.getStats().then(response => [
peerConnection.id,
response
]))).then(responses => new Map(responses));
}
}
/**
* Create a dummy audio MediaStreamTrack with the given AudioContext.
* @private
* @param {AudioContext} audioContext
* @return {MediaStreamTrack}
*/
function createDummyAudioMediaStreamTrack(audioContext) {
const mediaStreamDestination = audioContext.createMediaStreamDestination();
return mediaStreamDestination.stream.getAudioTracks()[0];
}
/**
* @event {PeerConnectionManager#candidates}
* @param {object} candidates
*/
/**
* @event {PeerConnectionManager#connectionStateChanged}
*/
/**
* @event {PeerConnectionManager#description}
* @param {object} description
*/
/**
* @event {PeerConnectionManager#iceConnectionStateChanged}
*/
/**
* @event {PeerConnectionManager#trackAdded}
* @param {MediaStreamTrack|DataTrackReceiver} mediaStreamTrackOrDataTrackReceiver
*/
/**
* Apply {@link TrackSenderChanges}.
* @param {PeerConnectionManager} peerConnectionManager
* @param {TrackSenderChanges} changes
* @returns {void}
*/
function applyTrackSenderChanges(peerConnectionManager, changes) {
if (changes.data.add.size
|| changes.data.remove.size
|| changes.media.add.size
|| changes.media.remove.size) {
peerConnectionManager._peerConnections.forEach(peerConnection => {
changes.data.remove.forEach(peerConnection.removeDataTrackSender, peerConnection);
changes.media.remove.forEach(peerConnection.removeMediaTrackSender, peerConnection);
changes.data.add.forEach(peerConnection.addDataTrackSender, peerConnection);
changes.media.add.forEach(peerConnection.addMediaTrackSender, peerConnection);
if (changes.media.add.size
|| changes.media.remove.size
|| (changes.data.add.size && !peerConnection.isApplicationSectionNegotiated)) {
peerConnection.offer();
}
});
}
}
/**
* @interface DataTrackSenderChanges
* @property {Set<DataTrackSender>} add
* @property {Set<DataTrackSender>} remove
*/
/**
* Get the {@Link DataTrackSender} changes.
* @param {PeerConnectionManager} peerConnectionManager
* @param {Array<DataTrackSender>} dataTrackSenders
* @returns {DataTrackSenderChanges} changes
*/
function getDataTrackSenderChanges(peerConnectionManager, dataTrackSenders) {
const dataTrackSendersToAdd = util.difference(dataTrackSenders, peerConnectionManager._dataTrackSenders);
const dataTrackSendersToRemove = util.difference(peerConnectionManager._dataTrackSenders, dataTrackSenders);
return {
add: dataTrackSendersToAdd,
remove: dataTrackSendersToRemove
};
}
/**
* @interface TrackSenderChanges
* @property {DataTrackSenderChanges} data
* @property {MediaTrackSenderChanges} media
*/
/**
* Get {@link DataTrackSender} and {@link MediaTrackSender} changes.
* @param {PeerConnectionManager} peerConnectionManager
* @param {Array<DataTrackSender>} dataTrackSenders
* @param {Array<MediaTrackSender>} mediaTrackSenders
* @returns {TrackSenderChanges} changes
*/
function getTrackSenderChanges(peerConnectionManager, dataTrackSenders, mediaTrackSenders) {
return {
data: getDataTrackSenderChanges(peerConnectionManager, dataTrackSenders),
media: getMediaTrackSenderChanges(peerConnectionManager, mediaTrackSenders)
};
}
/**
* @interface MediaTrackSenderChanges
* @property {Set<MediaTrackSender>} add
* @property {Set<MediaTrackSender>} remove
*/
/**
* Get the {@link MediaTrackSender} changes.
* @param {PeerConnectionManager} peerConnectionManager
* @param {Array<MediaTrackSender>} mediaTrackSenders
* @returns {MediaTrackSenderChanges} changes
*/
function getMediaTrackSenderChanges(peerConnectionManager, mediaTrackSenders) {
const mediaTrackSendersToAdd = util.difference(mediaTrackSenders, peerConnectionManager._mediaTrackSenders);
const mediaTrackSendersToRemove = util.difference(peerConnectionManager._mediaTrackSenders, mediaTrackSenders);
return {
add: mediaTrackSendersToAdd,
remove: mediaTrackSendersToRemove
};
}
/**
* This object maps RTCIceConnectionState and RTCPeerConnectionState values to a "rank".
*/
const toRank = {
new: 0,
checking: 1,
connecting: 2,
connected: 3,
completed: 4,
disconnected: -1,
failed: -2,
closed: -3
};
/**
* This object maps "rank" back to RTCIceConnectionState or RTCPeerConnectionState values.
*/
let fromRank;
/**
* `Object.keys` is not supported in older browsers, so we can't just
* synchronously call it in this module; we need to defer invoking it until we
* know we're in a modern environment (i.e., anything that supports WebRTC).
* @returns {object} fromRank
*/
function createFromRank() {
return Object.keys(toRank).reduce((fromRank, state) => {
return Object.assign(fromRank, { [toRank[state]]: state });
}, {});
}
/**
* Summarize RTCIceConnectionStates or RTCPeerConnectionStates.
* @param {Array<RTCIceConnectionState>|Array<RTCPeerConnectionState>} states
* @returns {RTCIceConnectionState|RTCPeerConnectionState} summary
*/
function summarizeIceOrPeerConnectionStates(states) {
if (!states.length) {
return 'new';
}
fromRank = fromRank || createFromRank();
return states.reduce((state1, state2) => {
return fromRank[Math.max(toRank[state1], toRank[state2])];
});
}
/**
* Update the {@link PeerConnectionManager}'s `iceConnectionState`, and emit an
* "iceConnectionStateChanged" event, if necessary.
* @param {PeerConnectionManager} pcm
* @returns {void}
*/
function updateIceConnectionState(pcm) {
pcm._lastIceConnectionState = pcm.iceConnectionState;
pcm._iceConnectionState = summarizeIceOrPeerConnectionStates(
[...pcm._peerConnections.values()].map(pcv2 => pcv2.iceConnectionState));
if (pcm.iceConnectionState !== pcm._lastIceConnectionState) {
pcm.emit('iceConnectionStateChanged');
}
}
/**
* Update the {@link PeerConnectionManager}'s `connectionState`, and emit a
* "connectionStateChanged" event, if necessary.
* @param {PeerConnectionManager} pcm
* @returns {void}
*/
function updateConnectionState(pcm) {
pcm._lastConnectionState = pcm.connectionState;
pcm._connectionState = summarizeIceOrPeerConnectionStates(
[...pcm._peerConnections.values()].map(pcv2 => pcv2.connectionState));
if (pcm.connectionState !== pcm._lastConnectionState) {
pcm.emit('connectionStateChanged');
}
}
module.exports = PeerConnectionManager;