UNPKG

twilio-conferencing-core-engine

Version:

twilio-conferencing-core-engine

1,514 lines (1,329 loc) 1.25 MB
(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)