feat(welcome-page): be able to open settings dialog (#3327)

* feat(welcome-page): be able to open settings dialog

- Create a getter for getting a settings tab's props so the device
  selection tab can get updated available devices.
- Be able to call a function from a tab after it has mounted. This is
  used for device selection to essentially call enumerateDevices on
  the welcome page so the device selectors are populated.
- Remove event UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED. Instead directly call
  setAudioOutputDeviceId where possible.
- Fix initialization of the audioOutputDeviceId in settings by defaulting
  the audio output device to the one set in settings.

* squash: updateAvailableDevices -> getAvailableDevices, add comment for propsUpdateFunction
This commit is contained in:
virtuacoplenny 2018-08-06 08:24:59 -07:00 committed by yanas
parent 81853d971a
commit cac8888b37
13 changed files with 186 additions and 94 deletions

View File

@ -45,6 +45,7 @@ import {
setDesktopSharingEnabled setDesktopSharingEnabled
} from './react/features/base/conference'; } from './react/features/base/conference';
import { import {
getAvailableDevices,
setAudioOutputDeviceId, setAudioOutputDeviceId,
updateDeviceList updateDeviceList
} from './react/features/base/devices'; } from './react/features/base/devices';
@ -2129,20 +2130,6 @@ export default {
} }
); );
APP.UI.addListener(
UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED,
audioOutputDeviceId => {
sendAnalytics(createDeviceChangedEvent('audio', 'output'));
setAudioOutputDeviceId(audioOutputDeviceId, APP.store.dispatch)
.then(() => logger.log('changed audio output device'))
.catch(err => {
logger.warn('Failed to change audio output device. '
+ 'Default or previously set audio output device '
+ 'will be used instead.', err);
});
}
);
APP.UI.addListener(UIEvents.TOGGLE_AUDIO_ONLY, audioOnly => { APP.UI.addListener(UIEvents.TOGGLE_AUDIO_ONLY, audioOnly => {
// FIXME On web video track is stored both in redux and in // FIXME On web video track is stored both in redux and in
@ -2314,39 +2301,43 @@ export default {
/** /**
* Inits list of current devices and event listener for device change. * Inits list of current devices and event listener for device change.
* @private * @private
* @returns {Promise}
*/ */
_initDeviceList() { _initDeviceList() {
const { mediaDevices } = JitsiMeetJS; const { mediaDevices } = JitsiMeetJS;
if (mediaDevices.isDeviceListAvailable() if (mediaDevices.isDeviceListAvailable()
&& mediaDevices.isDeviceChangeAvailable()) { && mediaDevices.isDeviceChangeAvailable()) {
mediaDevices.enumerateDevices(devices => {
// Ugly way to synchronize real device IDs with local storage
// and settings menu. This is a workaround until
// getConstraints() method will be implemented in browsers.
const { dispatch } = APP.store;
if (this.localAudio) {
dispatch(updateSettings({
micDeviceId: this.localAudio.getDeviceId()
}));
}
if (this.localVideo) {
dispatch(updateSettings({
cameraDeviceId: this.localVideo.getDeviceId()
}));
}
APP.store.dispatch(updateDeviceList(devices));
APP.UI.onAvailableDevicesChanged(devices);
});
this.deviceChangeListener = devices => this.deviceChangeListener = devices =>
window.setTimeout(() => this._onDeviceListChanged(devices), 0); window.setTimeout(() => this._onDeviceListChanged(devices), 0);
mediaDevices.addEventListener( mediaDevices.addEventListener(
JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED, JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED,
this.deviceChangeListener); this.deviceChangeListener);
const { dispatch } = APP.store;
return dispatch(getAvailableDevices())
.then(devices => {
// Ugly way to synchronize real device IDs with local
// storage and settings menu. This is a workaround until
// getConstraints() method will be implemented in browsers.
if (this.localAudio) {
dispatch(updateSettings({
micDeviceId: this.localAudio.getDeviceId()
}));
}
if (this.localVideo) {
dispatch(updateSettings({
cameraDeviceId: this.localVideo.getDeviceId()
}));
}
APP.UI.onAvailableDevicesChanged(devices);
});
} }
return Promise.resolve();
}, },
/** /**

View File

@ -9,17 +9,6 @@
*/ */
export const SET_AUDIO_INPUT_DEVICE = Symbol('SET_AUDIO_INPUT_DEVICE'); export const SET_AUDIO_INPUT_DEVICE = Symbol('SET_AUDIO_INPUT_DEVICE');
/**
* The type of Redux action which signals that the currently used audio
* output device should be changed.
*
* {
* type: SET_AUDIO_OUTPUT_DEVICE,
* deviceId: string,
* }
*/
export const SET_AUDIO_OUTPUT_DEVICE = Symbol('SET_AUDIO_OUTPUT_DEVICE');
/** /**
* The type of Redux action which signals that the currently used video * The type of Redux action which signals that the currently used video
* input device should be changed. * input device should be changed.

View File

@ -1,10 +1,34 @@
import JitsiMeetJS from '../lib-jitsi-meet';
import { import {
SET_AUDIO_INPUT_DEVICE, SET_AUDIO_INPUT_DEVICE,
SET_AUDIO_OUTPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE, SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST UPDATE_DEVICE_LIST
} from './actionTypes'; } from './actionTypes';
/**
* Queries for connected A/V input and output devices and updates the redux
* state of known devices.
*
* @returns {Function}
*/
export function getAvailableDevices() {
return dispatch => new Promise(resolve => {
const { mediaDevices } = JitsiMeetJS;
if (mediaDevices.isDeviceListAvailable()
&& mediaDevices.isDeviceChangeAvailable()) {
mediaDevices.enumerateDevices(devices => {
dispatch(updateDeviceList(devices));
resolve(devices);
});
} else {
resolve([]);
}
});
}
/** /**
* Signals to update the currently used audio input device. * Signals to update the currently used audio input device.
* *
@ -21,22 +45,6 @@ export function setAudioInputDevice(deviceId) {
}; };
} }
/**
* Signals to update the currently used audio output device.
*
* @param {string} deviceId - The id of the new audio ouput device.
* @returns {{
* type: SET_AUDIO_OUTPUT_DEVICE,
* deviceId: string
* }}
*/
export function setAudioOutputDevice(deviceId) {
return {
type: SET_AUDIO_OUTPUT_DEVICE,
deviceId
};
}
/** /**
* Signals to update the currently used video input device. * Signals to update the currently used video input device.
* *

View File

@ -6,7 +6,6 @@ import { MiddlewareRegistry } from '../redux';
import { import {
SET_AUDIO_INPUT_DEVICE, SET_AUDIO_INPUT_DEVICE,
SET_AUDIO_OUTPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE SET_VIDEO_INPUT_DEVICE
} from './actionTypes'; } from './actionTypes';
@ -22,9 +21,6 @@ MiddlewareRegistry.register(store => next => action => {
case SET_AUDIO_INPUT_DEVICE: case SET_AUDIO_INPUT_DEVICE:
APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId); APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId);
break; break;
case SET_AUDIO_OUTPUT_DEVICE:
APP.UI.emitEvent(UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED, action.deviceId);
break;
case SET_VIDEO_INPUT_DEVICE: case SET_VIDEO_INPUT_DEVICE:
APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId); APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId);
break; break;

View File

@ -1,6 +1,5 @@
import { import {
SET_AUDIO_INPUT_DEVICE, SET_AUDIO_INPUT_DEVICE,
SET_AUDIO_OUTPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE, SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST UPDATE_DEVICE_LIST
} from './actionTypes'; } from './actionTypes';
@ -40,7 +39,6 @@ ReducerRegistry.register(
// now. // now.
case SET_AUDIO_INPUT_DEVICE: case SET_AUDIO_INPUT_DEVICE:
case SET_VIDEO_INPUT_DEVICE: case SET_VIDEO_INPUT_DEVICE:
case SET_AUDIO_OUTPUT_DEVICE:
default: default:
return state; return state;
} }

View File

@ -103,6 +103,28 @@ class DialogWithTabs extends Component<Props, State> {
); );
} }
/**
* Gets the props to pass into the tab component.
*
* @param {number} tabId - The index of the tab configuration within
* {@link this.state.tabStates}.
* @returns {Object}
*/
_getTabProps(tabId) {
const { tabs } = this.props;
const { tabStates } = this.state;
const tabConfiguration = tabs[tabId];
const currentTabState = tabStates[tabId];
if (tabConfiguration.propsUpdateFunction) {
return tabConfiguration.propsUpdateFunction(
currentTabState,
tabConfiguration.props);
}
return { ...currentTabState };
}
/** /**
* Renders the tabs from the tab information passed on props. * Renders the tabs from the tab information passed on props.
* *
@ -155,10 +177,11 @@ class DialogWithTabs extends Component<Props, State> {
<div className = { styles }> <div className = { styles }>
<TabComponent <TabComponent
closeDialog = { closeDialog } closeDialog = { closeDialog }
mountCallback = { this.props.tabs[tabId].onMount }
onTabStateChange onTabStateChange
= { this._onTabStateChange } = { this._onTabStateChange }
tabId = { tabId } tabId = { tabId }
{ ...this.state.tabStates[tabId] } /> { ...this._getTabProps(tabId) } />
</div>); </div>);
} }

View File

@ -138,7 +138,7 @@ function _initSettings(featureState) {
if (settings.audioOutputDeviceId if (settings.audioOutputDeviceId
!== JitsiMeetJS.mediaDevices.getAudioOutputDevice()) { !== JitsiMeetJS.mediaDevices.getAudioOutputDevice()) {
JitsiMeetJS.mediaDevices.setAudioOutputDevice( JitsiMeetJS.mediaDevices.setAudioOutputDevice(
audioOutputDeviceId settings.audioOutputDeviceId
).catch(ex => { ).catch(ex => {
logger.warn('Failed to set audio output device from local ' logger.warn('Failed to set audio output device from local '
+ 'storage. Default audio output device will be used' + 'storage. Default audio output device will be used'

View File

@ -4,18 +4,22 @@ import {
Transport Transport
} from '../../../modules/transport'; } from '../../../modules/transport';
import { createDeviceChangedEvent, sendAnalytics } from '../analytics';
import { import {
getAudioOutputDeviceId, getAudioOutputDeviceId,
setAudioInputDevice, setAudioInputDevice,
setAudioOutputDevice, setAudioOutputDeviceId,
setVideoInputDevice setVideoInputDevice
} from '../base/devices'; } from '../base/devices';
import { i18next } from '../base/i18n'; import { i18next } from '../base/i18n';
import JitsiMeetJS from '../base/lib-jitsi-meet'; import JitsiMeetJS from '../base/lib-jitsi-meet';
import { updateSettings } from '../base/settings';
import { SET_DEVICE_SELECTION_POPUP_DATA } from './actionTypes'; import { SET_DEVICE_SELECTION_POPUP_DATA } from './actionTypes';
import { getDeviceSelectionDialogProps } from './functions'; import { getDeviceSelectionDialogProps } from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/** /**
* Opens a popup window with the device selection dialog in it. * Opens a popup window with the device selection dialog in it.
* *
@ -115,23 +119,22 @@ function _processRequest(dispatch, getState, request, responseCallback) { // esl
responseCallback(getState()['features/base/devices']); responseCallback(getState()['features/base/devices']);
break; break;
case 'setDevice': { case 'setDevice': {
let action;
const { device } = request; const { device } = request;
switch (device.kind) { switch (device.kind) {
case 'audioinput': case 'audioinput':
action = setAudioInputDevice; dispatch(setAudioInputDevice(device.id));
break; break;
case 'audiooutput': case 'audiooutput':
action = setAudioOutputDevice; setAudioOutputDeviceId(device.id, dispatch);
break; break;
case 'videoinput': case 'videoinput':
action = setVideoInputDevice; dispatch(setVideoInputDevice(device.id));
break; break;
default: default:
} }
dispatch(action(device.id));
responseCallback(true); responseCallback(true);
break; break;
} }
@ -179,6 +182,10 @@ export function submitDeviceSelectionTab(newState) {
if (newState.selectedVideoInputId if (newState.selectedVideoInputId
&& newState.selectedVideoInputId && newState.selectedVideoInputId
!== currentState.selectedVideoInputId) { !== currentState.selectedVideoInputId) {
dispatch(updateSettings({
cameraDeviceId: newState.selectedVideoInputId
}));
dispatch( dispatch(
setVideoInputDevice(newState.selectedVideoInputId)); setVideoInputDevice(newState.selectedVideoInputId));
} }
@ -186,6 +193,10 @@ export function submitDeviceSelectionTab(newState) {
if (newState.selectedAudioInputId if (newState.selectedAudioInputId
&& newState.selectedAudioInputId && newState.selectedAudioInputId
!== currentState.selectedAudioInputId) { !== currentState.selectedAudioInputId) {
dispatch(updateSettings({
micDeviceId: newState.selectedAudioInputId
}));
dispatch( dispatch(
setAudioInputDevice(newState.selectedAudioInputId)); setAudioInputDevice(newState.selectedAudioInputId));
} }
@ -193,8 +204,19 @@ export function submitDeviceSelectionTab(newState) {
if (newState.selectedAudioOutputId if (newState.selectedAudioOutputId
&& newState.selectedAudioOutputId && newState.selectedAudioOutputId
!== currentState.selectedAudioOutputId) { !== currentState.selectedAudioOutputId) {
dispatch( sendAnalytics(createDeviceChangedEvent('audio', 'output'));
setAudioOutputDevice(newState.selectedAudioOutputId));
setAudioOutputDeviceId(
newState.selectedAudioOutputId,
dispatch)
.then(() => logger.log('changed audio output device'))
.catch(err => {
logger.warn(
'Failed to change audio output device.',
'Default or previously set audio output device will',
' be used instead.',
err);
});
} }
}; };
} }

View File

@ -12,6 +12,8 @@ import AudioOutputPreview from './AudioOutputPreview';
import DeviceSelector from './DeviceSelector'; import DeviceSelector from './DeviceSelector';
import VideoInputPreview from './VideoInputPreview'; import VideoInputPreview from './VideoInputPreview';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/** /**
* The type of the React {@code Component} props of {@link DeviceSelection}. * The type of the React {@code Component} props of {@link DeviceSelection}.
*/ */
@ -64,6 +66,12 @@ export type Props = {
*/ */
hideAudioOutputSelect: boolean, hideAudioOutputSelect: boolean,
/**
* An optional callback to invoke after the component has completed its
* mount logic.
*/
mountCallback?: Function,
/** /**
* The id of the audio input device to preview. * The id of the audio input device to preview.
*/ */
@ -134,8 +142,12 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
* @inheritdoc * @inheritdoc
*/ */
componentDidMount() { componentDidMount() {
this._createAudioInputTrack(this.props.selectedAudioInputId); Promise.all([
this._createVideoInputTrack(this.props.selectedVideoInputId); this._createAudioInputTrack(this.props.selectedAudioInputId),
this._createVideoInputTrack(this.props.selectedVideoInputId)
])
.catch(err => logger.warn('Failed to initialize preview tracks', err))
.then(() => this.props.mountCallback && this.props.mountCallback());
} }
/** /**
@ -212,7 +224,7 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
* @returns {void} * @returns {void}
*/ */
_createAudioInputTrack(deviceId) { _createAudioInputTrack(deviceId) {
this._disposeAudioInputPreview() return this._disposeAudioInputPreview()
.then(() => createLocalTrack('audio', deviceId)) .then(() => createLocalTrack('audio', deviceId))
.then(jitsiLocalTrack => { .then(jitsiLocalTrack => {
this.setState({ this.setState({
@ -234,7 +246,7 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
* @returns {void} * @returns {void}
*/ */
_createVideoInputTrack(deviceId) { _createVideoInputTrack(deviceId) {
this._disposeVideoInputPreview() return this._disposeVideoInputPreview()
.then(() => createLocalTrack('video', deviceId)) .then(() => createLocalTrack('video', deviceId))
.then(jitsiLocalTrack => { .then(jitsiLocalTrack => {
if (!jitsiLocalTrack) { if (!jitsiLocalTrack) {

View File

@ -3,6 +3,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getAvailableDevices } from '../../../base/devices';
import { DialogWithTabs, hideDialog } from '../../../base/dialog'; import { DialogWithTabs, hideDialog } from '../../../base/dialog';
import { import {
DeviceSelection, DeviceSelection,
@ -77,6 +78,9 @@ class SettingsDialog extends Component<Props> {
const tabs = _tabs.map(tab => { const tabs = _tabs.map(tab => {
return { return {
...tab, ...tab,
onMount: tab.onMount
? (...args) => dispatch(tab.onMount(...args))
: undefined,
submit: (...args) => dispatch(tab.submit(...args)) submit: (...args) => dispatch(tab.submit(...args))
}; };
}); });
@ -133,7 +137,22 @@ function _mapStateToProps(state) {
name: SETTINGS_TABS.DEVICES, name: SETTINGS_TABS.DEVICES,
component: DeviceSelection, component: DeviceSelection,
label: 'settings.devices', label: 'settings.devices',
onMount: getAvailableDevices,
props: getDeviceSelectionDialogProps(state), props: getDeviceSelectionDialogProps(state),
propsUpdateFunction: (tabState, newProps) => {
// Ensure the device selection tab gets updated when new devices
// are found by taking the new props and only preserving the
// current user selected devices. If this were not done, the
// tab would keep using a copy of the initial props it received,
// leaving the device list to become stale.
return {
...newProps,
selectedAudioInputId: tabState.selectedAudioInputId,
selectedAudioOutputId: tabState.selectedAudioOutputId,
selectedVideoInputId: tabState.selectedVideoInputId
};
},
styles: 'settings-pane devices-pane', styles: 'settings-pane devices-pane',
submit: submitDeviceSelectionTab submit: submitDeviceSelectionTab
}); });

View File

@ -74,24 +74,30 @@ export function shouldShowOnlyDeviceSelection() {
export function getMoreTabProps(stateful: Object | Function) { export function getMoreTabProps(stateful: Object | Function) {
const state = toState(stateful); const state = toState(stateful);
const language = i18next.language || DEFAULT_LANGUAGE; const language = i18next.language || DEFAULT_LANGUAGE;
const conference = state['features/base/conference']; const {
conference,
followMeEnabled,
startAudioMutedPolicy,
startVideoMutedPolicy
} = state['features/base/conference'];
const configuredTabs = interfaceConfig.SETTINGS_SECTIONS || []; const configuredTabs = interfaceConfig.SETTINGS_SECTIONS || [];
const localParticipant = getLocalParticipant(state); const localParticipant = getLocalParticipant(state);
// The settings sections to display. // The settings sections to display.
const showModeratorSettings const showModeratorSettings = Boolean(
= configuredTabs.includes('moderator') conference
&& localParticipant.role === PARTICIPANT_ROLE.MODERATOR; && configuredTabs.includes('moderator')
&& localParticipant.role === PARTICIPANT_ROLE.MODERATOR);
return { return {
currentLanguage: language, currentLanguage: language,
followMeEnabled: Boolean(conference.followMeEnabled), followMeEnabled: Boolean(conference && followMeEnabled),
languages: LANGUAGES, languages: LANGUAGES,
showLanguageSettings: configuredTabs.includes('language'), showLanguageSettings: configuredTabs.includes('language'),
showModeratorSettings, showModeratorSettings,
startAudioMuted: Boolean(conference.startAudioMutedPolicy), startAudioMuted: Boolean(conference && startAudioMutedPolicy),
startVideoMuted: Boolean(conference.startVideoMutedPolicy) startVideoMuted: Boolean(conference && startVideoMutedPolicy)
}; };
} }
@ -106,12 +112,16 @@ export function getMoreTabProps(stateful: Object | Function) {
*/ */
export function getProfileTabProps(stateful: Object | Function) { export function getProfileTabProps(stateful: Object | Function) {
const state = toState(stateful); const state = toState(stateful);
const conference = state['features/base/conference']; const {
authEnabled,
authLogin,
conference
} = state['features/base/conference'];
const localParticipant = getLocalParticipant(state); const localParticipant = getLocalParticipant(state);
return { return {
authEnabled: conference.authEnabled, authEnabled: Boolean(conference && authEnabled),
authLogin: conference.authLogin, authLogin: Boolean(conference && authLogin),
displayName: localParticipant.name, displayName: localParticipant.name,
email: localParticipant.email email: localParticipant.email
}; };

View File

@ -6,9 +6,11 @@ import { AtlasKitThemeProvider } from '@atlaskit/theme';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { DialogContainer } from '../../base/dialog';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { Watermarks } from '../../base/react'; import { Watermarks } from '../../base/react';
import { RecentList } from '../../recent-list'; import { RecentList } from '../../recent-list';
import { openSettingsDialog } from '../../settings';
import { AbstractWelcomePage, _mapStateToProps } from './AbstractWelcomePage'; import { AbstractWelcomePage, _mapStateToProps } from './AbstractWelcomePage';
@ -18,6 +20,15 @@ import { AbstractWelcomePage, _mapStateToProps } from './AbstractWelcomePage';
* @extends AbstractWelcomePage * @extends AbstractWelcomePage
*/ */
class WelcomePage extends AbstractWelcomePage { class WelcomePage extends AbstractWelcomePage {
/**
* Default values for {@code WelcomePage} component's properties.
*
* @static
*/
static defaultProps = {
_room: ''
};
/** /**
* Initializes a new WelcomePage instance. * Initializes a new WelcomePage instance.
* *
@ -55,6 +66,7 @@ class WelcomePage extends AbstractWelcomePage {
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onFormSubmit = this._onFormSubmit.bind(this); this._onFormSubmit = this._onFormSubmit.bind(this);
this._onOpenSettings = this._onOpenSettings.bind(this);
this._onRoomChange = this._onRoomChange.bind(this); this._onRoomChange = this._onRoomChange.bind(this);
this._setAdditionalContentRef this._setAdditionalContentRef
= this._setAdditionalContentRef.bind(this); = this._setAdditionalContentRef.bind(this);
@ -155,6 +167,9 @@ class WelcomePage extends AbstractWelcomePage {
ref = { this._setAdditionalContentRef } /> ref = { this._setAdditionalContentRef } />
: null } : null }
</div> </div>
<AtlasKitThemeProvider mode = 'dark'>
<DialogContainer />
</AtlasKitThemeProvider>
</AtlasKitThemeProvider> </AtlasKitThemeProvider>
); );
} }
@ -172,6 +187,16 @@ class WelcomePage extends AbstractWelcomePage {
this._onJoin(); this._onJoin();
} }
/**
* Opens {@code SettingsDialog}.
*
* @private
* @returns {void}
*/
_onOpenSettings() {
this.props.dispatch(openSettingsDialog());
}
/** /**
* Overrides the super to account for the differences in the argument types * Overrides the super to account for the differences in the argument types
* provided by HTML and React Native text inputs. * provided by HTML and React Native text inputs.

View File

@ -63,7 +63,6 @@ export default {
LOGOUT: 'UI.logout', LOGOUT: 'UI.logout',
VIDEO_DEVICE_CHANGED: 'UI.video_device_changed', VIDEO_DEVICE_CHANGED: 'UI.video_device_changed',
AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed', AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed',
AUDIO_OUTPUT_DEVICE_CHANGED: 'UI.audio_output_device_changed',
/** /**
* Notifies interested listeners that the follow-me feature is enabled or * Notifies interested listeners that the follow-me feature is enabled or