twilio-conferencing-core-engine
Version:
twilio-conferencing-core-engine
1,514 lines (1,329 loc) • 1.25 MB
JavaScript
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
'use strict';
const ADJECTIVES = [
'Abrasive', 'Brash', 'Callous', 'Daft', 'Eccentric', 'Fiesty', 'Golden',
'Holy', 'Ignominious', 'Joltin', 'Killer', 'Luscious', 'Mushy', 'Nasty',
'OldSchool', 'Pompous', 'Quiet', 'Rowdy', 'Sneaky', 'Tawdry',
'Unique', 'Vivacious', 'Wicked', 'Xenophobic', 'Yawning', 'Zesty'
];
const FIRST_NAMES = [
'Anna', 'Bobby', 'Cameron', 'Danny', 'Emmett', 'Frida', 'Gracie', 'Hannah',
'Isaac', 'Jenova', 'Kendra', 'Lando', 'Mufasa', 'Nate', 'Owen', 'Penny',
'Quincy', 'Roddy', 'Samantha', 'Tammy', 'Ulysses', 'Victoria', 'Wendy',
'Xander', 'Yolanda', 'Zelda'
];
const LAST_NAMES = [
'Anchorage', 'Berlin', 'Cucamonga', 'Davenport', 'Essex', 'Fresno',
'Gunsight', 'Hanover', 'Indianapolis', 'Jamestown', 'Kane', 'Liberty',
'Minneapolis', 'Nevis', 'Oakland', 'Portland', 'Quantico', 'Raleigh',
'SaintPaul', 'Tulsa', 'Utica', 'Vail', 'Warsaw', 'XiaoJin', 'Yale',
'Zimmerman'
];
function randomItem(array) {
var randomIndex = Math.floor(Math.random() * array.length);
return array[randomIndex];
}
function randomName() {
return [ADJECTIVES, FIRST_NAMES, LAST_NAMES]
.map(randomItem)
.join(' ');
}
/**
* Generate room credentials with accessToken and room identity
*@param {string} accessToken - JWT string access token fetched granted by Twilio for the user
* @param {string} [identity] identity to use, if not specified use random name.
* @returns {object}
*/
function getRoomCredentials(accessToken, identity = randomName()) {
return { identity, accessToken };
}
module.exports = getRoomCredentials;
},{}],2:[function(require,module,exports){
"use strict";
/**
* Add URL parameters to the web app URL.
* @param params - the parameters to add
*/
function addUrlParams(params) {
const combinedParams = Object.assign(getUrlParams(), params);
const serializedParams = Object.entries(combinedParams)
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
.join("&");
history.pushState(null, "", `${location.pathname}?${serializedParams}`);
}
/**
* Generate an object map of URL parameters.
* @returns {*}
*/
function getUrlParams() {
const serializedParams = location.search.split("?")[1];
const nvpairs = serializedParams ? serializedParams.split("&") : [];
return nvpairs.reduce((params, nvpair) => {
const [name, value] = nvpair.split("=");
params[name] = decodeURIComponent(value);
return params;
}, {});
}
/**
* Whether the web app is running on a mobile browser.
* @type {boolean}
*/
const isMobile = (() => {
//ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
var hasTouchScreen = false;
if ("maxTouchPoints" in navigator) {
hasTouchScreen = navigator.maxTouchPoints > 0;
} else if ("msMaxTouchPoints" in navigator) {
hasTouchScreen = navigator.msMaxTouchPoints > 0;
} else {
var mQ = window.matchMedia && matchMedia("(pointer:coarse)");
if (mQ && mQ.media === "(pointer:coarse)") {
hasTouchScreen = !!mQ.matches;
} else if ("orientation" in window) {
hasTouchScreen = true; // deprecated, but good fallback
} else {
// Only as a last resort, fall back to user agent sniffing
var UA = navigator.userAgent;
hasTouchScreen =
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
}
}
return hasTouchScreen;
})();
const isValidFirefoxForScreenshare = () => {
var supportedConstraints = navigator.mediaDevices.getSupportedConstraints();
var mediaSourceSupport =
supportedConstraints && !!supportedConstraints.mediaSource;
var matchData = navigator.userAgent.match(/Firefox\/(\d+)/);
var firefoxVersion = 0;
if (matchData && matchData[1]) {
firefoxVersion = parseInt(matchData[1], 10);
}
return mediaSourceSupport && firefoxVersion >= 52;
};
const getFirefoxVersion = () => {
var matchData = navigator.userAgent.match(/Firefox\/(\d+)/);
return matchData && parseInt(matchData[1], 10);
};
/**
* Will return true for Chrome, and Chromium based browsers (ex Edge v42+)
*/
const isValidChromeForScreenshare = () => {
var matchData = navigator.userAgent.match(/Chrome\/(\d+)/);
var chromeVersion = 0;
if (matchData && matchData[1]) {
chromeVersion = parseInt(matchData[1], 10);
}
return chromeVersion >= 71;
};
const isValidSafariForScreenshare = () => {
if (navigator.userAgent.includes("Safari")) {
var matchData = navigator.userAgent.match(/Version\/(\d+)/);
var safariVersion = 0;
if (matchData && matchData[1]) {
safariVersion = parseFloat(matchData[1]);
}
return safariVersion >= 12.2;
}
return false;
};
/**
* Supported browsers: Chrome (72+), Firefox (52+), Safari (12.2+), Edge (42+)
*/
const canScreenshare = () => {
return (
!isMobile &&
(isValidChromeForScreenshare() ||
isValidFirefoxForScreenshare() ||
isValidSafariForScreenshare())
);
};
/**
* Will return true for all screen cast supported browsers except for Firefox (v52 - v65)
*/
const supportsGetDisplayMedia = () => {
var firefoxVersion = getFirefoxVersion();
return (
canScreenshare() &&
(firefoxVersion === null || firefoxVersion >= 66) &&
navigator.mediaDevices &&
navigator.mediaDevices.getDisplayMedia
);
};
module.exports = {
addUrlParams,
getUrlParams,
isMobile,
canScreenshare,
supportsGetDisplayMedia,
};
},{}],3:[function(require,module,exports){
module.exports={
"bandwidthProfile": {
"video": {
"dominantSpeakerPriority": "high",
"mode": "collaboration",
"renderDimensions": {
"high": { "height": 720, "width": 1280 },
"standard": { "height": 90, "width": 160 }
}
}
},
"dominantSpeaker": true,
"logLevel": "debug",
"maxAudioBitrate": 16000,
"preferredVideoCodecs": [{ "codec": "VP8", "simulcast": true }],
"video": {
"height": 720,
"frameRate": 24,
"width": 1280
},
"audio" : {}
}
},{}],4:[function(require,module,exports){
"use strict";
const Video = require("twilio-video");
const {
isMobile,
canScreenshare,
getUrlParams,
addUrlParams,
} = require("./browser");
const Media = require("./selectmedia");
const attachMicVolumeListener = require("./miclevel");
const { createScreenTrack } = require("./screenshare");
const getRoomCredentials = require("./Util/getRoomCredentials");
const defaultConnectOptions = require("./defaultConnectOptions");
const conferenceEvents = {
RoomConnected: "RoomConnected",
RoomCompleted: "RoomCompleted",
ParticipantConnected: "ParticipantConnected",
ParticipantDisconnected: "ParticipantDisconnected",
ParticipantSubscribedTrack: "ParticipantSubscribedTrack",
ParticipantUnsubscribedTrack: "ParticipantUnsubscribedTrack",
DominantSpeakerChanged: "DominantSpeakerChanged",
ExistingParticipantsReportingComplete:
"ExistingParticipantsReportingComplete",
ErrorOccured: "ErrorOccured",
};
const MediaType = { Video: "video", Audio: "audio" };
const TwilioVideoConferenceEngine = function () {
var eventCallbacks, currentRoom, currentConnectOptions, currentScreenTrack;
// For mobile browsers, limit the maximum incoming video bitrate to 2.5 Mbps.
if (isMobile) {
defaultConnectOptions.bandwidthProfile.video.maxSubscriptionBitrate = 2500000;
}
// On mobile browsers, there is the possibility of not getting any media even
// after the user has given permission, most likely due to some other app reserving
// the media device. So, we make sure users always test their media devices before
// joining the Room. For more best practices, please refer to the following guide:
// https://www.twilio.com/docs/video/build-js-video-application-recommendations-and-best-practices
let deviceIds;
const fetchDeviceIds = () => {
deviceIds = {
audio: localStorage.getItem("audioDeviceId"),
video: localStorage.getItem("videoDeviceId"),
};
};
/**
*
* @param {string} eventType - the type of event to report
* @param {any} args - the info associated with the event
*/
const notifyOfEvent = (eventType, args) => {
//Invoke
var callback = eventCallbacks[`on${eventType}`];
if (callback) callback(args);
};
/**
*
* @param {any} participant - the user who disconnected
*/
const participantDisconnected = (participant) => {
notifyOfEvent(conferenceEvents.ParticipantDisconnected, participant);
};
const dominantSpeakerChanged = (participant) => {
notifyOfEvent(conferenceEvents.DominantSpeakerChanged, participant);
};
/**
*
* @param {any} track - the media track that was subscribed
*/
const trackSubscribed = (track) => {
notifyOfEvent(conferenceEvents.ParticipantSubscribedTrack, track);
};
/**
*
* @param {any} track - the media track that was unsubscribed
*/
const trackUnsubscribed = (track) => {
//Setup track unmute event
if (track.on) {
track.on("enabled", () =>
trackSubscribed({ track: publication.track, participant })
);
}
notifyOfEvent(conferenceEvents.ParticipantUnsubscribedTrack, track);
};
/**
*
* @param {any} track - media track
*/
const setupTrackMuteEvents = (track) => {
if (track && track.on) {
track.on("disabled", () => {
trackUnsubscribed({ track: publication.track, participant });
});
track.on("enabled", () => {
trackSubscribed({ track: publication.track, participant });
});
}
};
/**
*
* @param {any} participant - the user who connected
*/
const participantConnected = (participant, isRemote = true) => {
//Fire callback
notifyOfEvent(conferenceEvents.ParticipantConnected, participant);
//subscribe to tracks already published by participant
participant.tracks.forEach((publication) => {
if (publication.isSubscribed || publication.track) {
if (isRemote && publication.track) {
setupTrackMuteEvents(publication.track);
}
trackSubscribed({ track: publication.track, participant });
}
publication.on("subscribed", (track) => {
if (isRemote) {
setupTrackMuteEvents(track);
}
trackSubscribed({ track, participant });
});
// Once the TrackPublication is unsubscribed from, detach the Track from the DOM.
publication.on("unsubscribed", (track) => {
trackUnsubscribed({ track, participant });
});
});
//listen to any future track subscribe/unsubscribe events by the participant - LOCAL
participant.on("trackSubscribed", (track) => {
if (isRemote) {
setupTrackMuteEvents(track);
}
trackSubscribed({ track, participant });
});
participant.on("trackUnsubscribed", (track) =>
trackUnsubscribed({ track, participant })
);
// Handle the TrackPublications that will be published by the Participant later.
participant.on("trackPublished", (publication) => {
trackSubscribed({ track: publication.track, participant });
});
participant.on("trackUnpublished", (publication) => {
trackUnsubscribed({ track: publication.track, participant });
});
};
/**
*
* @param {string} accessToken user identity jwt object generated by twilio token generator
* @param {string} roomName
* @param {object} connectOptions
*/
async function joinRoom(accessToken, roomName, connectOptions) {
if (typeof accessToken === "undefined")
throw new Error("User access token not supplied");
if (typeof roomName === "undefined")
throw new Error("Room name is not supplied");
if (typeof eventCallbacks === "undefined")
throw new Error(
"Event callbacks not registered, please register and initialize event callbacks"
);
connectOptions = connectOptions || defaultConnectOptions;
connectOptions.name = roomName;
if (typeof deviceIds === "undefined") fetchDeviceIds();
// Add the specified audio device ID to ConnectOptions.
if (connectOptions.audio)
connectOptions.audio.deviceId = { exact: deviceIds.audio };
// Add the specified video device ID to ConnectOptions.
if (connectOptions.video)
connectOptions.video.deviceId = { exact: deviceIds.video };
currentConnectOptions = connectOptions;
//async
return new Promise((resolve, reject) => {
Video.connect(accessToken, connectOptions)
.then((room) => {
notifyOfEvent(conferenceEvents.RoomConnected, room);
currentRoom = room;
localStorage.setItem("userName", accessToken);
//Local participant
participantConnected(room.localParticipant, false);
//Remote participants already connected
room.participants.forEach(participantConnected);
room.on("participantConnected", participantConnected);
room.on("participantDisconnected", participantDisconnected);
room.on("trackSubscribed", (track) => {
setupTrackMuteEvents(track);
});
//Let the client know that all participants events have been initialized
notifyOfEvent(
conferenceEvents.ExistingParticipantsReportingComplete,
room
);
room.on("dominantSpeakerChanged", dominantSpeakerChanged);
room.once("disconnected", (error) => {
participantDisconnected(room.localParticipant);
room.participants.forEach(participantDisconnected);
if (!error) notifyOfEvent(conferenceEvents.RoomCompleted, room);
else notifyOfEvent(conferenceEvents.ErrorOccured, error);
});
resolve(room);
})
.catch((error) => {
reject(error);
});
});
}
/**
* @param {string} token
* @param {object} event_callbacks
*/
var init = (event_callbacks) => {
if (typeof event_callbacks === "undefined")
throw new Error("Init without Event callbacks");
eventCallbacks = event_callbacks;
//Return an error if video is not supported
if (!Video.isSupported)
return new Error("Sorry, this browser is not supported by Twilio Video");
};
/**
*
* @param {function} onRoomConnected
* @param {function} onRoomDisconnected
* @param {function} onParticipantConnected
* @param {function} onParticipantDisconnected
* @param {function} onParticipantTrackSubscribed
* @param {function} onParticipantTrackUnsubscribed
* @param {function} onDominantSpeakerChanged
* @param {function} onExistingParticipantsReportingComplete
* @param {function} onErrorOccured
* @return {object}
*/
const registerCallbacks = (
onRoomConnected,
onRoomDisconnected,
onParticipantConnected,
onParticipantDisconnected,
onParticipantSubscribedTrack,
onParticipantUnsubscribedTrack,
onDominantSpeakerChanged,
onExistingParticipantsReportingComplete,
onErrorOccured
) => {
return {
onRoomConnected: (room) => {
if (typeof onRoomConnected !== "undefined") onRoomConnected(room);
},
onRoomDisconnected: (room) => {
if (typeof onRoomDisconnected !== "undefined") onRoomDisconnected(room);
},
onParticipantConnected: (participant) => {
if (typeof onParticipantConnected !== "undefined")
onParticipantConnected(participant);
},
onParticipantDisconnected: (participant) => {
if (typeof onParticipantDisconnected !== "undefined")
onParticipantDisconnected(participant);
},
onParticipantSubscribedTrack: (track) => {
if (typeof onParticipantSubscribedTrack !== "undefined")
onParticipantSubscribedTrack(track);
},
onParticipantUnsubscribedTrack: (track) => {
if (typeof onParticipantUnsubscribedTrack !== "undefined")
onParticipantUnsubscribedTrack(track);
},
onDominantSpeakerChanged: (participant) => {
if (typeof onDominantSpeakerChanged !== "undefined")
onDominantSpeakerChanged(participant);
},
onExistingParticipantsReportingComplete: (room) => {
if (typeof onExistingParticipantsReportingComplete !== "undefined")
onExistingParticipantsReportingComplete(room);
},
onErrorOccured: (error) => {
if (typeof onErrorOccured !== "undefined") onErrorOccured(error);
},
};
};
const turnOffMyVideo = () => {
if (
!currentRoom ||
!currentRoom.localParticipant ||
!currentRoom.localParticipant.videoTracks
)
return;
var localVideoTracks = Array.from(
currentRoom.localParticipant.videoTracks.values()
);
if (localVideoTracks.length) {
var localVideoTrack = localVideoTracks[0].track;
if (localVideoTrack) {
currentRoom.localParticipant.unpublishTrack(localVideoTrack);
localVideoTrack.stop();
//https://github.com/twilio/twilio-video.js/issues/656#issuecomment-499207086
//This event is not fired by default for a local participant
//Force fire
trackUnsubscribed({
track: { kind: MediaType.Video },
participant: currentRoom.localParticipant,
});
}
}
};
const turnOffMyAudio = () => {
if (
!currentRoom ||
!currentRoom.localParticipant ||
!currentRoom.localParticipant.audioTracks
)
return;
var localAudioTracks = Array.from(
currentRoom.localParticipant.audioTracks.values()
);
if (localAudioTracks.length) {
var localAudioTrack = localAudioTracks[0].track;
if (localAudioTrack) {
currentRoom.localParticipant.unpublishTrack(localAudioTrack);
localAudioTrack.stop();
//https://github.com/twilio/twilio-video.js/issues/656#issuecomment-499207086
//This event is not fired by default for a local participant
//Force fire
trackUnsubscribed({
track: { kind: MediaType.Audio },
participant: currentRoom.localParticipant,
});
}
}
};
/**
*
* @param {string} roomName - the room being quit
*/
const leaveRoom = (roomName) => {
//turn off local tracks
turnOffMyVideo();
turnOffMyAudio();
//call participant disconnected callback for local participant
notifyOfEvent(
conferenceEvents.ParticipantDisconnected,
currentRoom.localParticipant
);
//call participant disconnected callback for all remote participants
currentRoom.participants.forEach((participant) => {
notifyOfEvent(conferenceEvents.ParticipantDisconnected, participant);
});
var identity = localStorage.getItem("userName");
//TODO remove this in prod
console.log(`${identity} disconnected from room ${roomName}`);
localStorage.removeItem("userName");
if (currentRoom) currentRoom.disconnect();
currentRoom = null;
};
/**
*
* @param {function} render - the function to call with the video media stream
*/
const selectDefaultVideoSource = (render) => {
Media.selectDefaultMedia(MediaType.Video, render);
};
async function turnOnMyVideo() {
if (typeof currentRoom === "undefined") return;
if (currentConnectOptions.video && currentConnectOptions.video.deviceId) {
var localVideoTrack = await Video.createLocalVideoTrack(
currentConnectOptions.video
);
await currentRoom.localParticipant.publishTrack(localVideoTrack);
}
}
/**
*
* @param {function} render - the function to call with the audio media stream
*/
const selectDefaultAudioSource = (render) => {
Media.selectDefaultMedia(MediaType.Audio, render);
};
async function turnOnMyAudio() {
if (typeof currentRoom === "undefined") return;
if (currentConnectOptions.audio && currentConnectOptions.audio.deviceId) {
var localAudioTrack = await Video.createLocalAudioTrack(
currentConnectOptions.audio
);
await currentRoom.localParticipant.publishTrack(localAudioTrack);
}
}
/**
*
* @param {string} videoDeviceId - the id of the video device
* @param {function} render - - the function to call with the video media stream
*/
const changeVideoSource = (videoDeviceId, render) => {
Media.applyInputDevice(MediaType.Video, videoDeviceId, render);
};
/**
*
* @param {string} audioDeviceId - the id of the audio device
* @param {function} render - - the function to call with the audio media stream
*/
const changeAudioSource = (audioDeviceId, render) => {
Media.applyInputDevice(MediaType.Audio, audioDeviceId, render);
};
/**
* @returns {Promise<MediaDeviceInfo[]>} the list of video media devices
*/
async function listAllVideoDevices() {
return await Media.getInputDevices(MediaType.Video);
}
/**
* @returns {Promise<MediaDeviceInfo[]>} the list of audio media devices
*/
async function listAllAudioDevices() {
return await Media.getInputDevices(MediaType.Audio);
}
/**
* Create a LocalVideoTrack for your screen. You can then share it
* with other Participants in the Room.
* @param {number} height - Desired vertical resolution in pixels
* @param {number} width - Desired horizontal resolution in pixels
* @returns {Promise<LocalVideoTrack>}
*/
const startScreenShare = async (height, width) => {
width = width || currentConnectOptions.video.width;
height = height || currentConnectOptions.video.height;
createScreenTrack(height, width)
.then((track) => {
currentScreenTrack = track;
debugger;
if (currentRoom.localParticipant.videoTracks[0]) {
currentRoom.localParticipant.videoTracks[0].setPriority("low");
}
currentRoom.localParticipant.publishTrack(track, {
priority: "high", //choose among 'high', 'standard' or 'low'
});
})
.catch((e) => {
return Promise.reject(e);
});
};
const stopScreenShare = () => {
if (currentScreenTrack) {
currentScreenTrack.stop();
currentScreenTrack = null;
debugger;
if (currentRoom.localParticipant.videoTracks[0]) {
currentRoom.localParticipant.videoTracks[0].setPriority("high");
}
}
};
const isBrowserSupported = () => {
return Video.isSupported;
};
const assignDefaultAudioInputDeviceId = (audioDeviceId) => {
if (localStorage) localStorage.setItem("audioDeviceId", audioDeviceId);
};
const assignDefaultVideoInputDeviceId = (videoDeviceId) => {
if (localStorage) localStorage.setItem("videoDeviceId", videoDeviceId);
};
//Public API
return {
registerCallbacks,
init,
joinRoom,
leaveRoom,
turnOnMyVideo,
turnOnMyAudio,
turnOffMyVideo,
turnOffMyAudio,
changeVideoSource,
changeAudioSource,
listAllVideoDevices,
listAllAudioDevices,
startScreenShare,
stopScreenShare,
getRoomCredentials,
attachMicVolumeListener,
selectDefaultVideoSource,
selectDefaultAudioSource,
assignDefaultAudioInputDeviceId,
assignDefaultVideoInputDeviceId,
isMobile: isMobile,
getUrlParams,
addUrlParams,
isBrowserSupported,
canScreenshare,
};
};
module.exports = { CoreConferenceEngine: new TwilioVideoConferenceEngine() };
},{"./Util/getRoomCredentials":1,"./browser":2,"./defaultConnectOptions":3,"./miclevel":5,"./screenshare":195,"./selectmedia":196,"twilio-video":62}],5:[function(require,module,exports){
"use strict";
/**
* Calculate the root mean square (RMS) of the given array.
* @param samples
* @returns {number} the RMS value
*/
function rootMeanSquare(samples) {
const sumSq = samples.reduce((sumSq, sample) => sumSq + sample * sample, 0);
return Math.sqrt(sumSq / samples.length);
}
let audioContext;
const getAudioContext = () => {
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioContext = audioContext || AudioContext ? new AudioContext() : null;
return audioContext;
};
/**
* Poll the microphone's input level.
* @param stream - the MediaStream representing the microphone
* @param maxLevel - the calculated level should be in the range [0 - maxLevel]
* @param onLevel - called when the input level changes
*/
function micLevel(stream, maxLevel, onLevel) {
var audioContext = getAudioContext();
if (typeof audioContext !== "undefined" && audioContext !== null)
audioContext.resume().then(() => {
const analyser = audioContext.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.5;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const samples = new Uint8Array(analyser.frequencyBinCount);
const track = stream.getTracks()[0];
let level = null;
requestAnimationFrame(function checkLevel() {
analyser.getByteFrequencyData(samples);
const rms = rootMeanSquare(samples);
const log2Rms = rms && Math.log2(rms);
const newLevel = Math.ceil((maxLevel * log2Rms) / 8);
if (level !== newLevel) {
level = newLevel;
onLevel(level);
}
requestAnimationFrame(
track.readyState === "ended" ? () => onLevel(0) : checkLevel
);
});
});
}
module.exports = micLevel;
},{}],6:[function(require,module,exports){
'use strict';
var flatMap = require('./util').flatMap;
var guessBrowser = require('./util').guessBrowser;
var guessBrowserVersion = require('./util').guessBrowserVersion;
var getSdpFormat = require('./util/sdp').getSdpFormat;
var guess = guessBrowser();
var guessVersion = guessBrowserVersion();
var isChrome = guess === 'chrome';
var isFirefox = guess === 'firefox';
var isSafari = guess === 'safari';
var chromeMajorVersion = isChrome ? guessVersion.major : null;
var CHROME_LEGACY_MAX_AUDIO_LEVEL = 32767;
/**
* Get the standardized {@link RTCPeerConnection} statistics.
* @param {RTCPeerConnection} peerConnection
* @param {object} [options] - Used for testing
* @returns {Promise.<StandardizedStatsResponse>}
*/
function getStats(peerConnection, options) {
if (!(peerConnection && typeof peerConnection.getStats === 'function')) {
return Promise.reject(new Error('Given PeerConnection does not support getStats'));
}
return _getStats(peerConnection, options);
}
/**
* getStats() implementation.
* @param {RTCPeerConnection} peerConnection
* @param {object} [options] - Used for testing
* @returns {Promise.<StandardizedStatsResponse>}
*/
function _getStats(peerConnection, options) {
var localAudioTracks = getTracks(peerConnection, 'audio', 'local');
var localVideoTracks = getTracks(peerConnection, 'video', 'local');
var remoteAudioTracks = getTracks(peerConnection, 'audio');
var remoteVideoTracks = getTracks(peerConnection, 'video');
var statsResponse = {
activeIceCandidatePair: null,
localAudioTrackStats: [],
localVideoTrackStats: [],
remoteAudioTrackStats: [],
remoteVideoTrackStats: []
};
var trackStatsPromises = flatMap([
[localAudioTracks, 'localAudioTrackStats', false],
[localVideoTracks, 'localVideoTrackStats', false],
[remoteAudioTracks, 'remoteAudioTrackStats', true],
[remoteVideoTracks, 'remoteVideoTrackStats', true]
], function(triple) {
var tracks = triple[0];
var statsArrayName = triple[1];
var isRemote = triple[2];
return tracks.map(function(track) {
return getTrackStats(peerConnection, track, Object.assign({
isRemote: isRemote
}, options)).then(function(trackStatsArray) {
trackStatsArray.forEach(function(trackStats) {
trackStats.trackId = track.id;
statsResponse[statsArrayName].push(trackStats);
});
});
});
});
return Promise.all(trackStatsPromises).then(function() {
return getActiveIceCandidatePairStats(peerConnection, options);
}).then(function(activeIceCandidatePairStatsReport) {
statsResponse.activeIceCandidatePair = activeIceCandidatePairStatsReport;
return statsResponse;
});
}
/**
* Generate the {@link StandardizedActiveIceCandidatePairStatsReport} for the
* {@link RTCPeerConnection}.
* @param {RTCPeerConnection} peerConnection
* @param {object} [options]
* @returns {Promise<StandardizedActiveIceCandidatePairStatsReport>}
*/
function getActiveIceCandidatePairStats(peerConnection, options) {
options = options || {};
if (typeof options.testForChrome !== 'undefined' || isChrome
|| typeof options.testForSafari !== 'undefined' || isSafari) {
return peerConnection.getStats().then(
standardizeChromeOrSafariActiveIceCandidatePairStats);
}
if (typeof options.testForFirefox !== 'undefined' || isFirefox) {
return peerConnection.getStats().then(standardizeFirefoxActiveIceCandidatePairStats);
}
return Promise.reject(new Error('RTCPeerConnection#getStats() not supported'));
}
/**
* Standardize the active RTCIceCandidate pair's statistics in Chrome or Safari.
* @param {RTCStatsReport} stats
* @returns {?StandardizedActiveIceCandidatePairStatsReport}
*/
function standardizeChromeOrSafariActiveIceCandidatePairStats(stats) {
var activeCandidatePairStats = Array.from(stats.values()).find(function(stat) {
return stat.type === 'candidate-pair' && stat.nominated;
});
if (!activeCandidatePairStats) {
return null;
}
var activeLocalCandidateStats = stats.get(activeCandidatePairStats.localCandidateId);
var activeRemoteCandidateStats = stats.get(activeCandidatePairStats.remoteCandidateId);
var standardizedCandidateStatsKeys = [
{ key: 'candidateType', type: 'string' },
{ key: 'ip', type: 'string' },
{ key: 'port', type: 'number' },
{ key: 'priority', type: 'number' },
{ key: 'protocol', type: 'string' },
{ key: 'url', type: 'string' }
];
var standardizedLocalCandidateStatsKeys = standardizedCandidateStatsKeys.concat([
{ key: 'deleted', type: 'boolean' },
{ key: 'relayProtocol', type: 'string' }
]);
var standatdizedLocalCandidateStatsReport = activeLocalCandidateStats
? standardizedLocalCandidateStatsKeys.reduce(function(report, keyInfo) {
report[keyInfo.key] = typeof activeLocalCandidateStats[keyInfo.key] === keyInfo.type
? activeLocalCandidateStats[keyInfo.key]
: keyInfo.key === 'deleted' ? false : null;
return report;
}, {})
: null;
var standardizedRemoteCandidateStatsReport = activeRemoteCandidateStats
? standardizedCandidateStatsKeys.reduce(function(report, keyInfo) {
report[keyInfo.key] = typeof activeRemoteCandidateStats[keyInfo.key] === keyInfo.type
? activeRemoteCandidateStats[keyInfo.key]
: null;
return report;
}, {})
: null;
return [
{ key: 'availableIncomingBitrate', type: 'number' },
{ key: 'availableOutgoingBitrate', type: 'number' },
{ key: 'bytesReceived', type: 'number' },
{ key: 'bytesSent', type: 'number' },
{ key: 'consentRequestsSent', type: 'number' },
{ key: 'currentRoundTripTime', type: 'number' },
{ key: 'lastPacketReceivedTimestamp', type: 'number' },
{ key: 'lastPacketSentTimestamp', type: 'number' },
{ key: 'nominated', type: 'boolean' },
{ key: 'priority', type: 'number' },
{ key: 'readable', type: 'boolean' },
{ key: 'requestsReceived', type: 'number' },
{ key: 'requestsSent', type: 'number' },
{ key: 'responsesReceived', type: 'number' },
{ key: 'responsesSent', type: 'number' },
{ key: 'retransmissionsReceived', type: 'number' },
{ key: 'retransmissionsSent', type: 'number' },
{ key: 'state', type: 'string', fixup: function(state) { return state === 'inprogress' ? 'in-progress' : state; } },
{ key: 'totalRoundTripTime', type: 'number' },
{ key: 'transportId', type: 'string' },
{ key: 'writable', type: 'boolean' }
].reduce(function(report, keyInfo) {
report[keyInfo.key] = typeof activeCandidatePairStats[keyInfo.key] === keyInfo.type
? (keyInfo.fixup ? keyInfo.fixup(activeCandidatePairStats[keyInfo.key]) : activeCandidatePairStats[keyInfo.key])
: null;
return report;
}, {
localCandidate: standatdizedLocalCandidateStatsReport,
remoteCandidate: standardizedRemoteCandidateStatsReport
});
}
/**
* Standardize the active RTCIceCandidate pair's statistics in Firefox.
* @param {RTCStatsReport} stats
* @returns {?StandardizedActiveIceCandidatePairStatsReport}
*/
function standardizeFirefoxActiveIceCandidatePairStats(stats) {
var activeCandidatePairStats = Array.from(stats.values()).find(function(stat) {
return stat.type === 'candidate-pair' && stat.nominated;
});
if (!activeCandidatePairStats) {
return null;
}
var activeLocalCandidateStats = stats.get(activeCandidatePairStats.localCandidateId);
var activeRemoteCandidateStats = stats.get(activeCandidatePairStats.remoteCandidateId);
var standardizedCandidateStatsKeys = [
{ key: 'candidateType', type: 'string' },
{ key: 'ip', ffKeys: ['address', 'ipAddress'], type: 'string' },
{ key: 'port', ffKeys: ['portNumber'], type: 'number' },
{ key: 'priority', type: 'number' },
{ key: 'protocol', ffKeys: ['transport'], type: 'string' },
{ key: 'url', type: 'string' }
];
var standardizedLocalCandidateStatsKeys = standardizedCandidateStatsKeys.concat([
{ key: 'deleted', type: 'boolean' },
{ key: 'relayProtocol', type: 'string' }
]);
var candidateTypes = {
host: 'host',
peerreflexive: 'prflx',
relayed: 'relay',
serverreflexive: 'srflx'
};
var standatdizedLocalCandidateStatsReport = activeLocalCandidateStats
? standardizedLocalCandidateStatsKeys.reduce(function(report, keyInfo) {
var key = keyInfo.ffKeys && keyInfo.ffKeys.find(function(key) {
return key in activeLocalCandidateStats;
}) || keyInfo.key;
report[keyInfo.key] = typeof activeLocalCandidateStats[key] === keyInfo.type
? key === 'candidateType'
? candidateTypes[activeLocalCandidateStats[key]] || activeLocalCandidateStats[key]
: activeLocalCandidateStats[key]
: key === 'deleted' ? false : null;
return report;
}, {})
: null;
var standardizedRemoteCandidateStatsReport = activeRemoteCandidateStats
? standardizedCandidateStatsKeys.reduce(function(report, keyInfo) {
var key = keyInfo.ffKeys && keyInfo.ffKeys.find(function(key) {
return key in activeRemoteCandidateStats;
}) || keyInfo.key;
report[keyInfo.key] = typeof activeRemoteCandidateStats[key] === keyInfo.type
? key === 'candidateType'
? candidateTypes[activeRemoteCandidateStats[key]] || activeRemoteCandidateStats[key]
: activeRemoteCandidateStats[key]
: null;
return report;
}, {})
: null;
return [
{ key: 'availableIncomingBitrate', type: 'number' },
{ key: 'availableOutgoingBitrate', type: 'number' },
{ key: 'bytesReceived', type: 'number' },
{ key: 'bytesSent', type: 'number' },
{ key: 'consentRequestsSent', type: 'number' },
{ key: 'currentRoundTripTime', type: 'number' },
{ key: 'lastPacketReceivedTimestamp', type: 'number' },
{ key: 'lastPacketSentTimestamp', type: 'number' },
{ key: 'nominated', type: 'boolean' },
{ key: 'priority', type: 'number' },
{ key: 'readable', type: 'boolean' },
{ key: 'requestsReceived', type: 'number' },
{ key: 'requestsSent', type: 'number' },
{ key: 'responsesReceived', type: 'number' },
{ key: 'responsesSent', type: 'number' },
{ key: 'retransmissionsReceived', type: 'number' },
{ key: 'retransmissionsSent', type: 'number' },
{ key: 'state', type: 'string' },
{ key: 'totalRoundTripTime', type: 'number' },
{ key: 'transportId', type: 'string' },
{ key: 'writable', type: 'boolean' }
].reduce(function(report, keyInfo) {
report[keyInfo.key] = typeof activeCandidatePairStats[keyInfo.key] === keyInfo.type
? activeCandidatePairStats[keyInfo.key]
: null;
return report;
}, {
localCandidate: standatdizedLocalCandidateStatsReport,
remoteCandidate: standardizedRemoteCandidateStatsReport
});
}
/**
* Get local/remote audio/video MediaStreamTracks.
* @param {RTCPeerConnection} peerConnection - The RTCPeerConnection
* @param {string} kind - 'audio' or 'video'
* @param {string} [localOrRemote] - 'local' or 'remote'
* @returns {Array<MediaStreamTrack>}
*/
function getTracks(peerConnection, kind, localOrRemote) {
var getSendersOrReceivers = localOrRemote === 'local' ? 'getSenders' : 'getReceivers';
if (peerConnection[getSendersOrReceivers]) {
return peerConnection[getSendersOrReceivers]().map(function(senderOrReceiver) {
return senderOrReceiver.track;
}).filter(function(track) {
return track && track.kind === kind;
});
}
var getStreams = localOrRemote === 'local' ? 'getLocalStreams' : 'getRemoteStreams';
return flatMap(peerConnection[getStreams](), function(stream) {
var getTracks = kind === 'audio' ? 'getAudioTracks' : 'getVideoTracks';
return stream[getTracks]();
});
}
/**
* Get the standardized statistics for a particular MediaStreamTrack.
* @param {RTCPeerConnection} peerConnection
* @param {MediaStreamTrack} track
* @param {object} [options] - Used for testing
* @returns {Promise.<Array<StandardizedTrackStatsReport>>}
*/
function getTrackStats(peerConnection, track, options) {
options = options || {};
if (typeof options.testForChrome !== 'undefined' || isChrome) {
return chromeOrSafariGetTrackStats(peerConnection, track);
}
if (typeof options.testForFirefox !== 'undefined' || isFirefox) {
return firefoxGetTrackStats(peerConnection, track, options.isRemote);
}
if (typeof options.testForSafari !== 'undefined' || isSafari) {
if (typeof options.testForSafari !== 'undefined' || getSdpFormat() === 'unified') {
return chromeOrSafariGetTrackStats(peerConnection, track);
}
// NOTE(syerrapragada): getStats() is not supported on
// Safari versions where plan-b is the SDP format
// due to this bug: https://bugs.webkit.org/show_bug.cgi?id=192601
return Promise.reject(new Error([
'getStats() is not supported on this version of Safari',
'due to this bug: https://bugs.webkit.org/show_bug.cgi?id=192601'
].join(' ')));
}
return Promise.reject(new Error('RTCPeerConnection#getStats() not supported'));
}
/**
* Get the standardized statistics for a particular MediaStreamTrack in Chrome or Safari.
* @param {RTCPeerConnection} peerConnection
* @param {MediaStreamTrack} track
* @returns {Promise.<Array<StandardizedTrackStatsReport>>}
*/
function chromeOrSafariGetTrackStats(peerConnection, track) {
return new Promise(function(resolve, reject) {
if (chromeMajorVersion && chromeMajorVersion < 67) {
peerConnection.getStats(function(response) {
resolve([standardizeChromeLegacyStats(response, track)]);
}, null, reject);
return;
}
peerConnection.getStats(track).then(function(response) {
resolve(standardizeChromeOrSafariStats(response));
}, reject);
});
}
/**
* Get the standardized statistics for a particular MediaStreamTrack in Firefox.
* @param {RTCPeerConnection} peerConnection
* @param {MediaStreamTrack} track
* @param {boolean} isRemote
* @returns {Promise.<Array<StandardizedTrackStatsReport>>}
*/
function firefoxGetTrackStats(peerConnection, track, isRemote) {
return new Promise(function(resolve, reject) {
peerConnection.getStats(track).then(function(response) {
resolve([standardizeFirefoxStats(response, isRemote)]);
}, reject);
});
}
/**
* Standardize the MediaStreamTrack's legacy statistics in Chrome.
* @param {RTCStatsResponse} response
* @param {MediaStreamTrack} track
* @returns {StandardizedTrackStatsReport}
*/
function standardizeChromeLegacyStats(response, track) {
var ssrcReport = response.result().find(function(report) {
return report.type === 'ssrc' && report.stat('googTrackId') === track.id;
});
var standardizedStats = {};
if (ssrcReport) {
standardizedStats.timestamp = Math.round(Number(ssrcReport.timestamp));
standardizedStats = ssrcReport.names().reduce(function(stats, name) {
switch (name) {
case 'googCodecName':
stats.codecName = ssrcReport.stat(name);
break;
case 'googRtt':
stats.roundTripTime = Number(ssrcReport.stat(name));
break;
case 'googJitterReceived':
stats.jitter = Number(ssrcReport.stat(name));
break;
case 'googFrameWidthInput':
stats.frameWidthInput = Number(ssrcReport.stat(name));
break;
case 'googFrameHeightInput':
stats.frameHeightInput = Number(ssrcReport.stat(name));
break;
case 'googFrameWidthSent':
stats.frameWidthSent = Number(ssrcReport.stat(name));
break;
case 'googFrameHeightSent':
stats.frameHeightSent = Number(ssrcReport.stat(name));
break;
case 'googFrameWidthReceived':
stats.frameWidthReceived = Number(ssrcReport.stat(name));
break;
case 'googFrameHeightReceived':
stats.frameHeightReceived = Number(ssrcReport.stat(name));
break;
case 'googFrameRateInput':
stats.frameRateInput = Number(ssrcReport.stat(name));
break;
case 'googFrameRateSent':
stats.frameRateSent = Number(ssrcReport.stat(name));
break;
case 'googFrameRateReceived':
stats.frameRateReceived = Number(ssrcReport.stat(name));
break;
case 'ssrc':
stats[name] = ssrcReport.stat(name);
break;
case 'bytesReceived':
case 'bytesSent':
case 'packetsLost':
case 'packetsReceived':
case 'packetsSent':
case 'audioInputLevel':
case 'audioOutputLevel':
stats[name] = Number(ssrcReport.stat(name));
break;
}
return stats;
}, standardizedStats);
}
return standardizedStats;
}
/**
* Standardize the MediaStreamTrack's statistics in Chrome or Safari.
* @param {RTCStatsResponse} response
* @returns {Array<StandardizedTrackStatsReport>}
*/
function standardizeChromeOrSafariStats(response) {
var inbound = null;
// NOTE(mpatwardhan): We should expect more than one "outbound-rtp" stats for a
// VP8 simulcast MediaStreamTrack.
var outbound = [];
var remoteInbound = null;
var remoteOutbound = null;
var track = null;
var codec = null;
response.forEach(function(stat) {
switch (stat.type) {
case 'inbound-rtp':
inbound = stat;
break;
case 'outbound-rtp':
outbound.push(stat);
break;
case 'track':
track = stat;
break;
case 'codec':
codec = stat;
break;
case 'remote-inbound-rtp':
remoteInbound = stat;
break;
case 'remote-outbound-rtp':
remoteOutbound = stat;
break;
}
});
var isRemote = track && track.remoteSource;
var mainSources = isRemote ? [inbound] : outbound;
var stats = [];
var remoteSource = isRemote ? remoteOutbound : remoteInbound; // remote rtp stats
mainSources.forEach(function(source) {
var standardizedStats = {};
var statSources = [
source, // local rtp stats
track,
codec,
remoteSource && remoteSource.ssrc === source.ssrc ? remoteSource : null, // remote rtp stats
];
function getStatValue(name) {
var sourceFound = statSources.find(function(statSource) {
return statSource && typeof statSource[name] !== 'undefined';
}) || null;
return sourceFound ? sourceFound[name] : null;
}
var ssrc = getStatValue('ssrc');
if (typeof ssrc === 'number') {
standardizedStats.ssrc = String(ssrc);
}
var timestamp = getStatValue('timestamp');
standardizedStats.timestamp = Math.round(timestamp);
var mimeType = getStatValue('mimeType');
if (typeof mimeType === 'string') {
mimeType = mimeType.split('/');
standardizedStats.codecName = mimeType[mimeType.length - 1];
}
var roundTripTime = getStatValue('roundTripTime');
if (typeof roundTripTime === 'number') {
standardizedStats.roundTripTime = Math.round(roundTripTime * 1000);
}
var jitter = getStatValue('jitter');
if (typeof jitter === 'number') {
standardizedStats.jitter = Math.round(jitter * 1000);
}
var frameWidth = getStatValue('frameWidth');
if (typeof frameWidth === 'number') {
if (isRemote) {
standardizedStats.frameWidthReceived = frameWidth;
} else {
standardizedStats.frameWidthSent = frameWidth;
}
}
var frameHeight = getStatValue('frameHeight');
if (typeof frameHeight === 'number') {
if (isRemote) {
standardizedStats.frameHeightReceived = frameHeight;
} else {
standardizedStats.frameHeightSent = frameHeight;
}
}
var framesPerSecond = getStatValue('framesPerSecond');
if (typeof framesPerSecond === 'number') {
standardizedStats.frameRateSent = framesPerSecond;
}
var bytesReceived = getStatValue('bytesReceived');
if (typeof bytesReceived === 'number') {
standardizedStats.bytesReceived = bytesReceived;
}
var bytesSent = getStatValue('bytesSent');
if (typeof bytesSent === 'number') {
standardizedStats.bytesSent = bytesSent;
}
var packetsLost = getStatValue('packetsLost');
if (typeof packetsLost === 'number') {
standardizedStats.packetsLost = packetsLost;
}
var packetsReceived = getStatValue('packetsReceived');
if (typeof packetsReceived === 'number') {
standardizedStats.packetsReceived = packetsReceived;
}
var packetsSent = getStatValue('packetsSent');
if (typeof packetsSent === 'number') {
standardizedStats.packetsSent = packetsSent;
}
var audioLevel = getStatValue('audioLevel');
if (typeof audioLevel === 'number') {
audioLevel = Math.round(audioLevel * CHROME_LEGACY_MAX_AUDIO_LEVEL);
if (isRemote) {
standardizedStats.audioOutputLevel = audioLevel;
} else {
standardizedStats.audioInputLevel = audioLevel;
}
}
stats.push(standardizedStats);
});
return stats;
}
/**
* Standardize the MediaStreamTrack's statistics in Firefox.
* @param {RTCStatsReport} response
* @param {boolean} isRemote
* @returns {StandardizedTrackStatsReport}
*/
function standardizeFirefoxStats(response, isRemote) {
// NOTE(mroberts): If getStats is called on a closed RTCPeerConnection,
// Firefox returns undefined instead of an RTCStatsReport. We workaround this
// here. See the following bug for more details:
//
// https://bugzilla.mozilla.org/show_bug.cgi?id=1377225
//
response = response || new Map();
var inbound = null;
var outbound = null;
// NOTE(mmalavalli): Starting from Firefox 63, RTC{Inbound, Outbound}RTPStreamStats.isRemote
// will be deprecated, followed by its removal in Firefox 66. Also, trying to
// access members of the remote RTC{Inbound, Outbound}RTPStreamStats without
// using RTCStatsReport.get(remoteId) will trigger console warnings. So, we
// no longer depend on "isRemote", and we call RTCStatsReport.get(remoteId)
// to access the remote RTC{Inbound, Outbound}RTPStreamStats.
//
// Source: https://blog.mozilla.org/webrtc/getstats-isremote-65/
//
response.forEach(function(stat) {
if (stat.isRemote) {
return;
}
switch (stat.type) {
case 'inbound-rtp':
inbound = stat;
outbound = response.get(stat.remoteId);
break;
case 'outbound-rtp':
outbound = stat;
inbound = response.get(stat.remoteId);
break;
}
});
var first = isRemote ? inbound : outbound;
var second = isRemote ? outbound : inbound;
function getStatValue(name) {
if (first && typeof first[name] !== 'undefined') {
return first[name];
}
if (second && typeof second[name] !== 'undefined') {
return second[name];
}
return null;
}
var standardizedStats = {};
var timestamp = getStatValue('timestamp');
standardizedStats.timestamp = Math.round(timestamp);
var ssrc = getStatValue('ssrc');
if (typeof ssrc === 'number') {
standardizedStats.ssrc = String(ssrc);
}
var bytesSent = getStatValue('bytesSent');
if (typeof bytesSent === 'number') {
standardizedStats.bytesSent = bytesSent;
}
var packetsLost = getStatValue('packetsLost');
if (typeof packetsLost === 'number') {
standardizedStats.packetsLost = packetsLost;
}
var packetsSent = getStatValue('packetsSent');
if (typeof packetsSent === 'number') {
standardizedStats.packetsSent = packetsSent;
}
var roundTripTime = getStatValue('roundTripTime');
if (typeof roundTripTime === 'number') {
// roundTripTime is double - measured in seconds.
// https://www.w3.org/TR/webrtc-stats/#dom-rtcremoteinboundrtpstreamstats-roundtriptime
// cover it to milliseconds (and make it integer)
standardizedStats.roundTripTime = Math.round(roundTripTime * 1000);
}
var jitter = getStatValue('jitter');
if (typeof jitter === 'number') {
standardizedStats.jitter = Math.round(jitter * 1000)