@@ -49,6 +51,8 @@ import LocalMediaControls from './LocalMediaControls'
import Hex from 'crypto-js/enc-hex'
import SHA1 from 'crypto-js/sha1'
import { showError } from '@nextcloud/dialogs'
+import { callAnalyzer } from '../../utils/webrtc/index'
+import { CONNECTION_QUALITY } from '../../utils/webrtc/analyzers/PeerConnectionAnalyzer'
export default {
@@ -74,8 +78,21 @@ export default {
},
},
+ data() {
+ return {
+ callAnalyzer: callAnalyzer,
+ qualityWarningInGracePeriodTimeout: null,
+ }
+ },
+
computed: {
+ videoContainerClass() {
+ return {
+ 'speaking': this.localMediaModel.attributes.speaking,
+ }
+ },
+
userId() {
return this.$store.getters.getUserId()
},
@@ -112,6 +129,93 @@ export default {
return this.localMediaModel.attributes.localStream && this.localMediaModel.attributes.localStreamRequestVideoError
},
+ showQualityWarning() {
+ return this.senderConnectionQualityIsBad || this.qualityWarningInGracePeriodTimeout
+ },
+
+ senderConnectionQualityIsBad() {
+ return this.senderConnectionQualityAudioIsBad
+ || this.senderConnectionQualityVideoIsBad
+ || this.senderConnectionQualityScreenIsBad
+ },
+
+ senderConnectionQualityAudioIsBad() {
+ return callAnalyzer
+ && (callAnalyzer.attributes.senderConnectionQualityAudio === CONNECTION_QUALITY.VERY_BAD
+ || callAnalyzer.attributes.senderConnectionQualityAudio === CONNECTION_QUALITY.NO_TRANSMITTED_DATA)
+ },
+
+ senderConnectionQualityVideoIsBad() {
+ return callAnalyzer
+ && (callAnalyzer.attributes.senderConnectionQualityVideo === CONNECTION_QUALITY.VERY_BAD
+ || callAnalyzer.attributes.senderConnectionQualityVideo === CONNECTION_QUALITY.NO_TRANSMITTED_DATA)
+ },
+
+ senderConnectionQualityScreenIsBad() {
+ return callAnalyzer
+ && (callAnalyzer.attributes.senderConnectionQualityScreen === CONNECTION_QUALITY.VERY_BAD
+ || callAnalyzer.attributes.senderConnectionQualityScreen === CONNECTION_QUALITY.NO_TRANSMITTED_DATA)
+ },
+
+ qualityWarningAriaLabel() {
+ let label = ''
+ if (!this.localMediaModel.attributes.audioEnabled && this.localMediaModel.attributes.videoEnabled && this.localMediaModel.attributes.localScreen) {
+ label = t('spreed', 'Bad sent video and screen quality.')
+ } else if (!this.localMediaModel.attributes.audioEnabled && this.localMediaModel.attributes.localScreen) {
+ label = t('spreed', 'Bad sent screen quality.')
+ } else if (!this.localMediaModel.attributes.audioEnabled && this.localMediaModel.attributes.videoEnabled) {
+ label = t('spreed', 'Bad sent video quality.')
+ } else if (this.localMediaModel.attributes.videoEnabled && this.localMediaModel.attributes.localScreen) {
+ label = t('spreed', 'Bad sent audio, video and screen quality.')
+ } else if (this.localMediaModel.attributes.localScreen) {
+ label = t('spreed', 'Bad sent audio and screen quality.')
+ } else if (this.localMediaModel.attributes.videoEnabled) {
+ label = t('spreed', 'Bad sent audio and video quality.')
+ } else {
+ label = t('spreed', 'Bad sent audio quality.')
+ }
+
+ return label
+ },
+
+ qualityWarningTooltip() {
+ if (!this.showQualityWarning) {
+ return null
+ }
+
+ const tooltip = {}
+ if (!this.localMediaModel.attributes.audioEnabled && this.localMediaModel.attributes.videoEnabled && this.localMediaModel.attributes.localScreen) {
+ tooltip.content = t('spreed', 'Your internet connection or computer are busy and other participants might be unable to see you. To improve the situation try to disable your video while doing a screenshare.')
+ tooltip.actionLabel = t('spreed', 'Disable video')
+ tooltip.action = 'disableVideo'
+ } else if (!this.localMediaModel.attributes.audioEnabled && this.localMediaModel.attributes.localScreen) {
+ tooltip.content = t('spreed', 'Your internet connection or computer are busy and other participants might be unable to see your screen.')
+ tooltip.actionLabel = ''
+ tooltip.action = ''
+ } else if (!this.localMediaModel.attributes.audioEnabled && this.localMediaModel.attributes.videoEnabled) {
+ tooltip.content = t('spreed', 'Your internet connection or computer are busy and other participants might be unable to see you.')
+ tooltip.actionLabel = ''
+ tooltip.action = ''
+ } else if (this.localMediaModel.attributes.videoEnabled && this.localMediaModel.attributes.localScreen) {
+ tooltip.content = t('spreed', 'Your internet connection or computer are busy and other participants might be unable to understand and see you. To improve the situation try to disable your video while doing a screenshare.')
+ tooltip.actionLabel = t('spreed', 'Disable video')
+ tooltip.action = 'disableVideo'
+ } else if (this.localMediaModel.attributes.localScreen) {
+ tooltip.content = t('spreed', 'Your internet connection or computer are busy and other participants might be unable to understand and see your screen. To improve the situation try to disable your screenshare.')
+ tooltip.actionLabel = t('spreed', 'Disable screenshare')
+ tooltip.action = 'disableScreenShare'
+ } else if (this.localMediaModel.attributes.videoEnabled) {
+ tooltip.content = t('spreed', 'Your internet connection or computer are busy and other participants might be unable to understand and see you. To improve the situation try to disable your video.')
+ tooltip.actionLabel = t('spreed', 'Disable video')
+ tooltip.action = 'disableVideo'
+ } else {
+ tooltip.content = t('spreed', 'Your internet connection or computer are busy and other participants might be unable to understand you.')
+ tooltip.actionLabel = ''
+ tooltip.action = ''
+ }
+
+ return tooltip
+ },
},
watch: {
@@ -131,6 +235,20 @@ export default {
},
},
+ senderConnectionQualityIsBad: function(senderConnectionQualityIsBad) {
+ if (!senderConnectionQualityIsBad) {
+ return
+ }
+
+ if (this.qualityWarningInGracePeriodTimeout) {
+ window.clearTimeout(this.qualityWarningInGracePeriodTimeout)
+ }
+
+ this.qualityWarningInGracePeriodTimeout = window.setTimeout(() => {
+ this.qualityWarningInGracePeriodTimeout = null
+ }, 10000)
+ },
+
},
mounted() {
@@ -165,4 +283,5 @@ export default {
@import '../../assets/avatar.scss';
@include avatar-mixin(64px);
@include avatar-mixin(128px);
+
diff --git a/src/utils/webrtc/analyzers/AverageStatValue.js b/src/utils/webrtc/analyzers/AverageStatValue.js
new file mode 100644
index 00000000000..9ba6c4dd6a9
--- /dev/null
+++ b/src/utils/webrtc/analyzers/AverageStatValue.js
@@ -0,0 +1,135 @@
+/**
+ *
+ * @copyright Copyright (c) 2020, Daniel Calviño Sánchez (danxuliu@gmail.com)
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
.
+ *
+ */
+
+const STAT_VALUE_TYPE = {
+ CUMULATIVE: 0,
+ RELATIVE: 1,
+}
+
+/**
+ * Helper to calculate the average of the last N instances of an RTCStatsReport
+ * value.
+ *
+ * The average is a weighted average in which the latest elements have a higher
+ * weight. Specifically, the first item has a weight of 1, the last item has a
+ * weight of 3, and all the intermediate items have a weight that increases
+ * linearly from 1 to 3. The weights can be set when the AverageStatValue is
+ * created by specifying the weight of the last item.
+ *
+ * The number of items to keep track of must be set when the AverageStatValue is
+ * created. Once N items have been added adding a new one will discard the
+ * oldest value. "hasEnoughData()" can be used to check if at least N items have
+ * been added already and the average is reliable.
+ *
+ * An RTCStatsReport value can be cumulative since the creation of the
+ * RTCPeerConnection (like a sent packet count), or it can be an independent
+ * value at a certain point of time (like the round trip time). To be able to
+ * calculate the average the AverageStatValue converts cumulative values to
+ * relative ones. When the AverageStatValue is created it must be set whether
+ * the values that will be added are cumulative or not.
+ *
+ * The conversion from cumulative to relative is done automatically. Note,
+ * however, that the first value added to a cumulative AverageStatValue after
+ * creating or resetting it will be treated as 0 in the average calculation,
+ * as it will be the base from which the rest of relative values are calculated.
+ *
+ * Besides the weighted average it is possible to "peek" the last value, either
+ * the raw value that was added or the relative one after the conversion (which,
+ * for non cumulative values, will be the raw value too).
+ *
+ * @param {int} count the number of instances to take into account.
+ * @param {STAT_VALUE_TYPE} type whether the value is cumulative or relative.
+ * @param {int} lastValueWeight the value to calculate the weights of all the
+ * items, from the first (weight 1) to the last one.
+ */
+function AverageStatValue(count, type = STAT_VALUE_TYPE.CUMULATIVE, lastValueWeight = 3) {
+ this._count = count
+ this._type = type
+ this._extraWeightForEachElement = (lastValueWeight - 1) / (count - 1)
+
+ this._rawValues = []
+ this._relativeValues = []
+}
+AverageStatValue.prototype = {
+
+ reset: function() {
+ this._rawValues = []
+ this._relativeValues = []
+ },
+
+ add: function(value) {
+ if (this._rawValues.length === this._count) {
+ this._rawValues.shift()
+ this._relativeValues.shift()
+ }
+
+ let relativeValue = value
+ if (this._type === STAT_VALUE_TYPE.CUMULATIVE) {
+ // The first added value will be meaningless as it will be 0 and
+ // used as the base for the rest of values.
+ const lastRawValue = this._rawValues.length ? this._rawValues[this._rawValues.length - 1] : value
+ relativeValue = value - lastRawValue
+ }
+
+ this._rawValues.push(value)
+ this._relativeValues.push(relativeValue)
+ },
+
+ getLastRawValue: function() {
+ if (this._rawValues.length < 1) {
+ return NaN
+ }
+
+ return this._rawValues[this._rawValues.length - 1]
+ },
+
+ getLastRelativeValue: function() {
+ if (this._relativeValues.length < 1) {
+ return NaN
+ }
+
+ return this._relativeValues[this._relativeValues.length - 1]
+ },
+
+ hasEnoughData: function() {
+ return this._rawValues.length === this._count
+ },
+
+ getWeightedAverage: function() {
+ let weightedValues = 0
+ let weightsSum = 0
+
+ for (let i = 0; i < this._relativeValues.length; i++) {
+ const weight = 1 + (i * this._extraWeightForEachElement)
+
+ weightedValues += this._relativeValues[i] * weight
+ weightsSum += weight
+ }
+
+ return weightedValues / weightsSum
+ },
+
+}
+
+export {
+ STAT_VALUE_TYPE,
+ AverageStatValue,
+}
diff --git a/src/utils/webrtc/analyzers/CallAnalyzer.js b/src/utils/webrtc/analyzers/CallAnalyzer.js
new file mode 100644
index 00000000000..5087bd10774
--- /dev/null
+++ b/src/utils/webrtc/analyzers/CallAnalyzer.js
@@ -0,0 +1,145 @@
+/**
+ *
+ * @copyright Copyright (c) 2020, Daniel Calviño Sánchez (danxuliu@gmail.com)
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
.
+ *
+ */
+
+import {
+ ParticipantAnalyzer,
+} from './ParticipantAnalyzer'
+
+/**
+ * Analyzer for the quality of the connections of a call.
+ *
+ * After a CallAnalyzer is created the analysis will be automatically started.
+ *
+ * When the quality of the connections change different events will be triggered
+ * depending on the case:
+ * - 'change:senderConnectionQualityAudio'
+ * - 'change:senderConnectionQualityVideo'
+ * - 'change:senderConnectionQualityScreen'
+ *
+ * The reported values are based on CONNECTION_QUALITY values of
+ * PeerConnectionAnalyzer.
+ *
+ * Besides the event themselves, the quality can be known too using
+ * "get(valueName)" or even directly from the "attributes" object.
+ *
+ * Once the CallAnalyzer is no longer needed "destroy()" must be called to stop
+ * the analysis.
+ *
+ * @param {LocalMediaModel} localMediaModel the model for the local media.
+ * @param {LocalCallParticipantModel} localCallParticipantModel the model for
+ * the local participant; null if an MCU is not used.
+ * @param {CallParticipantCollection} callParticipantCollection the collection
+ * for the remote participants.
+ */
+export default function CallAnalyzer(localMediaModel, localCallParticipantModel, callParticipantCollection) {
+ this.attributes = {
+ senderConnectionQualityAudio: null,
+ senderConnectionQualityVideo: null,
+ senderConnectionQualityScreen: null,
+ }
+
+ this._handlers = []
+
+ this._localMediaModel = localMediaModel
+ this._localCallParticipantModel = localCallParticipantModel
+ this._callParticipantCollection = callParticipantCollection
+
+ this._handleSenderConnectionQualityAudioChangeBound = this._handleSenderConnectionQualityAudioChange.bind(this)
+ this._handleSenderConnectionQualityVideoChangeBound = this._handleSenderConnectionQualityVideoChange.bind(this)
+ this._handleSenderConnectionQualityScreenChangeBound = this._handleSenderConnectionQualityScreenChange.bind(this)
+
+ if (localCallParticipantModel) {
+ this._localParticipantAnalyzer = new ParticipantAnalyzer()
+ this._localParticipantAnalyzer.setSenderParticipant(localMediaModel, localCallParticipantModel)
+
+ this._localParticipantAnalyzer.on('change:senderConnectionQualityAudio', this._handleSenderConnectionQualityAudioChangeBound)
+ this._localParticipantAnalyzer.on('change:senderConnectionQualityVideo', this._handleSenderConnectionQualityVideoChangeBound)
+ this._localParticipantAnalyzer.on('change:senderConnectionQualityScreen', this._handleSenderConnectionQualityScreenChangeBound)
+ }
+}
+CallAnalyzer.prototype = {
+
+ get: function(key) {
+ return this.attributes[key]
+ },
+
+ set: function(key, value) {
+ this.attributes[key] = value
+
+ this._trigger('change:' + key, [value])
+ },
+
+ on: function(event, handler) {
+ if (!this._handlers.hasOwnProperty(event)) {
+ this._handlers[event] = [handler]
+ } else {
+ this._handlers[event].push(handler)
+ }
+ },
+
+ off: function(event, handler) {
+ const handlers = this._handlers[event]
+ if (!handlers) {
+ return
+ }
+
+ const index = handlers.indexOf(handler)
+ if (index !== -1) {
+ handlers.splice(index, 1)
+ }
+ },
+
+ _trigger: function(event, args) {
+ let handlers = this._handlers[event]
+ if (!handlers) {
+ return
+ }
+
+ args.unshift(this)
+
+ handlers = handlers.slice(0)
+ for (let i = 0; i < handlers.length; i++) {
+ const handler = handlers[i]
+ handler.apply(handler, args)
+ }
+ },
+
+ destroy: function() {
+ if (this._localParticipantAnalyzer) {
+ this._localParticipantAnalyzer.off('change:senderConnectionQualityAudio', this._handleSenderConnectionQualityAudioChangeBound)
+
+ this._localParticipantAnalyzer.destroy()
+ }
+ },
+
+ _handleSenderConnectionQualityAudioChange: function(participantAnalyzer, senderConnectionQualityAudio) {
+ this.set('senderConnectionQualityAudio', senderConnectionQualityAudio)
+ },
+
+ _handleSenderConnectionQualityVideoChange: function(participantAnalyzer, senderConnectionQualityVideo) {
+ this.set('senderConnectionQualityVideo', senderConnectionQualityVideo)
+ },
+
+ _handleSenderConnectionQualityScreenChange: function(participantAnalyzer, senderConnectionQualityScreen) {
+ this.set('senderConnectionQualityScreen', senderConnectionQualityScreen)
+ },
+
+}
diff --git a/src/utils/webrtc/analyzers/ParticipantAnalyzer.js b/src/utils/webrtc/analyzers/ParticipantAnalyzer.js
new file mode 100644
index 00000000000..a68411d688c
--- /dev/null
+++ b/src/utils/webrtc/analyzers/ParticipantAnalyzer.js
@@ -0,0 +1,347 @@
+/**
+ *
+ * @copyright Copyright (c) 2020, Daniel Calviño Sánchez (danxuliu@gmail.com)
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
.
+ *
+ */
+
+import {
+ PEER_DIRECTION,
+ PeerConnectionAnalyzer,
+} from './PeerConnectionAnalyzer'
+
+/**
+ * Analyzer for the quality of the connections of a Participant.
+ *
+ * After a ParticipantAnalyzer is created the participant to analyze must be set
+ * using any of the "setXXXParticipant" methods; "setSenderReceiverParticipant"
+ * is meant to be used when there is no HPB, while "setSenderParticipant" and
+ * "setReceiverParticipant" are meant to be used when there is an HPB for the
+ * local and remote participants respectively.
+ *
+ * When the quality of the connections change different events will be triggered
+ * depending on the case:
+ * - 'change:senderConnectionQualityAudio'
+ * - 'change:senderConnectionQualityVideo'
+ * - 'change:senderConnectionQualityScreen'
+ * - 'change:receiverConnectionQualityAudio'
+ * - 'change:receiverConnectionQualityVideo'
+ * - 'change:receiverConnectionQualityScreen'
+ *
+ * The reported values are based on CONNECTION_QUALITY values of
+ * PeerConnectionAnalyzer.
+ *
+ * Note that the connections will be analyzed only when the corresponding media
+ * is enabled so, for example, if a sender participant has muted but still has
+ * the video enabled only the video quality will be analyzed until the audio is
+ * unmuted again. This is done not only because the connection quality of media
+ * is less relevant when the media is disabled, but also because the connection
+ * stats provided by the browser and used for the analysis are less reliable in
+ * that case.
+ *
+ * Once the ParticipantAnalyzer is no longer needed "destroy()" must be called
+ * to stop the analysis.
+ */
+function ParticipantAnalyzer() {
+ this._handlers = []
+
+ this._localMediaModel = null
+ this._localCallParticipantModel = null
+ this._callParticipantModel = null
+
+ this._peer = null
+ this._screenPeer = null
+
+ this._senderPeerConnectionAnalyzer = null
+ this._receiverPeerConnectionAnalyzer = null
+ this._senderScreenPeerConnectionAnalyzer = null
+ this._receiverScreenPeerConnectionAnalyzer = null
+
+ this._handlePeerChangeBound = this._handlePeerChange.bind(this)
+ this._handleScreenPeerChangeBound = this._handleScreenPeerChange.bind(this)
+ this._handleSenderAudioEnabledChangeBound = this._handleSenderAudioEnabledChange.bind(this)
+ this._handleSenderVideoEnabledChangeBound = this._handleSenderVideoEnabledChange.bind(this)
+ this._handleReceiverAudioAvailableChangeBound = this._handleReceiverAudioAvailableChange.bind(this)
+ this._handleReceiverVideoAvailableChangeBound = this._handleReceiverVideoAvailableChange.bind(this)
+ this._handleConnectionQualityAudioChangeBound = this._handleConnectionQualityAudioChange.bind(this)
+ this._handleConnectionQualityVideoChangeBound = this._handleConnectionQualityVideoChange.bind(this)
+ this._handleConnectionQualityScreenChangeBound = this._handleConnectionQualityScreenChange.bind(this)
+}
+ParticipantAnalyzer.prototype = {
+
+ on: function(event, handler) {
+ if (!this._handlers.hasOwnProperty(event)) {
+ this._handlers[event] = [handler]
+ } else {
+ this._handlers[event].push(handler)
+ }
+ },
+
+ off: function(event, handler) {
+ const handlers = this._handlers[event]
+ if (!handlers) {
+ return
+ }
+
+ const index = handlers.indexOf(handler)
+ if (index !== -1) {
+ handlers.splice(index, 1)
+ }
+ },
+
+ _trigger: function(event, args) {
+ let handlers = this._handlers[event]
+ if (!handlers) {
+ return
+ }
+
+ args.unshift(this)
+
+ handlers = handlers.slice(0)
+ for (let i = 0; i < handlers.length; i++) {
+ const handler = handlers[i]
+ handler.apply(handler, args)
+ }
+ },
+
+ destroy: function() {
+ if (this._localCallParticipantModel) {
+ this._localCallParticipantModel.off('change:peer', this._handlePeerChangeBound)
+ }
+
+ if (this._callParticipantModel) {
+ this._callParticipantModel.off('change:peer', this._handlePeerChangeBound)
+ }
+
+ this._stopListeningToAudioVideoChanges()
+ this._stopListeningToScreenChanges()
+
+ this._localMediaModel = null
+ this._localCallParticipantModel = null
+ this._callParticipantModel = null
+
+ this._peer = null
+ this._screenPeer = null
+
+ this._senderPeerConnectionAnalyzer = null
+ this._receiverPeerConnectionAnalyzer = null
+ this._senderScreenPeerConnectionAnalyzer = null
+ this._receiverScreenPeerConnectionAnalyzer = null
+ },
+
+ setSenderParticipant: function(localMediaModel, localCallParticipantModel) {
+ this.destroy()
+
+ this._localMediaModel = localMediaModel
+ this._localCallParticipantModel = localCallParticipantModel
+
+ if (this._localCallParticipantModel) {
+ this._senderPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+ this._senderScreenPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+
+ this._localCallParticipantModel.on('change:peer', this._handlePeerChangeBound)
+ this._handlePeerChange(this._localCallParticipantModel, this._localCallParticipantModel.get('peer'))
+
+ this._localCallParticipantModel.on('change:screenPeer', this._handleScreenPeerChangeBound)
+ this._handleScreenPeerChange(this._localCallParticipantModel, this._localCallParticipantModel.get('screenPeer'))
+ }
+ },
+
+ setReceiverParticipant: function(callParticipantModel) {
+ this.destroy()
+
+ this._callParticipantModel = callParticipantModel
+
+ if (this._callParticipantModel) {
+ this._receiverPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+ this._receiverScreenPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+
+ this._callParticipantModel.on('change:peer', this._handlePeerChangeBound)
+ this._handlePeerChange(this._callParticipantModel, this._callParticipantModel.get('peer'))
+
+ this._callParticipantModel.on('change:screenPeer', this._handleScreenPeerChangeBound)
+ this._handleScreenPeerChange(this._callParticipantModel, this._callParticipantModel.get('screenPeer'))
+ }
+ },
+
+ setSenderReceiverParticipant: function(localMediaModel, callParticipantModel) {
+ this.destroy()
+
+ this._localMediaModel = localMediaModel
+ this._callParticipantModel = callParticipantModel
+
+ if (this._callParticipantModel) {
+ this._senderPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+ this._receiverPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+ this._senderScreenPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+ this._receiverScreenPeerConnectionAnalyzer = new PeerConnectionAnalyzer()
+
+ this._callParticipantModel.on('change:peer', this._handlePeerChangeBound)
+ this._handlePeerChange(this._callParticipantModel, this._callParticipantModel.get('peer'))
+
+ this._callParticipantModel.on('change:screenPeer', this._handleScreenPeerChangeBound)
+ this._handleScreenPeerChange(this._callParticipantModel, this._callParticipantModel.get('screenPeer'))
+ }
+ },
+
+ _handlePeerChange: function(model, peer) {
+ if (this._peer) {
+ this._stopListeningToAudioVideoChanges()
+ }
+
+ this._peer = peer
+
+ if (peer) {
+ this._startListeningToAudioVideoChanges()
+ }
+ },
+
+ _handleScreenPeerChange: function(model, screenPeer) {
+ if (this._screenPeer) {
+ this._stopListeningToScreenChanges()
+ }
+
+ this._screenPeer = screenPeer
+
+ if (screenPeer) {
+ this._startListeningToScreenChanges()
+ }
+ },
+
+ _startListeningToAudioVideoChanges: function() {
+ if (this._localMediaModel) {
+ this._senderPeerConnectionAnalyzer.setPeerConnection(this._peer.pc, PEER_DIRECTION.SENDER)
+
+ this._senderPeerConnectionAnalyzer.on('change:connectionQualityAudio', this._handleConnectionQualityAudioChangeBound)
+ this._senderPeerConnectionAnalyzer.on('change:connectionQualityVideo', this._handleConnectionQualityVideoChangeBound)
+
+ this._localMediaModel.on('change:audioEnabled', this._handleSenderAudioEnabledChangeBound)
+ this._localMediaModel.on('change:videoEnabled', this._handleSenderVideoEnabledChangeBound)
+
+ this._handleSenderAudioEnabledChange(this._localMediaModel, this._localMediaModel.get('audioEnabled'))
+ this._handleSenderVideoEnabledChange(this._localMediaModel, this._localMediaModel.get('videoEnabled'))
+ }
+
+ if (this._callParticipantModel) {
+ this._receiverPeerConnectionAnalyzer.setPeerConnection(this._peer.pc, PEER_DIRECTION.RECEIVER)
+
+ this._receiverPeerConnectionAnalyzer.on('change:connectionQualityAudio', this._handleConnectionQualityAudioChangeBound)
+ this._receiverPeerConnectionAnalyzer.on('change:connectionQualityVideo', this._handleConnectionQualityVideoChangeBound)
+
+ this._callParticipantModel.on('change:audioAvailable', this._handleReceiverAudioAvailableChangeBound)
+ this._callParticipantModel.on('change:videoAvailable', this._handleReceiverVideoAvailableChangeBound)
+
+ this._handleReceiverAudioAvailableChange(this._localMediaModel, this._callParticipantModel.get('audioAvailable'))
+ this._handleReceiverVideoAvailableChange(this._localMediaModel, this._callParticipantModel.get('videoAvailable'))
+ }
+ },
+
+ _startListeningToScreenChanges: function() {
+ if (this._localMediaModel) {
+ this._senderScreenPeerConnectionAnalyzer.setPeerConnection(this._screenPeer.pc, PEER_DIRECTION.SENDER)
+
+ this._senderScreenPeerConnectionAnalyzer.on('change:connectionQualityVideo', this._handleConnectionQualityScreenChangeBound)
+ }
+
+ if (this._callParticipantModel) {
+ this._receiverScreenPeerConnectionAnalyzer.setPeerConnection(this._screenPeer.pc, PEER_DIRECTION.RECEIVER)
+
+ this._receiverScreenPeerConnectionAnalyzer.on('change:connectionQualityVideo', this._handleConnectionQualityScreenChangeBound)
+ }
+ },
+
+ _stopListeningToAudioVideoChanges: function() {
+ if (this._localMediaModel) {
+ this._senderPeerConnectionAnalyzer.setPeerConnection(null)
+
+ this._senderPeerConnectionAnalyzer.off('change:connectionQualityAudio', this._handleConnectionQualityAudioChangeBound)
+ this._senderPeerConnectionAnalyzer.off('change:connectionQualityVideo', this._handleConnectionQualityVideoChangeBound)
+
+ this._localMediaModel.off('change:audioEnabled', this._handleSenderAudioEnabledChangeBound)
+ this._localMediaModel.off('change:videoEnabled', this._handleSenderVideoEnabledChangeBound)
+ }
+
+ if (this._callParticipantModel) {
+ this._receiverPeerConnectionAnalyzer.setPeerConnection(null)
+
+ this._receiverPeerConnectionAnalyzer.off('change:connectionQualityAudio', this._handleConnectionQualityAudioChangeBound)
+ this._receiverPeerConnectionAnalyzer.off('change:connectionQualityVideo', this._handleConnectionQualityVideoChangeBound)
+
+ this._callParticipantModel.off('change:audioAvailable', this._handleReceiverAudioAvailableChangeBound)
+ this._callParticipantModel.off('change:videoAvailable', this._handleReceiverVideoAvailableChangeBound)
+ }
+ },
+
+ _stopListeningToScreenChanges: function() {
+ if (this._localMediaModel) {
+ this._senderScreenPeerConnectionAnalyzer.setPeerConnection(null)
+
+ this._senderPeerConnectionAnalyzer.off('change:connectionQualityVideo', this._handleConnectionQualityScreenChangeBound)
+ }
+
+ if (this._callParticipantModel) {
+ this._receiverScreenPeerConnectionAnalyzer.setPeerConnection(null)
+
+ this._receiverPeerConnectionAnalyzer.off('change:connectionQualityVideo', this._handleConnectionQualityScreenChangeBound)
+ }
+ },
+
+ _handleConnectionQualityAudioChange: function(peerConnectionAnalyzer, connectionQualityAudio) {
+ if (peerConnectionAnalyzer === this._senderPeerConnectionAnalyzer) {
+ this._trigger('change:senderConnectionQualityAudio', [connectionQualityAudio])
+ } else if (peerConnectionAnalyzer === this._receiverPeerConnectionAnalyzer) {
+ this._trigger('change:receiverConnectionQualityAudio', [connectionQualityAudio])
+ }
+ },
+
+ _handleConnectionQualityVideoChange: function(peerConnectionAnalyzer, connectionQualityVideo) {
+ if (peerConnectionAnalyzer === this._senderPeerConnectionAnalyzer) {
+ this._trigger('change:senderConnectionQualityVideo', [connectionQualityVideo])
+ } else if (peerConnectionAnalyzer === this._receiverPeerConnectionAnalyzer) {
+ this._trigger('change:receiverConnectionQualityVideo', [connectionQualityVideo])
+ }
+ },
+
+ _handleConnectionQualityScreenChange: function(peerConnectionAnalyzer, connectionQualityScreen) {
+ if (peerConnectionAnalyzer === this._senderScreenPeerConnectionAnalyzer) {
+ this._trigger('change:senderConnectionQualityScreen', [connectionQualityScreen])
+ } else if (peerConnectionAnalyzer === this._receiverScreenPeerConnectionAnalyzer) {
+ this._trigger('change:receiverConnectionQualityScreen', [connectionQualityScreen])
+ }
+ },
+
+ _handleSenderAudioEnabledChange: function(localMediaModel, audioEnabled) {
+ this._senderPeerConnectionAnalyzer.setAnalysisEnabledAudio(audioEnabled)
+ },
+
+ _handleSenderVideoEnabledChange: function(localMediaModel, videoEnabled) {
+ this._senderPeerConnectionAnalyzer.setAnalysisEnabledVideo(videoEnabled)
+ },
+
+ _handleReceiverAudioAvailableChange: function(callParticipantModel, audioAvailable) {
+ this._receiverPeerConnectionAnalyzer.setAnalysisEnabledAudio(audioAvailable)
+ },
+
+ _handleReceiverVideoAvailableChange: function(callParticipantModel, videoAvailable) {
+ this._receiverPeerConnectionAnalyzer.setAnalysisEnabledVideo(videoAvailable)
+ },
+
+}
+
+export {
+ ParticipantAnalyzer,
+}
diff --git a/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js
new file mode 100644
index 00000000000..9aff77463d7
--- /dev/null
+++ b/src/utils/webrtc/analyzers/PeerConnectionAnalyzer.js
@@ -0,0 +1,560 @@
+/**
+ *
+ * @copyright Copyright (c) 2020, Daniel Calviño Sánchez (danxuliu@gmail.com)
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
.
+ *
+ */
+
+import {
+ STAT_VALUE_TYPE,
+ AverageStatValue,
+} from './AverageStatValue'
+
+const CONNECTION_QUALITY = {
+ UNKNOWN: 0,
+ GOOD: 1,
+ MEDIUM: 2,
+ BAD: 3,
+ VERY_BAD: 4,
+ NO_TRANSMITTED_DATA: 5,
+}
+
+const PEER_DIRECTION = {
+ SENDER: 0,
+ RECEIVER: 1,
+}
+
+/**
+ * Analyzer for the quality of the connection of an RTCPeerConnection.
+ *
+ * After creation "setPeerConnection(RTCPeerConnection)" must be called to set
+ * the RTCPeerConnection to analyze. The analysis will start and stop
+ * automatically based on the connection state, except when closed. Suprisingly,
+ * "iceConnectionStateChange" is not called when the ICE connection state
+ * changes to closed, so the change can not be detected from the
+ * PeerConnectionAnalyzer. This change can be detected from the signaling,
+ * though, and thus must be handled by the user of this class by calling
+ * "setPeerConnection(null)" to stop the analysis. Similarly,
+ * "setPeerConnection(null)" must be called too if the RTCPeerConnection is
+ * active but the analyzer is no longer needed.
+ *
+ * Similarly, the analysis should be enabled only when audio or video are
+ * enabled. This is also known from the signaling messages and needs to be
+ * handled by the user of this class by calling "setAnalysisEnabledAudio(bool)"
+ * and "setAnalysisEnabledVideo(bool)".
+ *
+ * The reason is that when audio or video are disabled the transmitted packets
+ * are much lower, so it is not possible to get a reliable analysis from them.
+ * Moreover, when the sent video is disabled in Firefox the stats are
+ * meaningless, as the packet count is no longer a monotonic increasing value.
+ *
+ * The reported connection quality is mainly based on the packets lost ratio,
+ * but also in other stats, like the amount of transmitted packets. UNKNOWN is
+ * used when the analysis is started or stopped (including when it is done
+ * automatically due to changes in the ICE connection status). In general even
+ * if the quality of the connection is bad WebRTC is able to keep audio and
+ * video at acceptable quality levels; only when the reported connection quality
+ * is very bad or no data is transmitted at all the audio and video quality may
+ * not be enough.
+ */
+function PeerConnectionAnalyzer() {
+ this._packets = {
+ 'audio': new AverageStatValue(5, STAT_VALUE_TYPE.CUMULATIVE),
+ 'video': new AverageStatValue(5, STAT_VALUE_TYPE.CUMULATIVE),
+ }
+ this._packetsLost = {
+ 'audio': new AverageStatValue(5, STAT_VALUE_TYPE.CUMULATIVE),
+ 'video': new AverageStatValue(5, STAT_VALUE_TYPE.CUMULATIVE),
+ }
+ this._packetsLostRatio = {
+ 'audio': new AverageStatValue(5, STAT_VALUE_TYPE.RELATIVE),
+ 'video': new AverageStatValue(5, STAT_VALUE_TYPE.RELATIVE),
+ }
+ this._packetsPerSecond = {
+ 'audio': new AverageStatValue(5, STAT_VALUE_TYPE.RELATIVE),
+ 'video': new AverageStatValue(5, STAT_VALUE_TYPE.RELATIVE),
+ }
+ // Latest values have a higher weight than the default one to better detect
+ // sudden changes in the round trip time, which can lead to discarded (but
+ // not lost) packets.
+ this._roundTripTime = {
+ 'audio': new AverageStatValue(5, STAT_VALUE_TYPE.RELATIVE, 5),
+ 'video': new AverageStatValue(5, STAT_VALUE_TYPE.RELATIVE, 5),
+ }
+ // Only the last relative value is used, but as it is a cumulative value the
+ // previous one is needed as a base to calculate the last one.
+ this._timestamps = {
+ 'audio': new AverageStatValue(2, STAT_VALUE_TYPE.CUMULATIVE),
+ 'video': new AverageStatValue(2, STAT_VALUE_TYPE.CUMULATIVE),
+ }
+
+ this._analysisEnabled = {
+ 'audio': true,
+ 'video': true,
+ }
+
+ this._handlers = []
+
+ this._peerConnection = null
+ this._peerDirection = null
+
+ this._getStatsInterval = null
+
+ this._handleIceConnectionStateChangedBound = this._handleIceConnectionStateChanged.bind(this)
+ this._processStatsBound = this._processStats.bind(this)
+
+ this._connectionQualityAudio = CONNECTION_QUALITY.UNKNOWN
+ this._connectionQualityVideo = CONNECTION_QUALITY.UNKNOWN
+}
+PeerConnectionAnalyzer.prototype = {
+
+ on: function(event, handler) {
+ if (!this._handlers.hasOwnProperty(event)) {
+ this._handlers[event] = [handler]
+ } else {
+ this._handlers[event].push(handler)
+ }
+ },
+
+ off: function(event, handler) {
+ const handlers = this._handlers[event]
+ if (!handlers) {
+ return
+ }
+
+ const index = handlers.indexOf(handler)
+ if (index !== -1) {
+ handlers.splice(index, 1)
+ }
+ },
+
+ _trigger: function(event, args) {
+ let handlers = this._handlers[event]
+ if (!handlers) {
+ return
+ }
+
+ args.unshift(this)
+
+ handlers = handlers.slice(0)
+ for (let i = 0; i < handlers.length; i++) {
+ const handler = handlers[i]
+ handler.apply(handler, args)
+ }
+ },
+
+ getConnectionQualityAudio: function() {
+ return this._connectionQualityAudio
+ },
+
+ getConnectionQualityVideo: function() {
+ return this._connectionQualityVideo
+ },
+
+ _setConnectionQualityAudio: function(connectionQualityAudio) {
+ if (this._connectionQualityAudio === connectionQualityAudio) {
+ return
+ }
+
+ this._connectionQualityAudio = connectionQualityAudio
+ this._trigger('change:connectionQualityAudio', [connectionQualityAudio])
+ },
+
+ _setConnectionQualityVideo: function(connectionQualityVideo) {
+ if (this._connectionQualityVideo === connectionQualityVideo) {
+ return
+ }
+
+ this._connectionQualityVideo = connectionQualityVideo
+ this._trigger('change:connectionQualityVideo', [connectionQualityVideo])
+ },
+
+ setPeerConnection: function(peerConnection, peerDirection = null) {
+ if (this._peerConnection) {
+ this._peerConnection.removeEventListener('iceconnectionstatechange', this._handleIceConnectionStateChangedBound)
+ this._stopGetStatsInterval()
+ }
+
+ this._peerConnection = peerConnection
+ this._peerDirection = peerDirection
+
+ if (this._peerConnection) {
+ this._peerConnection.addEventListener('iceconnectionstatechange', this._handleIceConnectionStateChangedBound)
+ this._handleIceConnectionStateChangedBound()
+ }
+ },
+
+ setAnalysisEnabledAudio: function(analysisEnabledAudio) {
+ this._analysisEnabled['audio'] = analysisEnabledAudio
+
+ if (!analysisEnabledAudio) {
+ this._setConnectionQualityAudio(CONNECTION_QUALITY.UNKNOWN)
+ } else {
+ this._packets['audio'].reset()
+ this._packetsLost['audio'].reset()
+ this._packetsLostRatio['audio'].reset()
+ this._packetsPerSecond['audio'].reset()
+ this._timestamps['audio'].reset()
+ }
+ },
+
+ setAnalysisEnabledVideo: function(analysisEnabledVideo) {
+ this._analysisEnabled['video'] = analysisEnabledVideo
+
+ if (!analysisEnabledVideo) {
+ this._setConnectionQualityVideo(CONNECTION_QUALITY.UNKNOWN)
+ } else {
+ this._packets['video'].reset()
+ this._packetsLost['video'].reset()
+ this._packetsLostRatio['video'].reset()
+ this._packetsPerSecond['video'].reset()
+ this._timestamps['video'].reset()
+ }
+ },
+
+ _handleIceConnectionStateChanged: function() {
+ // Note that even if the ICE connection state is "disconnected" the
+ // connection is actually active, media is still transmitted, and the
+ // stats are properly updated.
+ if (!this._peerConnection || (this._peerConnection.iceConnectionState !== 'connected' && this._peerConnection.iceConnectionState !== 'completed' && this._peerConnection.iceConnectionState !== 'disconnected')) {
+ this._setConnectionQualityAudio(CONNECTION_QUALITY.UNKNOWN)
+ this._setConnectionQualityVideo(CONNECTION_QUALITY.UNKNOWN)
+
+ this._stopGetStatsInterval()
+
+ return
+ }
+
+ if (this._getStatsInterval) {
+ // Already active, nothing to do.
+ return
+ }
+
+ this._getStatsInterval = window.setInterval(() => {
+ this._peerConnection.getStats().then(this._processStatsBound)
+ }, 1000)
+ },
+
+ _stopGetStatsInterval: function() {
+ window.clearInterval(this._getStatsInterval)
+ this._getStatsInterval = null
+ },
+
+ _processStats: function(stats) {
+ if (!this._peerConnection || (this._peerConnection.iceConnectionState !== 'connected' && this._peerConnection.iceConnectionState !== 'completed' && this._peerConnection.iceConnectionState !== 'disconnected')) {
+ return
+ }
+
+ if (this._peerDirection === PEER_DIRECTION.SENDER) {
+ this._processSenderStats(stats)
+ } else if (this._peerDirection === PEER_DIRECTION.RECEIVER) {
+ this._processReceiverStats(stats)
+ }
+
+ if (this._analysisEnabled['audio']) {
+ this._setConnectionQualityAudio(this._calculateConnectionQualityAudio())
+ }
+ if (this._analysisEnabled['video']) {
+ this._setConnectionQualityVideo(this._calculateConnectionQualityVideo())
+ }
+ },
+
+ _processSenderStats: function(stats) {
+ // Packets are calculated as "packetsReceived + packetsLost" or as
+ // "packetsSent" depending on the browser (see below).
+ const packets = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ // Packets stats for a sender are checked from the point of view of the
+ // receiver.
+ const packetsReceived = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ const packetsLost = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ // If "packetsReceived" is not available (like in Chromium) use
+ // "packetsSent" instead; it may be measured at a different time from
+ // the received statistics, so checking "packetsLost" against it may not
+ // be fully accurate, but it should be close enough.
+ const packetsSent = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ // Timestamp is set to "timestampReceived" or "timestampSent" depending
+ // on how "packets" were calculated.
+ const timestamp = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ const timestampReceived = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ const timestampSent = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ const roundTripTime = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ for (const stat of stats.values()) {
+ if (!this._analysisEnabled[stat.kind]) {
+ continue
+ }
+
+ if (stat.type === 'outbound-rtp') {
+ if ('packetsSent' in stat && 'kind' in stat) {
+ packetsSent[stat.kind] = stat.packetsSent
+
+ if ('timestamp' in stat && 'kind' in stat) {
+ timestampSent[stat.kind] = stat.timestamp
+ }
+ }
+ } else if (stat.type === 'remote-inbound-rtp') {
+ if ('packetsReceived' in stat && 'kind' in stat) {
+ packetsReceived[stat.kind] = stat.packetsReceived
+
+ if ('timestamp' in stat && 'kind' in stat) {
+ timestampReceived[stat.kind] = stat.timestamp
+ }
+ }
+ if ('packetsLost' in stat && 'kind' in stat) {
+ packetsLost[stat.kind] = stat.packetsLost
+ }
+ if ('roundTripTime' in stat && 'kind' in stat) {
+ roundTripTime[stat.kind] = stat.roundTripTime
+ }
+ }
+ }
+
+ for (const kind of ['audio', 'video']) {
+ if (packetsReceived[kind] >= 0 && packetsLost[kind] >= 0) {
+ packets[kind] = packetsReceived[kind] + packetsLost[kind]
+ timestamp[kind] = timestampReceived[kind]
+ } else if (packetsSent[kind] >= 0) {
+ packets[kind] = packetsSent[kind]
+ timestamp[kind] = timestampSent[kind]
+ }
+
+ // In some (strange) cases a newer stat may report a lower value
+ // than a previous one (it seems to happen if the connection delay
+ // is high; probably the browser assumes that a packet was lost but
+ // later receives the acknowledgment). If that happens just keep the
+ // previous value to prevent distorting the analysis with negative
+ // ratios of lost packets.
+ if (packetsLost[kind] >= 0 && packetsLost[kind] < this._packetsLost[kind].getLastRawValue()) {
+ packetsLost[kind] = this._packetsLost[kind].getLastRawValue()
+ }
+
+ if (packets[kind] >= 0) {
+ this._packets[kind].add(packets[kind])
+ }
+ if (packetsLost[kind] >= 0) {
+ this._packetsLost[kind].add(packetsLost[kind])
+ }
+ if (packets[kind] >= 0 && packetsLost[kind] >= 0) {
+ // The packet stats are cumulative values, so the isolated
+ // values are got from the helper object.
+ // If there were no transmitted packets in the last stats the
+ // ratio is higher than 1 both to signal that and to force the
+ // quality towards a very bad quality faster, but not
+ // immediately.
+ let packetsLostRatio = 1.5
+ if (this._packets[kind].getLastRelativeValue() > 0) {
+ packetsLostRatio = this._packetsLost[kind].getLastRelativeValue() / this._packets[kind].getLastRelativeValue()
+ }
+ this._packetsLostRatio[kind].add(packetsLostRatio)
+ }
+ if (timestamp[kind] >= 0) {
+ this._timestamps[kind].add(timestamp[kind])
+ }
+ if (packets[kind] >= 0 && timestamp[kind] >= 0) {
+ const elapsedSeconds = this._timestamps[kind].getLastRelativeValue() / 1000
+ // The packet stats are cumulative values, so the isolated
+ // values are got from the helper object.
+ const packetsPerSecond = this._packets[kind].getLastRelativeValue() / elapsedSeconds
+ this._packetsPerSecond[kind].add(packetsPerSecond)
+ }
+ if (roundTripTime[kind] >= 0) {
+ this._roundTripTime[kind].add(roundTripTime[kind])
+ }
+ }
+ },
+
+ _processReceiverStats: function(stats) {
+ // Packets are calculated as "packetsReceived + packetsLost".
+ const packets = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ const packetsReceived = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ const packetsLost = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ const timestamp = {
+ 'audio': -1,
+ 'video': -1,
+ }
+
+ for (const stat of stats.values()) {
+ if (!this._analysisEnabled[stat.kind]) {
+ continue
+ }
+
+ if (stat.type === 'inbound-rtp') {
+ if ('packetsReceived' in stat && 'kind' in stat) {
+ packetsReceived[stat.kind] = stat.packetsReceived
+ }
+ if ('packetsLost' in stat && 'kind' in stat) {
+ packetsLost[stat.kind] = stat.packetsLost
+ }
+ if ('timestamp' in stat && 'kind' in stat) {
+ timestamp[stat.kind] = stat.timestamp
+ }
+ }
+ }
+
+ for (const kind of ['audio', 'video']) {
+ if (packetsReceived[kind] >= 0 && packetsLost[kind] >= 0) {
+ packets[kind] = packetsReceived[kind] + packetsLost[kind]
+ }
+
+ // In some (strange) cases a newer stat may report a lower value
+ // than a previous one (it seems to happen if the connection delay
+ // is high; probably the browser assumes that a packet was lost but
+ // later receives the acknowledgment). If that happens just keep the
+ // previous value to prevent distorting the analysis with negative
+ // ratios of lost packets.
+ if (packetsLost[kind] >= 0 && packetsLost[kind] < this._packetsLost[kind].getLastRawValue()) {
+ packetsLost[kind] = this._packetsLost[kind].getLastRawValue()
+ }
+
+ if (packets[kind] >= 0) {
+ this._packets[kind].add(packets[kind])
+ }
+ if (packetsLost[kind] >= 0) {
+ this._packetsLost[kind].add(packetsLost[kind])
+ }
+ if (packets[kind] >= 0 && packetsLost[kind] >= 0) {
+ // The packet stats are cumulative values, so the isolated
+ // values are got from the helper object.
+ // If there were no transmitted packets in the last stats the
+ // ratio is higher than 1 both to signal that and to force the
+ // quality towards a very bad quality faster, but not
+ // immediately.
+ let packetsLostRatio = 1.5
+ if (this._packets[kind].getLastRelativeValue() > 0) {
+ packetsLostRatio = this._packetsLost[kind].getLastRelativeValue() / this._packets[kind].getLastRelativeValue()
+ }
+ this._packetsLostRatio[kind].add(packetsLostRatio)
+ }
+ if (timestamp[kind] >= 0) {
+ this._timestamps[kind].add(timestamp[kind])
+ }
+ if (packets[kind] >= 0 && timestamp[kind] >= 0) {
+ const elapsedSeconds = this._timestamps[kind].getLastRelativeValue() / 1000
+ // The packet stats are cumulative values, so the isolated
+ // values are got from the helper object.
+ const packetsPerSecond = this._packets[kind].getLastRelativeValue() / elapsedSeconds
+ this._packetsPerSecond[kind].add(packetsPerSecond)
+ }
+ }
+ },
+
+ _calculateConnectionQualityAudio: function() {
+ return this._calculateConnectionQuality(this._packetsLostRatio['audio'], this._packetsPerSecond['audio'], this._roundTripTime['audio'])
+ },
+
+ _calculateConnectionQualityVideo: function() {
+ return this._calculateConnectionQuality(this._packetsLostRatio['video'], this._packetsPerSecond['video'], this._roundTripTime['video'])
+ },
+
+ _calculateConnectionQuality: function(packetsLostRatio, packetsPerSecond, roundTripTime) {
+ if (!packetsLostRatio.hasEnoughData() || !packetsPerSecond.hasEnoughData()) {
+ return CONNECTION_QUALITY.UNKNOWN
+ }
+
+ const packetsLostRatioWeightedAverage = packetsLostRatio.getWeightedAverage()
+ if (packetsLostRatioWeightedAverage >= 1) {
+ return CONNECTION_QUALITY.NO_TRANSMITTED_DATA
+ }
+
+ // A high round trip time means that the delay is high, but it can also
+ // imply that some packets, even if they are not lost, are anyway
+ // discarded to try to keep the playing rate in real time.
+ // Round trip time is measured in seconds.
+ if (roundTripTime.hasEnoughData() && roundTripTime.getWeightedAverage() > 1.5) {
+ return CONNECTION_QUALITY.VERY_BAD
+ }
+
+ // In some cases there may be packets being transmitted without any lost
+ // packet, but if the number of packets is too low the connection is
+ // most likely in bad shape anyway.
+ // Note that in the case of video the number of transmitted packets
+ // depend on the resolution, frame rate and changes between frames, but
+ // even for a small (320x420) static video around 20 packets are
+ // transmitted on a good connection. If a high quality video is tried to
+ // be sent on a bad network the browser will automatically reduce its
+ // quality to keep a smooth video, albeit on a lower resolution. Thus
+ // with a threshold of 10 packets issues can be detected too for videos,
+ // although only once they can not be further downscaled.
+ if (packetsPerSecond.getWeightedAverage() < 10) {
+ return CONNECTION_QUALITY.VERY_BAD
+ }
+
+ if (packetsLostRatioWeightedAverage > 0.3) {
+ return CONNECTION_QUALITY.VERY_BAD
+ }
+
+ if (packetsLostRatioWeightedAverage > 0.2) {
+ return CONNECTION_QUALITY.BAD
+ }
+
+ if (packetsLostRatioWeightedAverage > 0.1) {
+ return CONNECTION_QUALITY.MEDIUM
+ }
+
+ return CONNECTION_QUALITY.GOOD
+ },
+
+}
+
+export {
+ CONNECTION_QUALITY,
+ PEER_DIRECTION,
+ PeerConnectionAnalyzer,
+}
diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js
index afaf26d82ef..456127e4273 100644
--- a/src/utils/webrtc/index.js
+++ b/src/utils/webrtc/index.js
@@ -21,6 +21,7 @@
import Signaling from '../signaling'
import initWebRtc from './webrtc'
+import CallAnalyzer from './analyzers/CallAnalyzer'
import CallParticipantCollection from './models/CallParticipantCollection'
import LocalCallParticipantModel from './models/LocalCallParticipantModel'
import LocalMediaModel from './models/LocalMediaModel'
@@ -33,6 +34,7 @@ let webRtc = null
const callParticipantCollection = new CallParticipantCollection()
const localCallParticipantModel = new LocalCallParticipantModel()
const localMediaModel = new LocalMediaModel()
+let callAnalyzer = null
let sentVideoQualityThrottler = null
let pendingConnectSignaling = null
@@ -91,7 +93,7 @@ function setupWebRtc() {
return
}
- webRtc = initWebRtc(signaling, callParticipantCollection)
+ webRtc = initWebRtc(signaling, callParticipantCollection, localCallParticipantModel)
localCallParticipantModel.setWebRtc(webRtc)
localMediaModel.setWebRtc(webRtc)
@@ -129,6 +131,12 @@ async function signalingJoinCall(token) {
sentVideoQualityThrottler = new SentVideoQualityThrottler(localMediaModel, callParticipantCollection)
+ if (signaling.hasFeature('mcu')) {
+ callAnalyzer = new CallAnalyzer(localMediaModel, localCallParticipantModel, callParticipantCollection)
+ } else {
+ callAnalyzer = new CallAnalyzer(localMediaModel, null, callParticipantCollection)
+ }
+
return new Promise((resolve, reject) => {
startedCall = resolve
@@ -146,6 +154,9 @@ async function signalingLeaveCall(token) {
sentVideoQualityThrottler.destroy()
sentVideoQualityThrottler = null
+ callAnalyzer.destroy()
+ callAnalyzer = null
+
await getSignaling()
await signaling.leaveCall(token)
}
@@ -176,6 +187,8 @@ export {
localCallParticipantModel,
localMediaModel,
+ callAnalyzer,
+
connectSignaling,
signalingJoinConversation,
diff --git a/src/utils/webrtc/models/CallParticipantModel.js b/src/utils/webrtc/models/CallParticipantModel.js
index fa061a6b5c6..4069b100180 100644
--- a/src/utils/webrtc/models/CallParticipantModel.js
+++ b/src/utils/webrtc/models/CallParticipantModel.js
@@ -37,6 +37,8 @@ export default function CallParticipantModel(options) {
this.attributes = {
peerId: null,
+ peer: null,
+ screenPeer: null,
// "undefined" is used for values not known yet; "null" or "false"
// are used for known but negative/empty values.
userId: undefined,
@@ -127,34 +129,34 @@ CallParticipantModel.prototype = {
},
_handlePeerStreamAdded: function(peer) {
- if (this._peer === peer) {
- this.set('stream', this._peer.stream || null)
+ if (this.get('peer') === peer) {
+ this.set('stream', this.get('peer').stream || null)
this.set('audioElement', attachMediaStream(this.get('stream'), null, { audio: true }))
// "peer.nick" is set only for users and when the MCU is not used.
- if (this._peer.nick !== undefined) {
- this.set('name', this._peer.nick)
+ if (this.get('peer').nick !== undefined) {
+ this.set('name', this.get('peer').nick)
}
- } else if (this._screenPeer === peer) {
- this.set('screen', this._screenPeer.stream || null)
+ } else if (this.get('screenPeer') === peer) {
+ this.set('screen', this.get('screenPeer').stream || null)
}
},
_handlePeerStreamRemoved: function(peer) {
- if (this._peer === peer) {
+ if (this.get('peer') === peer) {
this.get('audioElement').srcObject = null
this.set('audioElement', null)
this.set('stream', null)
this.set('audioAvailable', undefined)
this.set('speaking', undefined)
this.set('videoAvailable', undefined)
- } else if (this._screenPeer === peer) {
+ } else if (this.get('screenPeer') === peer) {
this.set('screen', null)
}
},
_handleNick: function(data) {
- if (!this._peer || this._peer.id !== data.id) {
+ if (!this.get('peer') || this.get('peer').id !== data.id) {
return
}
@@ -163,7 +165,7 @@ CallParticipantModel.prototype = {
},
_handleMute: function(data) {
- if (!this._peer || this._peer.id !== data.id) {
+ if (!this.get('peer') || this.get('peer').id !== data.id) {
return
}
@@ -176,7 +178,7 @@ CallParticipantModel.prototype = {
},
_handleUnmute: function(data) {
- if (!this._peer || this._peer.id !== data.id) {
+ if (!this.get('peer') || this.get('peer').id !== data.id) {
return
}
@@ -188,7 +190,7 @@ CallParticipantModel.prototype = {
},
_handleChannelMessage: function(peer, label, data) {
- if (!this._peer || this._peer.id !== peer.id) {
+ if (!this.get('peer') || this.get('peer').id !== peer.id) {
return
}
@@ -208,14 +210,14 @@ CallParticipantModel.prototype = {
console.warn('Mismatch between stored peer ID and ID of given peer: ', this.get('peerId'), peer.id)
}
- if (this._peer) {
- this._peer.off('extendedIceConnectionStateChange', this._handleExtendedIceConnectionStateChangeBound)
+ if (this.get('peer')) {
+ this.get('peer').off('extendedIceConnectionStateChange', this._handleExtendedIceConnectionStateChangeBound)
}
- this._peer = peer
+ this.set('peer', peer)
// Special case when the participant has no streams.
- if (!this._peer) {
+ if (!this.get('peer')) {
this.set('connectionState', ConnectionState.COMPLETED)
this.set('audioAvailable', false)
this.set('speaking', false)
@@ -225,10 +227,10 @@ CallParticipantModel.prototype = {
}
// Reset state that depends on the Peer object.
- this._handleExtendedIceConnectionStateChange(this._peer.pc.iceConnectionState)
- this._handlePeerStreamAdded(this._peer)
+ this._handleExtendedIceConnectionStateChange(this.get('peer').pc.iceConnectionState)
+ this._handlePeerStreamAdded(this.get('peer'))
- this._peer.on('extendedIceConnectionStateChange', this._handleExtendedIceConnectionStateChangeBound)
+ this.get('peer').on('extendedIceConnectionStateChange', this._handleExtendedIceConnectionStateChangeBound)
},
_handleExtendedIceConnectionStateChange: function(extendedIceConnectionState) {
@@ -236,8 +238,8 @@ CallParticipantModel.prototype = {
// not be set later for registered users without microphone nor
// camera.
const setNameForUserFromPeerNick = function() {
- if (this._peer.nick !== undefined) {
- this.set('name', this._peer.nick)
+ if (this.get('peer').nick !== undefined) {
+ this.set('name', this.get('peer').nick)
}
}.bind(this)
@@ -283,14 +285,14 @@ CallParticipantModel.prototype = {
},
setScreenPeer: function(screenPeer) {
- if (this.get('peerId') !== screenPeer.id) {
+ if (screenPeer && this.get('peerId') !== screenPeer.id) {
console.warn('Mismatch between stored peer ID and ID of given screen peer: ', this.get('peerId'), screenPeer.id)
}
- this._screenPeer = screenPeer
+ this.set('screenPeer', screenPeer)
// Reset state that depends on the screen Peer object.
- this._handlePeerStreamAdded(this._screenPeer)
+ this._handlePeerStreamAdded(this.get('screenPeer'))
},
setUserId: function(userId) {
diff --git a/src/utils/webrtc/models/LocalCallParticipantModel.js b/src/utils/webrtc/models/LocalCallParticipantModel.js
index db28f2999d2..6df70a18ceb 100644
--- a/src/utils/webrtc/models/LocalCallParticipantModel.js
+++ b/src/utils/webrtc/models/LocalCallParticipantModel.js
@@ -25,6 +25,8 @@ export default function LocalCallParticipantModel() {
this.attributes = {
peerId: null,
+ peer: null,
+ screenPeer: null,
guestName: null,
}
@@ -34,8 +36,14 @@ export default function LocalCallParticipantModel() {
LocalCallParticipantModel.prototype = {
+ get: function(key) {
+ return this.attributes[key]
+ },
+
set: function(key, value) {
this.attributes[key] = value
+
+ this._trigger('change:' + key, [value])
},
on: function(event, handler) {
@@ -90,6 +98,22 @@ LocalCallParticipantModel.prototype = {
this._unwatchDisplayNameChange = store.watch(state => state.actorStore.displayName, this.setGuestName.bind(this))
},
+ setPeer: function(peer) {
+ if (peer && this.get('peerId') !== peer.id) {
+ console.warn('Mismatch between stored peer ID and ID of given peer: ', this.get('peerId'), peer.id)
+ }
+
+ this.set('peer', peer)
+ },
+
+ setScreenPeer: function(screenPeer) {
+ if (screenPeer && this.get('peerId') !== screenPeer.id) {
+ console.warn('Mismatch between stored peer ID and ID of given screen peer: ', this.get('peerId'), screenPeer.id)
+ }
+
+ this.set('screenPeer', screenPeer)
+ },
+
setGuestName: function(guestName) {
if (!this._webRtc) {
throw new Error('WebRtc not initialized yet')
diff --git a/src/utils/webrtc/webrtc.js b/src/utils/webrtc/webrtc.js
index aa369dc4a74..b32ecf9d10e 100644
--- a/src/utils/webrtc/webrtc.js
+++ b/src/utils/webrtc/webrtc.js
@@ -45,6 +45,7 @@ let ownScreenPeer = null
let selfInCall = PARTICIPANT.CALL_FLAG.DISCONNECTED
const delayedConnectionToPeer = []
let callParticipantCollection = null
+let localCallParticipantModel = null
let showedTURNWarning = false
function arrayDiff(a, b) {
@@ -76,6 +77,8 @@ function createScreensharingPeer(signaling, sessionId) {
})
webrtc.emit('createdPeer', ownScreenPeer)
ownScreenPeer.start()
+
+ localCallParticipantModel.setScreenPeer(ownScreenPeer)
}
if (sessionId === currentSessionId) {
@@ -138,6 +141,8 @@ function checkStartPublishOwnPeer(signaling) {
})
webrtc.emit('createdPeer', ownPeer)
ownPeer.start()
+
+ localCallParticipantModel.setPeer(ownPeer)
}
function userHasStreams(user) {
@@ -312,8 +317,9 @@ function usersInCallChanged(signaling, users) {
}
}
-export default function initWebRTC(signaling, _callParticipantCollection) {
+export default function initWebRTC(signaling, _callParticipantCollection, _localCallParticipantModel) {
callParticipantCollection = _callParticipantCollection
+ localCallParticipantModel = _localCallParticipantModel
signaling.on('usersLeft', function(users) {
users.forEach(function(user) {
@@ -729,6 +735,8 @@ export default function initWebRTC(signaling, _callParticipantCollection) {
webrtc.removePeers(ownPeer.id)
ownPeer.end()
ownPeer = null
+
+ localCallParticipantModel.setPeer(ownPeer)
}
usersChanged(signaling, [], previousUsersInRoom)
@@ -926,6 +934,8 @@ export default function initWebRTC(signaling, _callParticipantCollection) {
if (ownScreenPeer) {
ownScreenPeer = null
+ localCallParticipantModel.setScreenPeer(ownScreenPeer)
+
signaling.sendRoomMessage({
roomType: 'screen',
type: 'unshareScreen',
diff --git a/webpack.common.js b/webpack.common.js
index ef8bfe74f47..324d9309912 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -42,7 +42,6 @@ module.exports = {
{
test: /\.vue$/,
loader: 'vue-loader',
- exclude: /node_modules/
},
{
test: /\.js$/,