From c040b3a7ddc828fdaa4304bd2858a2daa6ed0885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BC=D1=8F=D0=BD=20=D0=9C=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Tue, 7 May 2019 09:53:01 +0100 Subject: [PATCH] Fall back to using label for preferred devices (#4171) * Skips setting undefined device id to sink in audio preview. * Fallbacks to use labels for user selected devices. * Fixes comment. --- conference.js | 34 ++-- modules/devices/mediaDeviceHelper.js | 14 +- react/features/base/devices/actions.js | 8 +- react/features/base/devices/functions.js | 37 ++++- react/features/base/devices/middleware.js | 9 +- .../base/media/components/AbstractAudio.js | 7 +- react/features/base/settings/functions.js | 149 ++++++++++++++++++ react/features/base/settings/reducer.js | 5 +- react/features/base/tracks/functions.js | 10 +- react/features/device-selection/actions.js | 12 +- .../components/AudioOutputPreview.js | 1 + react/features/device-selection/functions.js | 11 +- 12 files changed, 261 insertions(+), 36 deletions(-) diff --git a/conference.js b/conference.js index 5037978daa..adaca2c971 100644 --- a/conference.js +++ b/conference.js @@ -718,13 +718,21 @@ export default { this.roomName = options.roomName; return ( - this.createInitialLocalTracksAndConnect( + + // Initialize the device list first. This way, when creating tracks + // based on preferred devices, loose label matching can be done in + // cases where the exact ID match is no longer available, such as + // when the camera device has switched USB ports. + this._initDeviceList() + .catch(error => logger.warn( + 'initial device list initialization failed', error)) + .then(() => this.createInitialLocalTracksAndConnect( options.roomName, { startAudioOnly: config.startAudioOnly, startScreenSharing: config.startScreenSharing, startWithAudioMuted: config.startWithAudioMuted, startWithVideoMuted: config.startWithVideoMuted - }) + })) .then(([ tracks, con ]) => { tracks.forEach(track => { if ((track.isAudioTrack() && this.isLocalAudioMuted()) @@ -769,7 +777,10 @@ export default { this.setVideoMuteStatus(true); } - this._initDeviceList(); + // Initialize device list a second time to ensure device labels + // get populated in case of an initial gUM acceptance; otherwise + // they may remain as empty strings. + this._initDeviceList(true); if (config.iAmRecorder) { this.recorder = new Recorder(); @@ -2277,20 +2288,23 @@ export default { }, /** - * Inits list of current devices and event listener for device change. + * Updates the list of current devices. + * @param {boolean} setDeviceListChangeHandler - Whether to add the deviceList change handlers. * @private * @returns {Promise} */ - _initDeviceList() { + _initDeviceList(setDeviceListChangeHandler = false) { const { mediaDevices } = JitsiMeetJS; if (mediaDevices.isDeviceListAvailable() && mediaDevices.isDeviceChangeAvailable()) { - this.deviceChangeListener = devices => - window.setTimeout(() => this._onDeviceListChanged(devices), 0); - mediaDevices.addEventListener( - JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED, - this.deviceChangeListener); + if (setDeviceListChangeHandler) { + this.deviceChangeListener = devices => + window.setTimeout(() => this._onDeviceListChanged(devices), 0); + mediaDevices.addEventListener( + JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED, + this.deviceChangeListener); + } const { dispatch } = APP.store; diff --git a/modules/devices/mediaDeviceHelper.js b/modules/devices/mediaDeviceHelper.js index c47654ed80..03627cef85 100644 --- a/modules/devices/mediaDeviceHelper.js +++ b/modules/devices/mediaDeviceHelper.js @@ -1,6 +1,11 @@ /* global APP, JitsiMeetJS */ import { getAudioOutputDeviceId } from '../../react/features/base/devices'; +import { + getUserSelectedCameraDeviceId, + getUserSelectedMicDeviceId, + getUserSelectedOutputDeviceId +} from '../../react/features/base/settings'; /** * Determines if currently selected audio output device should be changed after @@ -26,8 +31,7 @@ function getNewAudioOutputDevice(newDevices) { return 'default'; } - const settings = APP.store.getState()['features/base/settings']; - const preferredAudioOutputDeviceId = settings.userSelectedAudioOutputDeviceId; + const preferredAudioOutputDeviceId = getUserSelectedOutputDeviceId(APP.store.getState()); // if the preferred one is not the selected and is available in the new devices // we want to use it as it was just added @@ -49,8 +53,7 @@ function getNewAudioOutputDevice(newDevices) { function getNewAudioInputDevice(newDevices, localAudio) { const availableAudioInputDevices = newDevices.filter( d => d.kind === 'audioinput'); - const settings = APP.store.getState()['features/base/settings']; - const selectedAudioInputDeviceId = settings.userSelectedMicDeviceId; + const selectedAudioInputDeviceId = getUserSelectedMicDeviceId(APP.store.getState()); const selectedAudioInputDevice = availableAudioInputDevices.find( d => d.deviceId === selectedAudioInputDeviceId); @@ -88,8 +91,7 @@ function getNewAudioInputDevice(newDevices, localAudio) { function getNewVideoInputDevice(newDevices, localVideo) { const availableVideoInputDevices = newDevices.filter( d => d.kind === 'videoinput'); - const settings = APP.store.getState()['features/base/settings']; - const selectedVideoInputDeviceId = settings.userSelectedCameraDeviceId; + const selectedVideoInputDeviceId = getUserSelectedCameraDeviceId(APP.store.getState()); const selectedVideoInputDevice = availableVideoInputDevices.find( d => d.deviceId === selectedVideoInputDeviceId); diff --git a/react/features/base/devices/actions.js b/react/features/base/devices/actions.js index fa3a1ab9e5..48903bcf24 100644 --- a/react/features/base/devices/actions.js +++ b/react/features/base/devices/actions.js @@ -1,5 +1,8 @@ import JitsiMeetJS from '../lib-jitsi-meet'; -import { updateSettings } from '../settings'; +import { + getUserSelectedOutputDeviceId, + updateSettings +} from '../settings'; import { ADD_PENDING_DEVICE_REQUEST, @@ -91,8 +94,7 @@ export function configureInitialDevices() { return updateSettingsPromise .then(() => { - const { userSelectedAudioOutputDeviceId } - = getState()['features/base/settings']; + const userSelectedAudioOutputDeviceId = getUserSelectedOutputDeviceId(getState()); return setAudioOutputDeviceId(userSelectedAudioOutputDeviceId, dispatch) .catch(ex => logger.warn(`Failed to set audio output device. diff --git a/react/features/base/devices/functions.js b/react/features/base/devices/functions.js index 7a6125af23..b3a3a7c6a2 100644 --- a/react/features/base/devices/functions.js +++ b/react/features/base/devices/functions.js @@ -67,6 +67,34 @@ export function getDeviceIdByLabel(state: Object, label: string, kind: string) { } } +/** + * Finds a device with a label that matches the passed id and returns its label. + * + * @param {Object} state - The redux state. + * @param {string} id - The device id. + * @param {string} kind - The type of the device. One of "audioInput", + * "audioOutput", and "videoInput". Also supported is all lowercase versions + * of the preceding types. + * @returns {string|undefined} + */ +export function getDeviceLabelById(state: Object, id: string, kind: string) { + const webrtcKindToJitsiKindTranslator = { + audioinput: 'audioInput', + audiooutput: 'audioOutput', + videoinput: 'videoInput' + }; + + const kindToSearch = webrtcKindToJitsiKindTranslator[kind] || kind; + + const device + = (state['features/base/devices'].availableDevices[kindToSearch] || []) + .find(d => d.deviceId === id); + + if (device) { + return device.label; + } +} + /** * Returns the devices set in the URL. * @@ -118,24 +146,29 @@ export function groupDevicesByKind(devices: Object[]): Object { * @param {string} newId - New audio output device id. * @param {Function} dispatch - The Redux dispatch function. * @param {boolean} userSelection - Whether this is a user selection update. + * @param {?string} newLabel - New audio output device label to store. * @returns {Promise} */ export function setAudioOutputDeviceId( newId: string = 'default', dispatch: Function, - userSelection: boolean = false): Promise<*> { + userSelection: boolean = false, + newLabel: ?string): Promise<*> { return JitsiMeetJS.mediaDevices.setAudioOutputDevice(newId) .then(() => { const newSettings = { audioOutputDeviceId: newId, - userSelectedAudioOutputDeviceId: undefined + userSelectedAudioOutputDeviceId: undefined, + userSelectedAudioOutputDeviceLabel: undefined }; if (userSelection) { newSettings.userSelectedAudioOutputDeviceId = newId; + newSettings.userSelectedAudioOutputDeviceLabel = newLabel; } else { // a flow workaround, I needed to add 'userSelectedAudioOutputDeviceId: undefined' delete newSettings.userSelectedAudioOutputDeviceId; + delete newSettings.userSelectedAudioOutputDeviceLabel; } return dispatch(updateSettings(newSettings)); diff --git a/react/features/base/devices/middleware.js b/react/features/base/devices/middleware.js index 3be6e14400..87d9c8bfc6 100644 --- a/react/features/base/devices/middleware.js +++ b/react/features/base/devices/middleware.js @@ -147,7 +147,8 @@ function _useDevice({ dispatch }, device) { switch (device.kind) { case 'videoinput': { dispatch(updateSettings({ - userSelectedCameraDeviceId: device.deviceId + userSelectedCameraDeviceId: device.deviceId, + userSelectedCameraDeviceLabel: device.label })); dispatch(setVideoInputDevice(device.deviceId)); @@ -155,7 +156,8 @@ function _useDevice({ dispatch }, device) { } case 'audioinput': { dispatch(updateSettings({ - userSelectedMicDeviceId: device.deviceId + userSelectedMicDeviceId: device.deviceId, + userSelectedMicDeviceLabel: device.label })); dispatch(setAudioInputDevice(device.deviceId)); @@ -165,7 +167,8 @@ function _useDevice({ dispatch }, device) { setAudioOutputDeviceId( device.deviceId, dispatch, - true) + true, + device.label) .then(() => logger.log('changed audio output device')) .catch(err => { logger.warn( diff --git a/react/features/base/media/components/AbstractAudio.js b/react/features/base/media/components/AbstractAudio.js index a6287aa781..157475724d 100644 --- a/react/features/base/media/components/AbstractAudio.js +++ b/react/features/base/media/components/AbstractAudio.js @@ -2,6 +2,8 @@ import { Component } from 'react'; +const logger = require('jitsi-meet-logger').getLogger(__filename); + /** * Describes audio element interface used in the base/media feature for audio * playback. @@ -10,7 +12,7 @@ export type AudioElement = { currentTime: number, pause: () => void, play: () => void, - setSinkId?: string => void, + setSinkId?: string => Function, stop: () => void }; @@ -113,7 +115,8 @@ export default class AbstractAudio extends Component { setSinkId(sinkId: string): void { this._audioElementImpl && typeof this._audioElementImpl.setSinkId === 'function' - && this._audioElementImpl.setSinkId(sinkId); + && this._audioElementImpl.setSinkId(sinkId) + .catch(error => logger.error('Error setting sink', error)); } /** diff --git a/react/features/base/settings/functions.js b/react/features/base/settings/functions.js index c09864af8f..5cf108e61d 100644 --- a/react/features/base/settings/functions.js +++ b/react/features/base/settings/functions.js @@ -97,3 +97,152 @@ export function getServerURL(stateful: Object | Function) { return state['features/base/settings'].serverURL || DEFAULT_SERVER_URL; } + +/** + * Searches known devices for a matching deviceId and fall back to matching on + * label. Returns the stored preferred cameraDeviceId if a match is not found. + * + * @param {Object|Function} stateful - The redux state object or + * {@code getState} function. + * @returns {string} + */ +export function getUserSelectedCameraDeviceId(stateful: Object | Function) { + const state = toState(stateful); + const { + userSelectedCameraDeviceId, + userSelectedCameraDeviceLabel + } = state['features/base/settings']; + const { videoInput } = state['features/base/devices'].availableDevices; + + return _getUserSelectedDeviceId({ + availableDevices: videoInput, + + // Operating systems may append " #{number}" somewhere in the label so + // find and strip that bit. + matchRegex: /\s#\d*(?!.*\s#\d*)/, + userSelectedDeviceId: userSelectedCameraDeviceId, + userSelectedDeviceLabel: userSelectedCameraDeviceLabel, + replacement: '' + }); +} + +/** + * Searches known devices for a matching deviceId and fall back to matching on + * label. Returns the stored preferred micDeviceId if a match is not found. + * + * @param {Object|Function} stateful - The redux state object or + * {@code getState} function. + * @returns {string} + */ +export function getUserSelectedMicDeviceId(stateful: Object | Function) { + const state = toState(stateful); + const { + userSelectedMicDeviceId, + userSelectedMicDeviceLabel + } = state['features/base/settings']; + const { audioInput } = state['features/base/devices'].availableDevices; + + return _getUserSelectedDeviceId({ + availableDevices: audioInput, + + // Operating systems may append " ({number}-" somewhere in the label so + // find and strip that bit. + matchRegex: /\s\(\d*-\s(?!.*\s\(\d*-\s)/, + userSelectedDeviceId: userSelectedMicDeviceId, + userSelectedDeviceLabel: userSelectedMicDeviceLabel, + replacement: ' (' + }); +} + +/** + * Searches known devices for a matching deviceId and fall back to matching on + * label. Returns the stored preferred audioOutputDeviceId if a match is not found. + * + * @param {Object|Function} stateful - The redux state object or + * {@code getState} function. + * @returns {string} + */ +export function getUserSelectedOutputDeviceId(stateful: Object | Function) { + const state = toState(stateful); + const { + userSelectedAudioOutputDeviceId, + userSelectedAudioOutputDeviceLabel + } = state['features/base/settings']; + const { audioOutput } = state['features/base/devices'].availableDevices; + + return _getUserSelectedDeviceId({ + availableDevices: audioOutput, + matchRegex: undefined, + userSelectedDeviceId: userSelectedAudioOutputDeviceId, + userSelectedDeviceLabel: userSelectedAudioOutputDeviceLabel, + replacement: undefined + }); +} + +/** + * A helper function to abstract the logic for choosing which device ID to + * use. Falls back to fuzzy matching on label if a device ID match is not found. + * + * @param {Object} options - The arguments used to match find the preferred + * device ID from available devices. + * @param {Array} options.availableDevices - The array of currently + * available devices to match against. + * @param {Object} options.matchRegex - The regex to use to find strings + * appended to the label by the operating system. The matches will be replaced + * with options.replacement, with the intent of matching the same device that + * might have a modified label. + * @param {string} options.userSelectedDeviceId - The device ID the participant + * prefers to use. + * @param {string} options.userSelectedDeviceLabel - The label associated with the + * device ID the participant prefers to use. + * @param {string} options.replacement - The string to use with + * options.matchRegex to remove identifies added to the label by the operating + * system. + * @private + * @returns {string} The preferred device ID to use for media. + */ +function _getUserSelectedDeviceId(options) { + const { + availableDevices, + matchRegex, + userSelectedDeviceId, + userSelectedDeviceLabel, + replacement + } = options; + + // If there is no label at all, there is no need to fall back to checking + // the label for a fuzzy match. + if (!userSelectedDeviceLabel || !userSelectedDeviceId) { + return userSelectedDeviceId; + } + + const foundMatchingBasedonDeviceId = availableDevices.find( + candidate => candidate.deviceId === userSelectedDeviceId); + + // Prioritize matching the deviceId + if (foundMatchingBasedonDeviceId) { + return userSelectedDeviceId; + } + + const strippedDeviceLabel + = matchRegex ? userSelectedDeviceLabel.replace(matchRegex, replacement) + : userSelectedDeviceLabel; + const foundMatchBasedOnLabel = availableDevices.find(candidate => { + const { label } = candidate; + + if (!label) { + return false; + } else if (strippedDeviceLabel === label) { + return true; + } + + const strippedCandidateLabel + = label.replace(matchRegex, replacement); + + return strippedDeviceLabel === strippedCandidateLabel; + }); + + return foundMatchBasedOnLabel + ? foundMatchBasedOnLabel.deviceId : userSelectedDeviceId; +} + diff --git a/react/features/base/settings/reducer.js b/react/features/base/settings/reducer.js index a9746054dd..b483d941f4 100644 --- a/react/features/base/settings/reducer.js +++ b/react/features/base/settings/reducer.js @@ -33,7 +33,10 @@ const DEFAULT_STATE = { startWithVideoMuted: false, userSelectedAudioOutputDeviceId: undefined, userSelectedCameraDeviceId: undefined, - userSelectedMicDeviceId: undefined + userSelectedMicDeviceId: undefined, + userSelectedAudioOutputDeviceLabel: undefined, + userSelectedCameraDeviceLabel: undefined, + userSelectedMicDeviceLabel: undefined }; const STORE_NAME = 'features/base/settings'; diff --git a/react/features/base/tracks/functions.js b/react/features/base/tracks/functions.js index 2dce7ae5b9..abfa595880 100644 --- a/react/features/base/tracks/functions.js +++ b/react/features/base/tracks/functions.js @@ -3,6 +3,10 @@ import JitsiMeetJS, { JitsiTrackErrors, JitsiTrackEvents } from '../lib-jitsi-meet'; import { MEDIA_TYPE } from '../media'; +import { + getUserSelectedCameraDeviceId, + getUserSelectedMicDeviceId +} from '../settings'; const logger = require('jitsi-meet-logger').getLogger(__filename); @@ -37,13 +41,13 @@ export function createLocalTracksF( // reliance on the global variable APP will go away. store || (store = APP.store); // eslint-disable-line no-param-reassign - const settings = store.getState()['features/base/settings']; + const state = store.getState(); if (typeof cameraDeviceId === 'undefined' || cameraDeviceId === null) { - cameraDeviceId = settings.userSelectedCameraDeviceId; + cameraDeviceId = getUserSelectedCameraDeviceId(state); } if (typeof micDeviceId === 'undefined' || micDeviceId === null) { - micDeviceId = settings.userSelectedMicDeviceId; + micDeviceId = getUserSelectedMicDeviceId(state); } } diff --git a/react/features/device-selection/actions.js b/react/features/device-selection/actions.js index 6c08e13aba..dde51dfc7f 100644 --- a/react/features/device-selection/actions.js +++ b/react/features/device-selection/actions.js @@ -6,6 +6,7 @@ import { import { createDeviceChangedEvent, sendAnalytics } from '../analytics'; import { + getDeviceLabelById, setAudioInputDevice, setAudioOutputDeviceId, setVideoInputDevice @@ -112,7 +113,9 @@ export function submitDeviceSelectionTab(newState) { && newState.selectedVideoInputId !== currentState.selectedVideoInputId) { dispatch(updateSettings({ - userSelectedCameraDeviceId: newState.selectedVideoInputId + userSelectedCameraDeviceId: newState.selectedVideoInputId, + userSelectedCameraDeviceLabel: + getDeviceLabelById(getState(), newState.selectedVideoInputId, 'videoInput') })); dispatch( @@ -123,7 +126,9 @@ export function submitDeviceSelectionTab(newState) { && newState.selectedAudioInputId !== currentState.selectedAudioInputId) { dispatch(updateSettings({ - userSelectedMicDeviceId: newState.selectedAudioInputId + userSelectedMicDeviceId: newState.selectedAudioInputId, + userSelectedMicDeviceLabel: + getDeviceLabelById(getState(), newState.selectedAudioInputId, 'audioInput') })); dispatch( @@ -138,7 +143,8 @@ export function submitDeviceSelectionTab(newState) { setAudioOutputDeviceId( newState.selectedAudioOutputId, dispatch, - true) + true, + getDeviceLabelById(getState(), newState.selectedAudioOutputId, 'audioOutput')) .then(() => logger.log('changed audio output device')) .catch(err => { logger.warn( diff --git a/react/features/device-selection/components/AudioOutputPreview.js b/react/features/device-selection/components/AudioOutputPreview.js index e5cbc954b2..0d855be67f 100644 --- a/react/features/device-selection/components/AudioOutputPreview.js +++ b/react/features/device-selection/components/AudioOutputPreview.js @@ -113,6 +113,7 @@ class AudioOutputPreview extends Component { */ _setAudioSink() { this._audioElement + && this.props.deviceId && this._audioElement.setSinkId(this.props.deviceId); } } diff --git a/react/features/device-selection/functions.js b/react/features/device-selection/functions.js index f5fd6fda11..be908c907c 100644 --- a/react/features/device-selection/functions.js +++ b/react/features/device-selection/functions.js @@ -15,6 +15,11 @@ import { } from '../base/devices'; import JitsiMeetJS from '../base/lib-jitsi-meet'; import { toState } from '../base/redux'; +import { + getUserSelectedCameraDeviceId, + getUserSelectedMicDeviceId, + getUserSelectedOutputDeviceId +} from '../base/settings'; /** * Returns the properties for the device selection dialog from Redux state. @@ -38,9 +43,9 @@ export function getDeviceSelectionDialogProps(stateful: Object | Function) { // on welcome page we also show only what we have saved as user selected devices if (!conference) { disableAudioInputChange = false; - selectedAudioInputId = settings.userSelectedMicDeviceId; - selectedAudioOutputId = settings.userSelectedAudioOutputDeviceId; - selectedVideoInputId = settings.userSelectedCameraDeviceId; + selectedAudioInputId = getUserSelectedMicDeviceId(state); + selectedAudioOutputId = getUserSelectedOutputDeviceId(state); + selectedVideoInputId = getUserSelectedCameraDeviceId(state); } // we fill the device selection dialog with the devices that are currently