diff --git a/package-lock.json b/package-lock.json index f2b5ad77e9a..3d0f8ed2af4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9695,6 +9695,11 @@ "vue-style-loader": "^4.1.0" } }, + "vue-material-design-icons": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-4.7.1.tgz", + "integrity": "sha512-Bp1ClFVpC2pfmNTq/d9O8FwvizVulg2R5M96do3yvHR720gzpdmrOLmcwXDvtFAf2v7qaXpf1572NnTF80oOjw==" + }, "vue-multiselect": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.6.tgz", diff --git a/package.json b/package.json index e58cc25f1d9..647397e28af 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vue-at": "^2.5.0-beta.2", "vue-clipboard2": "^0.3.1", "vue-fragment": "^1.5.1", + "vue-material-design-icons": "^4.7.1", "vue-observe-visibility": "^0.4.6", "vue-prevent-unload": "^0.2.3", "vue-router": "^3.1.5", diff --git a/src/components/CallView/LocalMediaControls.vue b/src/components/CallView/LocalMediaControls.vue index 1e50a6504ec..0a3883c4613 100644 --- a/src/components/CallView/LocalMediaControls.vue +++ b/src/components/CallView/LocalMediaControls.vue @@ -80,13 +80,48 @@ +
+ + +
+ {{ qualityWarningTooltip.content }} +
+ + +
+
+
+
@@ -478,4 +545,24 @@ export default { #muteWrapper .icon-audio-off + .volume-indicator { background: linear-gradient(0deg, gray, white 36px); } + +.network-connection-state { + position: absolute; + bottom: 0; + right: 2px; + width: 32px; + height: 32px; + filter: drop-shadow(1px 1px 4px var(--color-box-shadow)); +} + +.hint { + padding: 4px; + text-align: left; + &__actions{ + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + padding-top:4px; + } +} diff --git a/src/components/CallView/LocalVideo.vue b/src/components/CallView/LocalVideo.vue index 09c8c55cf99..76e68cc76f9 100644 --- a/src/components/CallView/LocalVideo.vue +++ b/src/components/CallView/LocalVideo.vue @@ -19,7 +19,7 @@ --> @@ -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$/,