+
+
+
+
+
diff --git a/src/components/RightSidebar/RightSidebar.vue b/src/components/RightSidebar/RightSidebar.vue
index 2ca6af13eb7..6d694bdcb37 100644
--- a/src/components/RightSidebar/RightSidebar.vue
+++ b/src/components/RightSidebar/RightSidebar.vue
@@ -27,6 +27,7 @@
:title="title"
:starred="isFavorited"
:title-editable="canModerate && isRenamingConversation"
+ @update:active="handleUpdateActive"
@update:starred="onFavoriteChange"
@update:title="handleUpdateTitle"
@submit-title="handleSubmitTitle"
@@ -66,6 +67,13 @@
icon="icon-settings">
+
+
+
@@ -73,6 +81,7 @@
import AppSidebar from '@nextcloud/vue/dist/Components/AppSidebar'
import AppSidebarTab from '@nextcloud/vue/dist/Components/AppSidebarTab'
import ChatView from '../ChatView'
+import MediaDevicesPreview from '../MediaDevicesPreview'
import { CollectionList } from 'nextcloud-vue-collections'
import BrowserStorage from '../../services/BrowserStorage'
import { CONVERSATION, WEBINAR, PARTICIPANT } from '../../constants'
@@ -92,6 +101,7 @@ export default {
AppSidebarTab,
ChatView,
CollectionList,
+ MediaDevicesPreview,
ParticipantsTab,
SetGuestUsername,
},
@@ -109,6 +119,7 @@ export default {
data() {
return {
+ activeTab: null,
contactsLoading: false,
// The conversation name (while editing)
conversationName: '',
@@ -215,6 +226,10 @@ export default {
this.conversation.isFavorite = !this.conversation.isFavorite
},
+ handleUpdateActive(active) {
+ this.activeTab = active
+ },
+
/**
* Updates the conversationName value while editing the conversation's title.
* @param {string} title the conversation title emitted by the AppSidevar vue
diff --git a/src/utils/webrtc/MediaDevicesManager.js b/src/utils/webrtc/MediaDevicesManager.js
new file mode 100644
index 00000000000..a8a8e0bf35c
--- /dev/null
+++ b/src/utils/webrtc/MediaDevicesManager.js
@@ -0,0 +1,318 @@
+/**
+ *
+ * @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 .
+ *
+ */
+
+/**
+ * Wrapper for MediaDevices to simplify its use.
+ *
+ * The MediaDevicesManager keeps an updated list of devices that can be accessed
+ * from "attributes.devices". Clients of this class must call
+ * "enableDeviceEvents()" to start keeping track of the devices, and
+ * "disableDeviceEvents()" once it is no longer needed. Eventually there must be
+ * one call to "disableDeviceEvents()" for each call to "enableDeviceEvents()",
+ * but several clients can be active at the same time.
+ *
+ * Each element of "attributes.devices" is an object with the following fields:
+ * - deviceId: the unique identifier for the device
+ * - groupId: two or more devices have the same groupId if they belong to the
+ * same physical device
+ * - kind: either "audioinput", "videoinput" or "audiooutput"
+ * - label: a human readable identifier for the device
+ * - fallbackLabel: a generated label if the actual label is empty
+ *
+ * Note that the list may not contain some kind of devices due to browser
+ * limitations (for example, currently Firefox does not list "audiooutput"
+ * devices).
+ *
+ * In some browsers if persistent media permissions have not been granted and a
+ * MediaStream is not active the list may contain at most one device of each
+ * kind, and all of them with empty attributes except for the kind.
+ *
+ * In other browsers just the label may not be available if persistent media
+ * permissions have not been granted and a MediaStream has not been active. In
+ * 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)".
+ *
+ * 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
+ * devices of that kind the selected device will fall back to the first one
+ * found, or to the one with the "default" id (if any). It is possible to
+ * explicitly disable devices of certain kind by setting xxxInputId to "null"
+ * (in that case the fallback devices will not be taken into account).
+ */
+export default function MediaDevicesManager() {
+ this.attributes = {
+ devices: [],
+
+ audioInputId: undefined,
+ videoInputId: undefined,
+ }
+
+ this._enabledCount = 0
+
+ this._knownDevices = {}
+
+ this._fallbackAudioInputId = undefined
+ this._fallbackVideoInputId = undefined
+
+ this._updateDevicesBound = this._updateDevices.bind(this)
+}
+MediaDevicesManager.prototype = {
+
+ /**
+ * Returns whether getting user media and enumerating media devices is
+ * supported or not.
+ *
+ * Note that even if false is returned the MediaDevices interface could be
+ * technically supported by the browser but not available due to the page
+ * being loaded in an insecure context.
+ *
+ * @returns {boolean} true if MediaDevices interface is supported, false
+ * otherwise.
+ */
+ isSupported: function() {
+ return navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia && navigator.mediaDevices.enumerateDevices
+ },
+
+ enableDeviceEvents: function() {
+ if (!this.isSupported()) {
+ return
+ }
+
+ this._enabledCount++
+
+ this._updateDevices()
+
+ navigator.mediaDevices.addEventListener('devicechange', this._updateDevicesBound)
+ },
+
+ disableDeviceEvents: function() {
+ if (!this.isSupported()) {
+ return
+ }
+
+ this._enabledCount--
+
+ if (!this._enabledCount) {
+ navigator.mediaDevices.removeEventListener('devicechange', this._updateDevicesBound)
+ }
+ },
+
+ _updateDevices: function() {
+ navigator.mediaDevices.enumerateDevices().then(devices => {
+ 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))
+
+ removedDevices.forEach(removedDevice => {
+ this._removeDevice(removedDevice)
+ })
+ updatedDevices.forEach(updatedDevice => {
+ this._updateDevice(updatedDevice)
+ })
+ addedDevices.forEach(addedDevice => {
+ this._addDevice(addedDevice)
+ })
+ }).catch(function(error) {
+ console.error('Could not update known media devices: ' + error.name + ': ' + error.message)
+ })
+ },
+
+ _removeDevice: function(removedDevice) {
+ const removedDeviceIndex = this.attributes.devices.findIndex(oldDevice => oldDevice.deviceId === removedDevice.deviceId && oldDevice.kind === removedDevice.kind)
+ if (removedDeviceIndex >= 0) {
+ this.attributes.devices.splice(removedDeviceIndex, 1)
+ }
+
+ if (removedDevice.kind === 'audioinput') {
+ if (this._fallbackAudioInputId === removedDevice.deviceId) {
+ const firstAudioInputDevice = this.attributes.devices.find(device => device.kind === 'audioinput')
+ this._fallbackAudioInputId = firstAudioInputDevice ? firstAudioInputDevice.deviceId : undefined
+ }
+ if (this.attributes.audioInputId === removedDevice.deviceId) {
+ this.attributes.audioInputId = this._fallbackAudioInputId
+ }
+ } else if (removedDevice.kind === 'videoinput') {
+ if (this._fallbackVideoInputId === removedDevice.deviceId) {
+ const firstVideoInputDevice = this.attributes.devices.find(device => device.kind === 'videoinput')
+ this._fallbackVideoInputId = firstVideoInputDevice ? firstVideoInputDevice.deviceId : undefined
+ }
+ if (this.attributes.videoInputId === removedDevice.deviceId) {
+ this.attributes.videoInputId = this._fallbackVideoInputId
+ }
+ }
+ },
+
+ _updateDevice: function(updatedDevice) {
+ const oldDevice = this.attributes.devices.find(oldDevice => oldDevice.deviceId === updatedDevice.deviceId && oldDevice.kind === updatedDevice.kind)
+
+ // Only update the label if it has a value, as it may have been
+ // removed if there is currently no active stream.
+ if (updatedDevice.label) {
+ oldDevice.label = updatedDevice.label
+ }
+
+ // These should not have changed, but just in case
+ oldDevice.groupId = updatedDevice.groupId
+ oldDevice.kind = updatedDevice.kind
+ },
+
+ _addDevice: function(addedDevice) {
+ // Copy the device to add, as its properties are read only and
+ // thus they can not be updated later.
+ addedDevice = {
+ deviceId: addedDevice.deviceId,
+ groupId: addedDevice.groupId,
+ kind: addedDevice.kind,
+ label: addedDevice.label,
+ }
+
+ const knownDevice = this._knownDevices[addedDevice.kind + '-' + addedDevice.deviceId]
+ if (knownDevice) {
+ addedDevice.fallbackLabel = knownDevice.fallbackLabel
+ // If the added device has a label keep it; otherwise use
+ // the previously known one, if any.
+ addedDevice.label = addedDevice.label ? addedDevice.label : knownDevice.label
+ } else {
+ // Generate a fallback label to be used when the actual label is
+ // not available.
+ if (addedDevice.deviceId === 'default' || addedDevice.deviceId === '') {
+ addedDevice.fallbackLabel = t('spreed', 'Default')
+ } else if (addedDevice.kind === 'audioinput') {
+ addedDevice.fallbackLabel = t('spreed', 'Microphone {number}', { number: Object.values(this._knownDevices).filter(device => device.kind === 'audioinput' && device.deviceId !== '').length + 1 })
+ } else if (addedDevice.kind === 'videoinput') {
+ addedDevice.fallbackLabel = t('spreed', 'Camera {number}', { number: Object.values(this._knownDevices).filter(device => device.kind === 'videoinput' && device.deviceId !== '').length + 1 })
+ } else if (addedDevice.kind === 'audiooutput') {
+ addedDevice.fallbackLabel = t('spreed', 'Speaker {number}', { number: Object.values(this._knownDevices).filter(device => device.kind === 'audioutput' && device.deviceId !== '').length + 1 })
+ }
+ }
+
+ // Always refresh the known device with the latest values.
+ this._knownDevices[addedDevice.kind + '-' + addedDevice.deviceId] = addedDevice
+
+ // Set first available device as fallback, and override any
+ // fallback previously set if the default device is added.
+ if (addedDevice.kind === 'audioinput') {
+ if (!this._fallbackAudioInputId || addedDevice.deviceId === 'default') {
+ this._fallbackAudioInputId = addedDevice.deviceId
+ }
+ if (this.attributes.audioInputId === undefined) {
+ this.attributes.audioInputId = this._fallbackAudioInputId
+ }
+ } else if (addedDevice.kind === 'videoinput') {
+ if (!this._fallbackVideoInputId || addedDevice.deviceId === 'default') {
+ this._fallbackVideoInputId = addedDevice.deviceId
+ }
+ if (this.attributes.videoInputId === undefined) {
+ this.attributes.videoInputId = this._fallbackVideoInputId
+ }
+ }
+
+ this.attributes.devices.push(addedDevice)
+ },
+
+ /**
+ * Wrapper for MediaDevices.getUserMedia to use the selected audio and video
+ * input devices.
+ *
+ * The selected audio and video input devices are used only if the
+ * constraints do not specify a device already. Otherwise the devices in the
+ * constraints are respected.
+ *
+ * @param {MediaStreamConstraints} constraints the constraints specifying
+ * the media to request
+ * @returns {Promise} resolved with a MediaStream object when successful, or
+ * rejected with a DOMException in case of error
+ */
+ getUserMedia: function(constraints) {
+ if (!this.isSupported()) {
+ return new Promise((resolve, reject) => {
+ reject(new DOMException('MediaDevicesManager is not supported', 'NotSupportedError'))
+ })
+ }
+
+ if (constraints.audio && !constraints.audio.deviceId) {
+ if (this.attributes.audioInputId) {
+ if (!(constraints.audio instanceof Object)) {
+ constraints.audio = {}
+ }
+ constraints.audio.deviceId = this.attributes.audioInputId
+ } else if (this.attributes.audioInputId === null) {
+ constraints.audio = false
+ }
+ }
+
+ if (constraints.video && !constraints.video.deviceId) {
+ if (this.attributes.videoInputId) {
+ if (!(constraints.video instanceof Object)) {
+ constraints.video = {}
+ }
+ constraints.video.deviceId = this.attributes.videoInputId
+ } else if (this.attributes.videoInputId === null) {
+ constraints.video = false
+ }
+ }
+
+ return navigator.mediaDevices.getUserMedia(constraints).then(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.
+ this._updateSelectedDevicesFromGetUserMediaResult(stream)
+
+ // The list of devices is always updated when a stream is started as
+ // that is the only time at which the full device information is
+ // guaranteed to be available.
+ this._updateDevices()
+
+ return stream
+ }).catch(error => {
+ // The list of devices is also updated in case of failure, as even
+ // if getting the stream failed the permissions may have been
+ // permanently granted.
+ this._updateDevices()
+
+ throw error
+ })
+ },
+
+ _updateSelectedDevicesFromGetUserMediaResult: function(stream) {
+ if (this.attributes.audioInputId) {
+ const audioTracks = stream.getAudioTracks()
+ const audioTrackSettings = audioTracks.length > 0 ? audioTracks[0].getSettings() : null
+ 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
+ }
+ }
+
+ if (this.attributes.videoInputId) {
+ const videoTracks = stream.getVideoTracks()
+ const videoTrackSettings = videoTracks.length > 0 ? videoTracks[0].getSettings() : null
+ 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
+ }
+ }
+ },
+}
diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js
index 3d6300cf78d..4f5aa5a4907 100644
--- a/src/utils/webrtc/index.js
+++ b/src/utils/webrtc/index.js
@@ -27,6 +27,7 @@ import CallAnalyzer from './analyzers/CallAnalyzer'
import CallParticipantCollection from './models/CallParticipantCollection'
import LocalCallParticipantModel from './models/LocalCallParticipantModel'
import LocalMediaModel from './models/LocalMediaModel'
+import MediaDevicesManager from './MediaDevicesManager'
import SentVideoQualityThrottler from './SentVideoQualityThrottler'
import { PARTICIPANT } from '../../constants'
import { fetchSignalingSettings } from '../../services/signalingService'
@@ -35,6 +36,7 @@ let webRtc = null
const callParticipantCollection = new CallParticipantCollection()
const localCallParticipantModel = new LocalCallParticipantModel()
const localMediaModel = new LocalMediaModel()
+const mediaDevicesManager = new MediaDevicesManager()
let callAnalyzer = null
let sentVideoQualityThrottler = null
@@ -225,6 +227,8 @@ export {
localCallParticipantModel,
localMediaModel,
+ mediaDevicesManager,
+
callAnalyzer,
signalingJoinConversation,
diff --git a/src/utils/webrtc/simplewebrtc/localmedia.js b/src/utils/webrtc/simplewebrtc/localmedia.js
index b49a4d744c7..61b0955d22e 100644
--- a/src/utils/webrtc/simplewebrtc/localmedia.js
+++ b/src/utils/webrtc/simplewebrtc/localmedia.js
@@ -5,6 +5,9 @@ const hark = require('hark')
const getScreenMedia = require('./getscreenmedia')
const WildEmitter = require('wildemitter')
const mockconsole = require('mockconsole')
+// Only mediaDevicesManager is used, but it can not be assigned here due to not
+// being initialized yet.
+const webrtcIndex = require('../index.js')
function isAllTracksEnded(stream) {
let isAllTracksEnded = true
@@ -43,7 +46,7 @@ function LocalMedia(opts) {
this._audioMonitorStreams = []
this.localScreens = []
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ if (!webrtcIndex.mediaDevicesManager.isSupported()) {
this._logerror('Your browser does not support local media capture.')
}
@@ -86,7 +89,7 @@ LocalMedia.prototype.start = function(mediaConstraints, cb, context) {
const self = this
const constraints = mediaConstraints || this.config.media
- if (!navigator || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ if (!webrtcIndex.mediaDevicesManager.isSupported()) {
const error = new Error('MediaStreamError')
error.name = 'NotSupportedError'
@@ -99,7 +102,7 @@ LocalMedia.prototype.start = function(mediaConstraints, cb, context) {
this.emit('localStreamRequested', constraints, context)
- navigator.mediaDevices.getUserMedia(constraints).then(function(stream) {
+ webrtcIndex.mediaDevicesManager.getUserMedia(constraints).then(function(stream) {
// Although the promise should be resolved only if all the constraints
// are met Edge resolves it if both audio and video are requested but
// only audio is available.