Browse Source

Merge pull request #4023 from nextcloud/make-possible-to-select-input-media-devices-during-calls

Make possible to select input media devices during calls
pull/4036/head
Joas Schilling 5 years ago
committed by GitHub
parent
commit
fd7c6f206a
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      src/components/MediaDevicesPreview.vue
  2. 114
      src/utils/webrtc/MediaDevicesManager.js
  3. 2
      src/utils/webrtc/index.js
  4. 40
      src/utils/webrtc/models/LocalMediaModel.js
  5. 113
      src/utils/webrtc/shims/MediaStream.js
  6. 99
      src/utils/webrtc/shims/MediaStreamTrack.js
  7. 341
      src/utils/webrtc/simplewebrtc/localmedia.js
  8. 58
      src/utils/webrtc/simplewebrtc/peer.js
  9. 53
      src/utils/webrtc/webrtc.js

4
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)
},
},

114
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)
}
}
},

2
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'

40
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

113
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 <http://www.gnu.org/licenses/>.
*
*/
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)
}
}

99
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 <http://www.gnu.org/licenses/>.
*
*/
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)
}
}

341
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

58
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) {

53
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

Loading…
Cancel
Save