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.
This commit is contained in:
parent
3f4a71c26d
commit
c040b3a7dd
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<Props> {
|
|||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<string>} 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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -113,6 +113,7 @@ class AudioOutputPreview extends Component<Props> {
|
|||
*/
|
||||
_setAudioSink() {
|
||||
this._audioElement
|
||||
&& this.props.deviceId
|
||||
&& this._audioElement.setSinkId(this.props.deviceId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue