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