diff --git a/src/components/MediaDevicesPreview.vue b/src/components/MediaDevicesPreview.vue index be68afe6d40..0a379fdb26a 100644 --- a/src/components/MediaDevicesPreview.vue +++ b/src/components/MediaDevicesPreview.vue @@ -126,7 +126,7 @@ export default { return mediaDevicesManager.attributes.audioInputId }, set(value) { - mediaDevicesManager.attributes.audioInputId = value + mediaDevicesManager.set('audioInputId', value) }, }, @@ -135,7 +135,7 @@ export default { return mediaDevicesManager.attributes.videoInputId }, set(value) { - mediaDevicesManager.attributes.videoInputId = value + mediaDevicesManager.set('videoInputId', value) }, }, diff --git a/src/utils/webrtc/MediaDevicesManager.js b/src/utils/webrtc/MediaDevicesManager.js index a8a8e0bf35c..27077aa198f 100644 --- a/src/utils/webrtc/MediaDevicesManager.js +++ b/src/utils/webrtc/MediaDevicesManager.js @@ -50,7 +50,11 @@ * those cases the fallback label can be used instead. * * "attributes.audioInputId" and "attributes.videoInputId" define the devices - * that will be used when calling "getUserMedia(constraints)". + * that will be used when calling "getUserMedia(constraints)". Clients of this + * class must modify them using "set('audioInputId', value)" and + * "set('videoInputId', value)" to ensure that change events are triggered. + * However, note that change events are not triggered when the devices are + * modified. * * The selected devices will be automatically cleared if they are no longer * available. When no device of certain kind is selected and there are other @@ -67,6 +71,8 @@ export default function MediaDevicesManager() { videoInputId: undefined, } + this._handlers = [] + this._enabledCount = 0 this._knownDevices = {} @@ -74,10 +80,57 @@ export default function MediaDevicesManager() { this._fallbackAudioInputId = undefined this._fallbackVideoInputId = undefined + this._tracks = [] + this._updateDevicesBound = this._updateDevices.bind(this) } MediaDevicesManager.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) + } + }, + /** * Returns whether getting user media and enumerating media devices is * supported or not. @@ -119,6 +172,9 @@ MediaDevicesManager.prototype = { _updateDevices: function() { navigator.mediaDevices.enumerateDevices().then(devices => { + const previousAudioInputId = this.attributes.audioInputId + const previousVideoInputId = this.attributes.videoInputId + const removedDevices = this.attributes.devices.filter(oldDevice => !devices.find(device => oldDevice.deviceId === device.deviceId && oldDevice.kind === device.kind)) const updatedDevices = devices.filter(device => this.attributes.devices.find(oldDevice => device.deviceId === oldDevice.deviceId && device.kind === oldDevice.kind)) const addedDevices = devices.filter(device => !this.attributes.devices.find(oldDevice => device.deviceId === oldDevice.deviceId && device.kind === oldDevice.kind)) @@ -132,6 +188,15 @@ MediaDevicesManager.prototype = { addedDevices.forEach(addedDevice => { this._addDevice(addedDevice) }) + + // Trigger change events after all the devices are processed to + // prevent change events for intermediate states. + if (previousAudioInputId !== this.attributes.audioInputId) { + this._trigger('change:audioInputId', [this.attributes.audioInputId]) + } + if (previousVideoInputId !== this.attributes.videoInputId) { + this._trigger('change:videoInputId', [this.attributes.videoInputId]) + } }).catch(function(error) { console.error('Could not update known media devices: ' + error.name + ': ' + error.message) }) @@ -272,7 +337,11 @@ MediaDevicesManager.prototype = { } } + this._stopIncompatibleTracks(constraints) + return navigator.mediaDevices.getUserMedia(constraints).then(stream => { + this._registerStream(stream) + // In Firefox the dialog to grant media permissions allows the user // to change the device to use, overriding the device that was // originally requested. @@ -294,6 +363,45 @@ MediaDevicesManager.prototype = { }) }, + _stopIncompatibleTracks: function(constraints) { + this._tracks.forEach(track => { + if (constraints.audio && constraints.audio.deviceId && track.kind === 'audio') { + const settings = track.getSettings() + if (settings && settings.deviceId !== constraints.audio.deviceId) { + track.stop() + } + } + + if (constraints.video && constraints.video.deviceId && track.kind === 'video') { + const settings = track.getSettings() + if (settings && settings.deviceId !== constraints.video.deviceId) { + track.stop() + } + } + }) + }, + + _registerStream: function(stream) { + stream.getTracks().forEach(track => { + this._registerTrack(track) + }) + }, + + _registerTrack: function(track) { + this._tracks.push(track) + + track.addEventListener('ended', () => { + const index = this._tracks.indexOf(track) + if (index >= 0) { + this._tracks.splice(index, 1) + } + }) + + track.addEventListener('cloned', event => { + this._registerTrack(event.detail) + }) + }, + _updateSelectedDevicesFromGetUserMediaResult: function(stream) { if (this.attributes.audioInputId) { const audioTracks = stream.getAudioTracks() @@ -301,7 +409,7 @@ MediaDevicesManager.prototype = { if (audioTrackSettings && audioTrackSettings.deviceId && this.attributes.audioInputId !== audioTrackSettings.deviceId) { console.debug('Input audio device overridden in getUserMedia: Expected: ' + this.attributes.audioInputId + ' Found: ' + audioTrackSettings.deviceId) - this.attributes.audioInputId = audioTrackSettings.deviceId + this.set('audioInputId', audioTrackSettings.deviceId) } } @@ -311,7 +419,7 @@ MediaDevicesManager.prototype = { if (videoTrackSettings && videoTrackSettings.deviceId && this.attributes.videoInputId !== videoTrackSettings.deviceId) { console.debug('Input video device overridden in getUserMedia: Expected: ' + this.attributes.videoInputId + ' Found: ' + videoTrackSettings.deviceId) - this.attributes.videoInputId = videoTrackSettings.deviceId + this.set('videoInputId', videoTrackSettings.deviceId) } } }, diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js index 4f5aa5a4907..73d2815887b 100644 --- a/src/utils/webrtc/index.js +++ b/src/utils/webrtc/index.js @@ -19,6 +19,8 @@ * */ +import './shims/MediaStream' +import './shims/MediaStreamTrack' import Axios from '@nextcloud/axios' import CancelableRequest from '../cancelableRequest' import Signaling from '../signaling' diff --git a/src/utils/webrtc/models/LocalMediaModel.js b/src/utils/webrtc/models/LocalMediaModel.js index 21f5e6cfc76..abec519382d 100644 --- a/src/utils/webrtc/models/LocalMediaModel.js +++ b/src/utils/webrtc/models/LocalMediaModel.js @@ -44,6 +44,7 @@ export default function LocalMediaModel() { this._handleLocalStreamBound = this._handleLocalStream.bind(this) this._handleLocalStreamRequestFailedRetryNoVideoBound = this._handleLocalStreamRequestFailedRetryNoVideo.bind(this) this._handleLocalStreamRequestFailedBound = this._handleLocalStreamRequestFailed.bind(this) + this._handleLocalStreamChangedBound = this._handleLocalStreamChanged.bind(this) this._handleLocalStreamStoppedBound = this._handleLocalStreamStopped.bind(this) this._handleAudioOnBound = this._handleAudioOn.bind(this) this._handleAudioOffBound = this._handleAudioOff.bind(this) @@ -116,6 +117,7 @@ LocalMediaModel.prototype = { this._webRtc.webrtc.off('localStream', this._handleLocalStreamBound) this._webRtc.webrtc.off('localStreamRequestFailedRetryNoVideo', this._handleLocalStreamRequestFailedBound) this._webRtc.webrtc.off('localStreamRequestFailed', this._handleLocalStreamRequestFailedBound) + this._webRtc.webrtc.off('localStreamChanged', this._handleLocalStreamChangedBound) this._webRtc.webrtc.off('localStreamStopped', this._handleLocalStreamStoppedBound) this._webRtc.webrtc.off('audioOn', this._handleAudioOnBound) this._webRtc.webrtc.off('audioOff', this._handleAudioOffBound) @@ -147,6 +149,7 @@ LocalMediaModel.prototype = { this._webRtc.webrtc.on('localStream', this._handleLocalStreamBound) this._webRtc.webrtc.on('localStreamRequestFailedRetryNoVideo', this._handleLocalStreamRequestFailedRetryNoVideoBound) this._webRtc.webrtc.on('localStreamRequestFailed', this._handleLocalStreamRequestFailedBound) + this._webRtc.webrtc.on('localStreamChanged', this._handleLocalStreamChangedBound) this._webRtc.webrtc.on('localStreamStopped', this._handleLocalStreamStoppedBound) this._webRtc.webrtc.on('audioOn', this._handleAudioOnBound) this._webRtc.webrtc.on('audioOff', this._handleAudioOffBound) @@ -225,6 +228,43 @@ LocalMediaModel.prototype = { } }, + _handleLocalStreamChanged: function(localStream) { + // Only a single local stream is assumed to be active at the same time. + this.set('localStream', localStream) + + this._updateMediaAvailability(localStream) + }, + + _updateMediaAvailability: function(localStream) { + if (localStream && localStream.getAudioTracks().length > 0) { + this.set('audioAvailable', true) + + if (!this.get('audioEnabled')) { + // Explicitly disable the audio to ensure that it will also be + // disabled in the other end. Otherwise the WebRTC media could + // be enabled. + this.disableAudio() + } + } else { + this.disableAudio() + this.set('audioAvailable', false) + } + + if (localStream && localStream.getVideoTracks().length > 0) { + this.set('videoAvailable', true) + + if (!this.get('videoEnabled')) { + // Explicitly disable the video to ensure that it will also be + // disabled in the other end. Otherwise the WebRTC media could + // be enabled. + this.disableVideo() + } + } else { + this.disableVideo() + this.set('videoAvailable', false) + } + }, + _handleLocalStreamStopped: function(localStream) { if (this.get('localStream') !== localStream) { return diff --git a/src/utils/webrtc/shims/MediaStream.js b/src/utils/webrtc/shims/MediaStream.js new file mode 100644 index 00000000000..7d0cee880e8 --- /dev/null +++ b/src/utils/webrtc/shims/MediaStream.js @@ -0,0 +1,113 @@ +/** + * + * @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 . + * + */ + +if (window.MediaStream) { + const originalMediaStreamAddTrack = window.MediaStream.prototype.addTrack + window.MediaStream.prototype.addTrack = function(track) { + let addTrackEventDispatched = false + const testAddTrackEvent = () => { + addTrackEventDispatched = true + } + this.addEventListener('addtrack', testAddTrackEvent) + + originalMediaStreamAddTrack.apply(this, arguments) + + this.removeEventListener('addtrack', testAddTrackEvent) + + if (!addTrackEventDispatched) { + this.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track: track })) + } + } + + const originalMediaStreamRemoveTrack = window.MediaStream.prototype.removeTrack + window.MediaStream.prototype.removeTrack = function(track) { + let removeTrackEventDispatched = false + const testRemoveTrackEvent = () => { + removeTrackEventDispatched = true + } + this.addEventListener('removetrack', testRemoveTrackEvent) + + originalMediaStreamRemoveTrack.apply(this, arguments) + + this.removeEventListener('removetrack', testRemoveTrackEvent) + + if (!removeTrackEventDispatched) { + this.dispatchEvent(new MediaStreamTrackEvent('removetrack', { track: track })) + } + } + + // Event implementations do not support advanced parameters like "options" + // or "useCapture". + const originalMediaStreamDispatchEvent = window.MediaStream.prototype.dispatchEvent + const originalMediaStreamAddEventListener = window.MediaStream.prototype.addEventListener + const originalMediaStreamRemoveEventListener = window.MediaStream.prototype.removeEventListener + + window.MediaStream.prototype.dispatchEvent = function(event) { + if (this._listeners && this._listeners[event.type]) { + this._listeners[event.type].forEach(listener => { + listener.apply(this, [event]) + }) + } + + return originalMediaStreamDispatchEvent.apply(this, arguments) + } + + let isMediaStreamDispatchEventSupported + + window.MediaStream.prototype.addEventListener = function(type, listener) { + if (isMediaStreamDispatchEventSupported === undefined) { + isMediaStreamDispatchEventSupported = false + const testDispatchEventSupportHandler = () => { + isMediaStreamDispatchEventSupported = true + } + originalMediaStreamAddEventListener.apply(this, ['test-dispatch-event-support', testDispatchEventSupportHandler]) + originalMediaStreamDispatchEvent.apply(this, [new Event('test-dispatch-event-support')]) + originalMediaStreamRemoveEventListener(this, ['test-dispatch-event-support', testDispatchEventSupportHandler]) + + console.debug('Is MediaStream.dispatchEvent() supported?: ', isMediaStreamDispatchEventSupported) + } + + if (!isMediaStreamDispatchEventSupported) { + if (!this._listeners) { + this._listeners = [] + } + + if (!this._listeners.hasOwnProperty(type)) { + this._listeners[type] = [listener] + } else if (!this._listeners[type].includes(listener)) { + this._listeners[type].push(listener) + } + } + + return originalMediaStreamAddEventListener.apply(this, arguments) + } + + window.MediaStream.prototype.removeEventListener = function(type, listener) { + if (this._listeners && this._listeners[type]) { + const index = this._listeners[type].indexOf(listener) + if (index >= 0) { + this._listeners[type].splice(index, 1) + } + } + + return originalMediaStreamRemoveEventListener.apply(this, arguments) + } +} diff --git a/src/utils/webrtc/shims/MediaStreamTrack.js b/src/utils/webrtc/shims/MediaStreamTrack.js new file mode 100644 index 00000000000..9d442302906 --- /dev/null +++ b/src/utils/webrtc/shims/MediaStreamTrack.js @@ -0,0 +1,99 @@ +/** + * + * @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 . + * + */ + +if (window.MediaStreamTrack) { + const originalMediaStreamTrackClone = window.MediaStreamTrack.prototype.clone + window.MediaStreamTrack.prototype.clone = function() { + const newTrack = originalMediaStreamTrackClone.apply(this, arguments) + + this.dispatchEvent(new CustomEvent('cloned', { detail: newTrack })) + + return newTrack + } + + const originalMediaStreamTrackStop = window.MediaStreamTrack.prototype.stop + window.MediaStreamTrack.prototype.stop = function() { + const wasAlreadyEnded = this.readyState === 'ended' + + originalMediaStreamTrackStop.apply(this, arguments) + + if (!wasAlreadyEnded) { + this.dispatchEvent(new Event('ended')) + } + } + + // Event implementations do not support advanced parameters like "options" + // or "useCapture". + const originalMediaStreamTrackDispatchEvent = window.MediaStreamTrack.prototype.dispatchEvent + const originalMediaStreamTrackAddEventListener = window.MediaStreamTrack.prototype.addEventListener + const originalMediaStreamTrackRemoveEventListener = window.MediaStreamTrack.prototype.removeEventListener + + window.MediaStreamTrack.prototype.dispatchEvent = function(event) { + if (this._listeners && this._listeners[event.type]) { + this._listeners[event.type].forEach(listener => { + listener.apply(this, [event]) + }) + } + + return originalMediaStreamTrackDispatchEvent.apply(this, arguments) + } + + let isMediaStreamTrackDispatchEventSupported + + window.MediaStreamTrack.prototype.addEventListener = function(type, listener) { + if (isMediaStreamTrackDispatchEventSupported === undefined) { + isMediaStreamTrackDispatchEventSupported = false + const testDispatchEventSupportHandler = () => { + isMediaStreamTrackDispatchEventSupported = true + } + originalMediaStreamTrackAddEventListener.apply(this, ['test-dispatch-event-support', testDispatchEventSupportHandler]) + originalMediaStreamTrackDispatchEvent.apply(this, [new Event('test-dispatch-event-support')]) + originalMediaStreamTrackRemoveEventListener(this, ['test-dispatch-event-support', testDispatchEventSupportHandler]) + + console.debug('Is MediaStreamTrack.dispatchEvent() supported?: ', isMediaStreamTrackDispatchEventSupported) + } + + if (!isMediaStreamTrackDispatchEventSupported) { + if (!this._listeners) { + this._listeners = [] + } + + if (!this._listeners.hasOwnProperty(type)) { + this._listeners[type] = [listener] + } else if (!this._listeners[type].includes(listener)) { + this._listeners[type].push(listener) + } + } + + return originalMediaStreamTrackAddEventListener.apply(this, arguments) + } + + window.MediaStreamTrack.prototype.removeEventListener = function(type, listener) { + if (this._listeners && this._listeners[type]) { + const index = this._listeners[type].indexOf(listener) + if (index >= 0) { + this._listeners[type].splice(index, 1) + } + } + + return originalMediaStreamTrackRemoveEventListener.apply(this, arguments) + } +} diff --git a/src/utils/webrtc/simplewebrtc/localmedia.js b/src/utils/webrtc/simplewebrtc/localmedia.js index 61b0955d22e..a7d5ec9450d 100644 --- a/src/utils/webrtc/simplewebrtc/localmedia.js +++ b/src/utils/webrtc/simplewebrtc/localmedia.js @@ -17,6 +17,14 @@ function isAllTracksEnded(stream) { return isAllTracksEnded } +function isAllAudioTracksEnded(stream) { + let isAllAudioTracksEnded = true + stream.getAudioTracks().forEach(function(t) { + isAllAudioTracksEnded = t.readyState === 'ended' && isAllAudioTracksEnded + }) + return isAllAudioTracksEnded +} + function LocalMedia(opts) { WildEmitter.call(this) @@ -52,10 +60,37 @@ function LocalMedia(opts) { this._audioMonitors = [] this.on('localScreenStopped', this._stopAudioMonitor.bind(this)) + + this._handleAudioInputIdChangedBound = this._handleAudioInputIdChanged.bind(this) + this._handleVideoInputIdChangedBound = this._handleVideoInputIdChanged.bind(this) } util.inherits(LocalMedia, WildEmitter) +/** + * Clones a MediaStreamTrack that will be ended when the original + * MediaStreamTrack is ended. + * + * @param {MediaStreamTrack} track the track to clone + * @returns {MediaStreamTrack} the linked track + */ +const cloneLinkedTrack = function(track) { + const linkedTrack = track.clone() + + // Keep a reference of all the linked clones of a track to be able to + // remove them when the source track is removed. + if (!track.linkedTracks) { + track.linkedTracks = [] + } + track.linkedTracks.push(linkedTrack) + + track.addEventListener('ended', function() { + linkedTrack.stop() + }) + + return linkedTrack +} + /** * Clones a MediaStream that will be ended when the original MediaStream is * ended. @@ -67,18 +102,16 @@ const cloneLinkedStream = function(stream) { const linkedStream = new MediaStream() stream.getTracks().forEach(function(track) { - const linkedTrack = track.clone() - linkedStream.addTrack(linkedTrack) + linkedStream.addTrack(cloneLinkedTrack(track)) + }) - // Keep a reference of all the linked clones of a track to be able to - // stop them when the track is stopped. - if (!track.linkedTracks) { - track.linkedTracks = [] - } - track.linkedTracks.push(linkedTrack) + stream.addEventListener('addtrack', function(event) { + linkedStream.addTrack(cloneLinkedTrack(event.track)) + }) - track.addEventListener('ended', function() { - linkedTrack.stop() + stream.addEventListener('removetrack', function(event) { + event.track.linkedTracks.forEach(linkedTrack => { + linkedStream.removeTrack(linkedTrack) }) }) @@ -132,6 +165,9 @@ LocalMedia.prototype.start = function(mediaConstraints, cb, context) { self.emit('localStream', constraints, stream) + webrtcIndex.mediaDevicesManager.on('change:audioInputId', self._handleAudioInputIdChangedBound) + webrtcIndex.mediaDevicesManager.on('change:videoInputId', self._handleVideoInputIdChangedBound) + if (cb) { return cb(null, stream) } @@ -153,50 +189,274 @@ LocalMedia.prototype.start = function(mediaConstraints, cb, context) { }) } +LocalMedia.prototype._handleAudioInputIdChanged = function(mediaDevicesManager, audioInputId) { + if (this._pendingAudioInputIdChangedCount) { + this._pendingAudioInputIdChangedCount++ + + return + } + + const localStreamsChanged = [] + const localTracksReplaced = [] + + if (this.localStreams.length === 0 && audioInputId) { + // Force the creation of a new stream to add a new audio track to it. + localTracksReplaced.push({ track: null, stream: null }) + } + + this.localStreams.forEach(stream => { + if (stream.getAudioTracks().length === 0) { + localStreamsChanged.push(stream) + + localTracksReplaced.push({ track: null, stream }) + } + + stream.getAudioTracks().forEach(track => { + const settings = track.getSettings() + if (track.kind === 'audio' && settings && settings.deviceId !== audioInputId) { + track.stop() + + stream.removeTrack(track) + + if (!localStreamsChanged.includes(stream)) { + localStreamsChanged.push(stream) + } + + localTracksReplaced.push({ track, stream }) + } + }) + }) + + if (audioInputId === null) { + localStreamsChanged.forEach(stream => { + this.emit('localStreamChanged', stream) + }) + + localTracksReplaced.forEach(trackStreamPair => { + this.emit('localTrackReplaced', null, trackStreamPair.track, trackStreamPair.stream) + }) + + return + } + + if (localTracksReplaced.length === 0) { + return + } + + this._pendingAudioInputIdChangedCount = 1 + + const resetPendingAudioInputIdChangedCount = () => { + const audioInputIdChangedAgain = this._pendingAudioInputIdChangedCount > 1 + + this._pendingAudioInputIdChangedCount = 0 + + if (audioInputIdChangedAgain) { + this._handleAudioInputIdChanged(webrtcIndex.mediaDevicesManager.get('audioInputId')) + } + } + + webrtcIndex.mediaDevicesManager.getUserMedia({ audio: true }).then(stream => { + // According to the specification "getUserMedia({ audio: true })" will + // return a single audio track. + const track = stream.getTracks()[0] + if (stream.getTracks().length > 1) { + console.error('More than a single audio track returned by getUserMedia, only the first one will be used') + } + + localTracksReplaced.forEach(trackStreamPair => { + const clonedTrack = track.clone() + + let stream = trackStreamPair.stream + let streamIndex = this.localStreams.indexOf(stream) + if (streamIndex < 0) { + stream = new MediaStream() + this.localStreams.push(stream) + streamIndex = this.localStreams.length - 1 + } + + stream.addTrack(clonedTrack) + + // The audio monitor stream is never disabled to be able to analyze + // it even when the stream sent is muted. + let audioMonitorStream + if (streamIndex > this._audioMonitorStreams.length - 1) { + audioMonitorStream = cloneLinkedStream(stream) + this._audioMonitorStreams.push(audioMonitorStream) + } else { + audioMonitorStream = this._audioMonitorStreams[streamIndex] + } + + if (this.config.detectSpeakingEvents) { + this._setupAudioMonitor(audioMonitorStream, this.config.harkOptions) + } + + clonedTrack.addEventListener('ended', () => { + if (isAllTracksEnded(stream)) { + this._removeStream(stream) + } + }) + + this.emit('localStreamChanged', stream) + this.emit('localTrackReplaced', clonedTrack, trackStreamPair.track, trackStreamPair.stream) + }) + + // After the clones were added to the local streams the original track + // is no longer needed. + track.stop() + + resetPendingAudioInputIdChangedCount() + }).catch(() => { + localStreamsChanged.forEach(stream => { + this.emit('localStreamChanged', stream) + }) + + localTracksReplaced.forEach(trackStreamPair => { + this.emit('localTrackReplaced', null, trackStreamPair.track, trackStreamPair.stream) + }) + + resetPendingAudioInputIdChangedCount() + }) +} + +LocalMedia.prototype._handleVideoInputIdChanged = function(mediaDevicesManager, videoInputId) { + if (this._pendingVideoInputIdChangedCount) { + this._pendingVideoInputIdChangedCount++ + + return + } + + const localStreamsChanged = [] + const localTracksReplaced = [] + + if (this.localStreams.length === 0 && videoInputId) { + // Force the creation of a new stream to add a new video track to it. + localTracksReplaced.push({ track: null, stream: null }) + } + + this.localStreams.forEach(stream => { + if (stream.getVideoTracks().length === 0) { + localStreamsChanged.push(stream) + + localTracksReplaced.push({ track: null, stream }) + } + + stream.getVideoTracks().forEach(track => { + const settings = track.getSettings() + if (track.kind === 'video' && settings && settings.deviceId !== videoInputId) { + track.stop() + + stream.removeTrack(track) + + if (!localStreamsChanged.includes(stream)) { + localStreamsChanged.push(stream) + } + + localTracksReplaced.push({ track, stream }) + } + }) + }) + + if (videoInputId === null) { + localStreamsChanged.forEach(stream => { + this.emit('localStreamChanged', stream) + }) + + localTracksReplaced.forEach(trackStreamPair => { + this.emit('localTrackReplaced', null, trackStreamPair.track, trackStreamPair.stream) + }) + + return + } + + if (localTracksReplaced.length === 0) { + return + } + + this._pendingVideoInputIdChangedCount = 1 + + const resetPendingVideoInputIdChangedCount = () => { + const videoInputIdChangedAgain = this._pendingVideoInputIdChangedCount > 1 + + this._pendingVideoInputIdChangedCount = 0 + + if (videoInputIdChangedAgain) { + this._handleVideoInputIdChanged(webrtcIndex.mediaDevicesManager.get('videoInputId')) + } + } + + webrtcIndex.mediaDevicesManager.getUserMedia({ video: true }).then(stream => { + // According to the specification "getUserMedia({ video: true })" will + // return a single video track. + const track = stream.getTracks()[0] + if (stream.getTracks().length > 1) { + console.error('More than a single video track returned by getUserMedia, only the first one will be used') + } + + localTracksReplaced.forEach(trackStreamPair => { + const clonedTrack = track.clone() + + let stream = trackStreamPair.stream + if (!this.localStreams.includes(stream)) { + stream = new MediaStream() + this.localStreams.push(stream) + + const audioMonitorStream = cloneLinkedStream(stream) + this._audioMonitorStreams.push(audioMonitorStream) + } + + stream.addTrack(clonedTrack) + + clonedTrack.addEventListener('ended', () => { + if (isAllTracksEnded(stream)) { + this._removeStream(stream) + } + }) + + this.emit('localStreamChanged', stream) + this.emit('localTrackReplaced', clonedTrack, trackStreamPair.track, trackStreamPair.stream) + }) + + // After the clones were added to the local streams the original track + // is no longer needed. + track.stop() + + resetPendingVideoInputIdChangedCount() + }).catch(() => { + localStreamsChanged.forEach(stream => { + this.emit('localStreamChanged', stream) + }) + + localTracksReplaced.forEach(trackStreamPair => { + this.emit('localTrackReplaced', null, trackStreamPair.track, trackStreamPair.stream) + }) + + resetPendingVideoInputIdChangedCount() + }) +} + LocalMedia.prototype.stop = function(stream) { this.stopStream(stream) this.stopScreenShare(stream) + + if (!this.localStreams.length) { + webrtcIndex.mediaDevicesManager.off('change:audioInputId', this._handleAudioInputIdChangedBound) + webrtcIndex.mediaDevicesManager.off('change:videoInputId', this._handleVideoInputIdChangedBound) + } } LocalMedia.prototype.stopStream = function(stream) { - const self = this - if (stream) { const idx = this.localStreams.indexOf(stream) if (idx > -1) { stream.getTracks().forEach(function(track) { track.stop() - - // Linked tracks must be explicitly stopped, as stopping a track - // does not trigger the "ended" event, and due to a bug in - // Firefox it is not possible to explicitly dispatch the event - // either (nor any other event with a different name): - // https://bugzilla.mozilla.org/show_bug.cgi?id=1473457 - if (track.linkedTracks) { - track.linkedTracks.forEach(function(linkedTrack) { - linkedTrack.stop() - }) - } }) - this._removeStream(stream) } } else { this.localStreams.forEach(function(stream) { stream.getTracks().forEach(function(track) { track.stop() - - // Linked tracks must be explicitly stopped, as stopping a track - // does not trigger the "ended" event, and due to a bug in - // Firefox it is not possible to explicitly dispatch the event - // either (nor any other event with a different name): - // https://bugzilla.mozilla.org/show_bug.cgi?id=1473457 - if (track.linkedTracks) { - track.linkedTracks.forEach(function(linkedTrack) { - linkedTrack.stop() - }) - } }) - self._removeStream(stream) }) } } @@ -356,7 +616,6 @@ LocalMedia.prototype._removeStream = function(stream) { let idx = this.localStreams.indexOf(stream) if (idx > -1) { this.localStreams.splice(idx, 1) - this._stopAudioMonitor(this._audioMonitorStreams[idx]) this._audioMonitorStreams.splice(idx, 1) this.emit('localStreamStopped', stream) } else { @@ -374,6 +633,14 @@ LocalMedia.prototype._setupAudioMonitor = function(stream, harkOptions) { const self = this let timeout + stream.getAudioTracks().forEach(function(track) { + track.addEventListener('ended', function() { + if (isAllAudioTracksEnded(stream)) { + self._stopAudioMonitor(stream) + } + }) + }) + audio.on('speaking', function() { self._speaking = true diff --git a/src/utils/webrtc/simplewebrtc/peer.js b/src/utils/webrtc/simplewebrtc/peer.js index 7b49f051381..ef3704e9ccb 100644 --- a/src/utils/webrtc/simplewebrtc/peer.js +++ b/src/utils/webrtc/simplewebrtc/peer.js @@ -77,6 +77,11 @@ function Peer(options) { } }) }) + + this.handleLocalTrackReplacedBound = this.handleLocalTrackReplaced.bind(this) + // TODO What would happen if the track is replaced while the peer is + // still negotiating the offer and answer? + this.parent.on('localTrackReplaced', this.handleLocalTrackReplacedBound) } // proxy events to parent @@ -377,6 +382,59 @@ Peer.prototype.end = function() { } this.pc.close() this.handleStreamRemoved() + this.parent.off('localTrackReplaced', this.handleLocalTrackReplacedBound) +} + +Peer.prototype.handleLocalTrackReplaced = function(newTrack, oldTrack, stream) { + let senderFound = false + + this.pc.getSenders().forEach(sender => { + if (sender.track !== oldTrack) { + return + } + + if (!sender.track && !newTrack) { + return + } + + if (!sender.kind && sender.track) { + sender.kind = sender.track.kind + } else if (!sender.kind) { + this.pc.getTransceivers().forEach(transceiver => { + if (transceiver.sender === sender) { + sender.kind = transceiver.mid + } + }) + } + + // A null track can match on audio and video senders, so it needs to be + // ensured that the sender kind and the new track kind are compatible. + // However, in some cases it may not be possible to know the sender + // kind. In those cases just go ahead and try to replace the track; if + // the kind does not match then replacing the track will fail, but this + // should not prevent replacing the track with a proper one later, nor + // affect any other sender. + if (!sender.track && sender.kind && sender.kind !== newTrack.kind) { + return + } + + senderFound = true + + sender.replaceTrack(newTrack).catch(error => { + if (error.name === 'InvalidModificationError') { + console.debug('Track could not be replaced, negotiation needed') + } else { + console.error('Track could not be replaced: ', error, oldTrack, newTrack) + } + }) + }) + + // If the call started when the audio or video device was not active there + // will be no sender for that type. In that case the track needs to be added + // instead of replaced. + if (!senderFound && newTrack) { + this.pc.addTrack(newTrack, stream) + } } Peer.prototype.handleRemoteStreamAdded = function(event) { diff --git a/src/utils/webrtc/webrtc.js b/src/utils/webrtc/webrtc.js index 4db8e9ef869..dcd9faf33e6 100644 --- a/src/utils/webrtc/webrtc.js +++ b/src/utils/webrtc/webrtc.js @@ -610,6 +610,37 @@ export default function initWebRTC(signaling, _callParticipantCollection, _local }) } + const forceReconnect = function(signaling, flags) { + if (ownPeer) { + webrtc.removePeers(ownPeer.id) + ownPeer.end() + ownPeer = null + + localCallParticipantModel.setPeer(ownPeer) + } + + usersChanged(signaling, [], previousUsersInRoom) + usersInCallMapping = {} + previousUsersInRoom = [] + + // Reconnects with a new session id will trigger "usersChanged" + // with the users in the room and that will re-establish the + // peerconnection streams. + // If flags are undefined the current call flags are used. + signaling.forceReconnect(true, flags) + } + + function setHandlerForNegotiationNeeded(peer) { + peer.pc.addEventListener('negotiationneeded', function() { + // Negotiation needed will be first triggered before the connection + // is established, but forcing a reconnection should be done only + // once the connection was established. + if (peer.pc.iceConnectionState !== 'new' && peer.pc.iceConnectionState !== 'checking') { + forceReconnect(signaling) + } + }) + } + webrtc.on('createdPeer', function(peer) { console.debug('Peer created', peer) @@ -640,6 +671,8 @@ export default function initWebRTC(signaling, _callParticipantCollection, _local setHandlerForIceConnectionStateChange(peer) } + setHandlerForNegotiationNeeded(peer) + // Make sure required data channels exist for all peers. This // is required for peers that get created by SimpleWebRTC from // received "Offer" messages. Otherwise the "channelMessage" @@ -734,26 +767,6 @@ export default function initWebRTC(signaling, _callParticipantCollection, _local stopPeerCheckMedia(peer) }) - const forceReconnect = function(signaling, flags) { - if (ownPeer) { - webrtc.removePeers(ownPeer.id) - ownPeer.end() - ownPeer = null - - localCallParticipantModel.setPeer(ownPeer) - } - - usersChanged(signaling, [], previousUsersInRoom) - usersInCallMapping = {} - previousUsersInRoom = [] - - // Reconnects with a new session id will trigger "usersChanged" - // with the users in the room and that will re-establish the - // peerconnection streams. - // If flags are undefined the current call flags are used. - signaling.forceReconnect(true, flags) - } - webrtc.webrtc.on('videoOn', function() { if (signaling.getSendVideoIfAvailable()) { return