diff --git a/bbb-voice-conference/config/freeswitch/conf/autoload_configs/conference.conf.xml b/bbb-voice-conference/config/freeswitch/conf/autoload_configs/conference.conf.xml index 739511f0a8ed5fc51c73cad6b857d5ed487e30b9..fa37e9452fd48657eabad78c4c8358d4d28b7271 100644 --- a/bbb-voice-conference/config/freeswitch/conf/autoload_configs/conference.conf.xml +++ b/bbb-voice-conference/config/freeswitch/conf/autoload_configs/conference.conf.xml @@ -216,6 +216,7 @@ <param name="caller-id-number" value="$${outbound_caller_id}"/> <!-- param name="comfort-noise" value="true"/ --> <param name="comfort-noise" value="1400"/> + <param name="video-auto-floor-msec" value="2000"/> <!-- <param name="conference-flags" value="video-floor-only|rfc-4579|livearray-sync|auto-3d-position|minimize-video-encoding"/> --> diff --git a/bbb-voice-conference/config/freeswitch/conf/autoload_configs/logfile.conf.xml b/bbb-voice-conference/config/freeswitch/conf/autoload_configs/logfile.conf.xml index 670bae957f9ec66c1c49dab4db3c899e3a385b9f..abbd2f737af48e5004023eea799d9b72ce37e9e3 100644 --- a/bbb-voice-conference/config/freeswitch/conf/autoload_configs/logfile.conf.xml +++ b/bbb-voice-conference/config/freeswitch/conf/autoload_configs/logfile.conf.xml @@ -9,10 +9,10 @@ <!-- File to log to --> <!--<param name="logfile" value="/var/log/freeswitch.log"/>--> <!-- At this length in bytes rotate the log file (0 for never) --> - <param name="rollover" value="1048576000"/> + <param name="rollover" value="104857600"/> <!-- Maximum number of log files to keep before wrapping --> <!-- If this parameter is enabled, the log filenames will not include a date stamp --> - <param name="maximum-rotate" value="32"/> + <param name="maximum-rotate" value="10"/> <!-- Prefix all log lines by the session's uuid --> <param name="uuid" value="true" /> </settings> @@ -22,7 +22,7 @@ value is one or more of debug,info,notice,warning,err,crit,alert,all Please see comments in console.conf.xml for more information --> - <map name="all" value="console,debug,info,notice,warning,err,crit,alert"/> + <map name="all" value="info,notice,warning,err,crit,alert"/> </mappings> </profile> </profiles> diff --git a/bigbluebutton-html5/.eslintrc.js b/bigbluebutton-html5/.eslintrc.js index a43e3c5f16931c9653447587abf9cfef215b8dc4..b93b3575a49bb8e24ac18da4470a96be92138116 100644 --- a/bigbluebutton-html5/.eslintrc.js +++ b/bigbluebutton-html5/.eslintrc.js @@ -24,6 +24,7 @@ module.exports = { 'react/prop-types': 1, 'jsx-a11y/no-access-key': 0, 'react/jsx-props-no-spreading': 'off', + 'max-classes-per-file': ['error', 2], }, globals: { browser: 'writable', diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js index ac0d4a93cfb3183aa12c3aec82ce547604004a14..ca41fc8f87cbace78d7b33e51e0749257983d686 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js @@ -119,28 +119,69 @@ class SIPSession { return matchConstraints; } - setInputStream(stream) { - if (!this.currentSession - || !this.currentSession.sessionDescriptionHandler - ) return; + /** + * Set the input stream for the peer that represents the current session. + * Internally, this will call the sender's replaceTrack function. + * @param {MediaStream} stream The MediaStream object to be used as input + * stream + * @return {Promise} A Promise that is resolved with the + * MediaStream object that was set. + */ + async setInputStream(stream) { + try { + if (!this.currentSession + || !this.currentSession.sessionDescriptionHandler + ) return null; + await this.currentSession.sessionDescriptionHandler + .setLocalMediaStream(stream); - this.currentSession.sessionDescriptionHandler.setLocalMediaStream(stream); + return stream; + } catch (error) { + logger.warn({ + logCode: 'sip_js_setinputstream_error', + extraInfo: { + errorCode: error.code, + errorMessage: error.message, + callerIdName: this.user.callerIdName, + }, + }, 'Failed to set input stream (mic)'); + return null; + } } + /** + * Change the input device with the given deviceId, without renegotiating + * peer. + * A new MediaStream object is created for the given deviceId. This object + * is returned by the resolved promise. + * @param {String} deviceId The id of the device to be set as input + * @return {Promise} A promise that is resolved with the MediaStream + * object after changing the input device. + */ + async liveChangeInputDevice(deviceId) { + try { + this.inputDeviceId = deviceId; - liveChangeInputDevice(deviceId) { - this.inputDeviceId = deviceId; - - const constraints = { - audio: this.getAudioConstraints(), - }; + const constraints = { + audio: this.getAudioConstraints(), + }; - this.inputStream.getAudioTracks().forEach(t => t.stop()); + this.inputStream.getAudioTracks().forEach((t) => t.stop()); - return navigator.mediaDevices.getUserMedia(constraints).then((stream) => { - this.setInputStream(stream); - }); + return await navigator.mediaDevices.getUserMedia(constraints) + .then(this.setInputStream.bind(this)); + } catch (error) { + logger.warn({ + logCode: 'sip_js_livechangeinputdevice_error', + extraInfo: { + errorCode: error.code, + errorMessage: error.message, + callerIdName: this.user.callerIdName, + }, + }, 'Failed to change input device (mic)'); + return null; + } } get inputDeviceId() { @@ -149,7 +190,7 @@ class SIPSession { if (stream) { const track = stream.getAudioTracks().find( - t => t.getSettings().deviceId, + (t) => t.getSettings().deviceId, ); if (track && (typeof track.getSettings === 'function')) { diff --git a/bigbluebutton-html5/imports/startup/client/base.jsx b/bigbluebutton-html5/imports/startup/client/base.jsx index 5186879e1cdfc392bccfdd07b968e7c9d21452bb..8af4e57b2869a482c963684a97735a3cc954eff8 100755 --- a/bigbluebutton-html5/imports/startup/client/base.jsx +++ b/bigbluebutton-html5/imports/startup/client/base.jsx @@ -19,7 +19,6 @@ import { notify } from '/imports/ui/services/notification'; import deviceInfo from '/imports/utils/deviceInfo'; import { invalidateCookie } from '/imports/ui/components/audio/audio-modal/service'; import getFromUserSettings from '/imports/ui/services/users-settings'; -import LayoutManagerComponent from '/imports/ui/components/layout/layout-manager/component'; import LayoutManagerContainer from '/imports/ui/components/layout/layout-manager/container'; import { withLayoutContext } from '/imports/ui/components/layout/context'; import VideoService from '/imports/ui/components/video-provider/service'; diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx index 0d2565a0f557816453c6c92c90e69ee317a9cb28..26c232b3f462c5dfa1d131a01b73f5e4039c08aa 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx @@ -21,7 +21,9 @@ import MediaService, { const ActionsBarContainer = (props) => { const usingUsersContext = useContext(UsersContext); const { users } = usingUsersContext; - const currentUser = users[Auth.meetingID][Auth.userID]; + + const currentUser = { userId: Auth.userID, emoji: users[Auth.meetingID][Auth.userID].emoji }; + return ( <ActionsBar { ...{ diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx index 8c40bf140b98bddf73366468fcff18b0522cc57d..9b9c082da510cd6e7a67173866b1b761c5e64f60 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx @@ -30,6 +30,7 @@ const intlMessages = defineMessages({ }); const propTypes = { + shortcuts: PropTypes.objectOf(PropTypes.string).isRequired, processToggleMuteFromOutside: PropTypes.func.isRequired, handleToggleMuteMicrophone: PropTypes.func.isRequired, handleJoinAudio: PropTypes.func.isRequired, @@ -88,8 +89,11 @@ class AudioControls extends PureComponent { ); } - static renderLeaveButtonWithLiveStreamSelector() { - return (<InputStreamLiveSelectorContainer />); + static renderLeaveButtonWithLiveStreamSelector(props) { + const { handleLeaveAudio } = props; + return ( + <InputStreamLiveSelectorContainer {...{ handleLeaveAudio }} /> + ); } renderLeaveButtonWithoutLiveStreamSelector() { @@ -151,7 +155,8 @@ class AudioControls extends PureComponent { if (inAudio) { if (_enableDynamicDeviceSelection) { - return AudioControls.renderLeaveButtonWithLiveStreamSelector(); + return AudioControls.renderLeaveButtonWithLiveStreamSelector(this + .props); } return this.renderLeaveButtonWithoutLiveStreamSelector(); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx index f3c993f1a8a60454bf93c8ac96e0df8a22cfcda8..aed2c2f38397c51b9940cf5a97c5e0c94acf7e93 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx @@ -11,14 +11,23 @@ import Storage from '/imports/ui/services/storage/session'; import getFromUserSettings from '/imports/ui/services/users-settings'; import AudioControls from './component'; import AudioModalContainer from '../audio-modal/container'; -import { invalidateCookie } from '../audio-modal/service'; +import { + setUserSelectedMicrophone, + setUserSelectedListenOnly, +} from '../audio-modal/service'; + import Service from '../service'; import AppService from '/imports/ui/components/app/service'; const ROLE_VIEWER = Meteor.settings.public.user.role_viewer; const APP_CONFIG = Meteor.settings.public.app; -const AudioControlsContainer = props => <AudioControls {...props} />; +const AudioControlsContainer = (props) => { + const { + users, lockSettings, userLocks, children, ...newProps + } = props; + return <AudioControls {...newProps} />; +}; const processToggleMuteFromOutside = (e) => { switch (e.data) { @@ -46,7 +55,8 @@ const handleLeaveAudio = () => { const meetingIsBreakout = AppService.meetingIsBreakout(); if (!meetingIsBreakout) { - invalidateCookie('joinedAudio'); + setUserSelectedMicrophone(false); + setUserSelectedListenOnly(false); } const skipOnFistJoin = getFromUserSettings('bbb_skip_check_audio_on_first_join', APP_CONFIG.skipCheckOnJoin); @@ -88,7 +98,7 @@ export default withUsersConsumer(lockContextContainer(withModalMounter(withTrack } return ({ - processToggleMuteFromOutside: arg => processToggleMuteFromOutside(arg), + processToggleMuteFromOutside: (arg) => processToggleMuteFromOutside(arg), showMute: isConnected() && !isListenOnly() && !isEchoTest() && !userLocks.userMic, muted: isConnected() && !isListenOnly() && isMuted(), inAudio: isConnected() && !isEchoTest(), diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx index 535588a3e45efb9e705cb96e9dcaac60e56ad456..94c2186c4e8f7f98ebae4fdbcf50446ef77d3524 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx @@ -50,7 +50,7 @@ const intlMessages = defineMessages({ const propTypes = { liveChangeInputDevice: PropTypes.func.isRequired, - exitAudio: PropTypes.func.isRequired, + handleLeaveAudio: PropTypes.func.isRequired, liveChangeOutputDevice: PropTypes.func.isRequired, intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired, @@ -267,7 +267,7 @@ class InputStreamLiveSelector extends Component { const { liveChangeInputDevice, - exitAudio, + handleLeaveAudio, liveChangeOutputDevice, intl, shortcuts, @@ -299,7 +299,7 @@ class InputStreamLiveSelector extends Component { key="leaveAudioButtonKey" className={styles.stopButton} label={intl.formatMessage(intlMessages.leaveAudio)} - onClick={() => exitAudio()} + onClick={() => handleLeaveAudio()} accessKey={shortcuts.leaveaudio} />, ]); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx index 4c0c04c0ed989a21fc23275bcce2d162ecd7deea..86e4dca851a9b428f04922eefab6ab71c6e0a6c3 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx @@ -11,14 +11,12 @@ class InputStreamLiveSelectorContainer extends PureComponent { } } -export default withTracker(() => { - return { - isAudioConnected: Service.isConnected(), - isListenOnly: Service.isListenOnly(), - currentInputDeviceId: Service.inputDeviceId(), - currentOutputDeviceId: Service.outputDeviceId(), - liveChangeInputDevice: Service.liveChangeInputDevice, - liveChangeOutputDevice: Service.changeOutputDevice, - exitAudio: Service.exitAudio, - }; -})(InputStreamLiveSelectorContainer); +export default withTracker(({ handleLeaveAudio }) => ({ + isAudioConnected: Service.isConnected(), + isListenOnly: Service.isListenOnly(), + currentInputDeviceId: Service.inputDeviceId(), + currentOutputDeviceId: Service.outputDeviceId(), + liveChangeInputDevice: Service.liveChangeInputDevice, + liveChangeOutputDevice: Service.changeOutputDevice, + handleLeaveAudio, +}))(InputStreamLiveSelectorContainer); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx index 24b8cff217babd4aa96d9ad983ef769f554d6147..b0b0488af0e4607b55d0948aee47c0b3f0190293 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx @@ -15,13 +15,11 @@ import { closeModal, joinListenOnly, leaveEchoTest, - getcookieData, } from './service'; import Storage from '/imports/ui/services/storage/session'; import Service from '../service'; -const AudioModalContainer = props => <AudioModal {...props} />; - +const AudioModalContainer = (props) => <AudioModal {...props} />; const APP_CONFIG = Meteor.settings.public.app; @@ -50,7 +48,6 @@ export default lockContextContainer(withModalMounter(withTracker(({ userLocks }) } const meetingIsBreakout = AppService.meetingIsBreakout(); - const { joinedAudio } = getcookieData(); const joinFullAudioImmediately = (autoJoin && (skipCheck || skipCheckOnJoin && !getEchoTest)) || (skipCheck || skipCheckOnJoin && !getEchoTest); @@ -61,14 +58,15 @@ export default lockContextContainer(withModalMounter(withTracker(({ userLocks }) const { isChrome, isIe } = browserInfo; return ({ - joinedAudio, meetingIsBreakout, closeModal, - joinMicrophone: skipEchoTest => joinMicrophone(skipEchoTest || skipCheck || skipCheckOnJoin), + joinMicrophone: (skipEchoTest) => joinMicrophone(skipEchoTest || skipCheck || skipCheckOnJoin), joinListenOnly, leaveEchoTest, - changeInputDevice: inputDeviceId => Service.changeInputDevice(inputDeviceId), - changeOutputDevice: outputDeviceId => Service.changeOutputDevice(outputDeviceId), + changeInputDevice: (inputDeviceId) => Service + .changeInputDevice(inputDeviceId), + changeOutputDevice: (outputDeviceId) => Service + .changeOutputDevice(outputDeviceId), joinEchoTest: () => Service.joinEchoTest(), exitAudio: () => Service.exitAudio(), isConnecting: Service.isConnecting(), diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/service.js b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/service.js index afb7d098ec406b2fcbb0316863c7eea7c89987e9..738f2ef7ffa9be7d4cf5c74067dbbc61bcaec4b7 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/service.js +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/service.js @@ -1,39 +1,29 @@ import { showModal } from '/imports/ui/components/modal/service'; import Service from '../service'; -import AppService from '/imports/ui/components/app/service'; +import Storage from '/imports/ui/services/storage/session'; -export const getcookieData = () => { - const cookiesString = document.cookie; - const cookies = cookiesString.split(';'); - const cookiesKeyValue = cookies.reduce((acc, value) => { - if (!value) return acc; - const splitValue = value.trim().split('='); - return { - ...acc, - [splitValue[0]]: splitValue[1], - }; - }, {}); - return cookiesKeyValue; -}; +const CLIENT_DID_USER_SELECTED_MICROPHONE_KEY = 'clientUserSelectedMicrophone'; +const CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY = 'clientUserSelectedListenOnly'; -export const setCookieData = (name, value, daysToExpire = 1) => { - const date = new Date(); - date.setDate(date.getDate() + daysToExpire); - document.cookie = `${name}=${value};expires=${date.toUTCString()}`; -}; +export const setUserSelectedMicrophone = (value) => ( + Storage.setItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY, !!value) +); -export const invalidateCookie = (name) => { - const cookies = getcookieData(); - if (cookies[name]) { - // set the expires date to current date invalid the cookie - setCookieData(name, false, 0); - return true; - } - return false; -}; +export const setUserSelectedListenOnly = (value) => ( + Storage.setItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY, !!value) +); + +export const didUserSelectedMicrophone = () => ( + !!Storage.getItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY) +); + +export const didUserSelectedListenOnly = () => ( + !!Storage.getItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY) +); -export const joinMicrophone = (skipEchoTest = false, changeInputDevice = false) => { - const meetingIsBreakout = AppService.meetingIsBreakout(); +export const joinMicrophone = (skipEchoTest = false) => { + Storage.setItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY, true); + Storage.setItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY, false); const call = new Promise((resolve, reject) => { if (skipEchoTest) { @@ -45,17 +35,6 @@ export const joinMicrophone = (skipEchoTest = false, changeInputDevice = false) }); return call.then(() => { - const { inputAudioId } = getcookieData(); - - if (changeInputDevice && inputAudioId) { - Service.changeInputDevice(inputAudioId); - } - - if (!meetingIsBreakout) { - const inputDeviceId = Service.inputDeviceId(); - setCookieData('joinedAudio', true, 1); - setCookieData('inputAudioId', inputDeviceId, 1); - } showModal(null); }).catch((error) => { throw error; @@ -63,6 +42,9 @@ export const joinMicrophone = (skipEchoTest = false, changeInputDevice = false) }; export const joinListenOnly = () => { + Storage.setItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY, false); + Storage.setItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY, true); + const call = new Promise((resolve) => { Service.joinListenOnly().then(() => { // Autoplay block wasn't triggered. Close the modal. If autoplay was @@ -96,4 +78,6 @@ export default { closeModal, joinListenOnly, leaveEchoTest, + didUserSelectedMicrophone, + didUserSelectedListenOnly, }; diff --git a/bigbluebutton-html5/imports/ui/components/audio/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/container.jsx index d2d48b1df6c9b0c94228b29f96c0b9d55ec8cf41..81f37ef27e2a0557583776dbbbd7dc0a78df6703 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/container.jsx @@ -1,4 +1,5 @@ import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; import { withTracker } from 'meteor/react-meteor-data'; import { Session } from 'meteor/session'; import { withModalMounter } from '/imports/ui/components/modal/service'; @@ -10,7 +11,13 @@ import { notify } from '/imports/ui/services/notification'; import getFromUserSettings from '/imports/ui/services/users-settings'; import VideoPreviewContainer from '/imports/ui/components/video-preview/container'; import lockContextContainer from '/imports/ui/components/lock-viewers/context/container'; -import { getcookieData, joinMicrophone } from '/imports/ui/components/audio/audio-modal/service'; +import { + joinMicrophone, + joinListenOnly, + didUserSelectedMicrophone, + didUserSelectedListenOnly, +} from '/imports/ui/components/audio/audio-modal/service'; + import Service from './service'; import AudioModalContainer from './audio-modal/container'; import Settings from '/imports/ui/services/settings'; @@ -73,20 +80,54 @@ class AudioContainer extends PureComponent { } componentDidMount() { - const { meetingIsBreakout, joinedAudio } = this.props; + const { meetingIsBreakout } = this.props; + this.init(); - if (meetingIsBreakout && joinedAudio) { - joinMicrophone(true, true); + + if (meetingIsBreakout) { + this.joinAudio(); } } componentDidUpdate(prevProps) { - const { hasBreakoutRooms, joinedAudio } = this.props; + if (this.userIsReturningFromBreakoutRoom(prevProps)) { + this.joinAudio(); + } + } + + /** + * Helper function to determine wheter user is returning from breakout room + * to main room. + * @param {[Object} prevProps prevProps param from componentDidUpdate + * @return {boolean} True if user is returning from breakout room + * to main room. False, otherwise. + */ + userIsReturningFromBreakoutRoom(prevProps) { + const { hasBreakoutRooms } = this.props; const { hasBreakoutRooms: hadBreakoutRooms } = prevProps; - if (hadBreakoutRooms && !hasBreakoutRooms && joinedAudio - && !Service.isConnected()) { - joinMicrophone(true, true); + return hadBreakoutRooms && !hasBreakoutRooms; + } + + /** + * Helper function that join (or not) user in audio. If user previously + * selected microphone, it will automatically join mic (without audio modal). + * If user previously selected listen only option in audio modal, then it will + * automatically join listen only. + */ + joinAudio() { + if (Service.isConnected()) return; + + const { + userSelectedMicrophone, + userSelectedListenOnly, + } = this.props; + + if (userSelectedMicrophone) { + joinMicrophone(true); + return; } + + if (userSelectedListenOnly) joinListenOnly(); } render() { @@ -126,7 +167,9 @@ export default lockContextContainer(withModalMounter(injectIntl(withTracker(({ m const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo); const autoShareWebcam = getFromUserSettings('bbb_auto_share_webcam', KURENTO_CONFIG.autoShareWebcam); const { userWebcam, userMic } = userLocks; - const { joinedAudio } = getcookieData(); + + const userSelectedMicrophone = didUserSelectedMicrophone(); + const userSelectedListenOnly = didUserSelectedListenOnly(); const meetingIsBreakout = AppService.meetingIsBreakout(); const hasBreakoutRooms = AppService.getBreakoutRooms().length > 0; const openAudioModal = () => new Promise((resolve) => { @@ -152,7 +195,13 @@ export default lockContextContainer(withModalMounter(injectIntl(withTracker(({ m // if the user joined a breakout room, the main room's audio was // programmatically dropped to avoid interference. On breakout end, // offer to rejoin main room audio only if the user is not in audio already - if (Service.isUsingAudio() || joinedAudio) { + if (Service.isUsingAudio() + || userSelectedMicrophone + || userSelectedListenOnly) { + if (enableVideo && autoShareWebcam) { + openVideoPreviewModal(); + } + return; } setTimeout(() => openAudioModal().then(() => { @@ -166,7 +215,8 @@ export default lockContextContainer(withModalMounter(injectIntl(withTracker(({ m return { hasBreakoutRooms, meetingIsBreakout, - joinedAudio, + userSelectedMicrophone, + userSelectedListenOnly, init: () => { Service.init(messages, intl); const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo); @@ -180,10 +230,20 @@ export default lockContextContainer(withModalMounter(injectIntl(withTracker(({ m Session.set('audioModalIsOpen', true); if (enableVideo && autoShareWebcam) { openAudioModal().then(() => { openVideoPreviewModal(); didMountAutoJoin = true; }); - } else if (!(joinedAudio && meetingIsBreakout)) { + } else if (!( + userSelectedMicrophone + && userSelectedListenOnly + && meetingIsBreakout)) { openAudioModal(); didMountAutoJoin = true; } }, }; })(AudioContainer)))); + +AudioContainer.propTypes = { + hasBreakoutRooms: PropTypes.bool.isRequired, + meetingIsBreakout: PropTypes.bool.isRequired, + userSelectedListenOnly: PropTypes.bool.isRequired, + userSelectedMicrophone: PropTypes.bool.isRequired, +}; diff --git a/bigbluebutton-html5/imports/ui/components/button/styles.scss b/bigbluebutton-html5/imports/ui/components/button/styles.scss index 3bb841646c32c8ed32fe3bb0a92dc8352cc6e407..ce751faeaaa5c92a84a198144370423ccce175fb 100755 --- a/bigbluebutton-html5/imports/ui/components/button/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/button/styles.scss @@ -107,9 +107,7 @@ display: inline-block; cursor: pointer; - &:-moz-focusring { - outline: none; - } + &:focus, &:hover { @@ -120,6 +118,12 @@ outline-style: solid; } + &:-moz-focusring { + outline-color: transparent; + outline-offset: var(--border-radius); + } + + &:active { &:focus { span:first-of-type::before { diff --git a/bigbluebutton-html5/imports/ui/components/components-data/chat-context/context.jsx b/bigbluebutton-html5/imports/ui/components/components-data/chat-context/context.jsx index 134bfc6f753da79d13d8846b73579e724bacfde7..08449297ad36781c089193c10beaaea031915c7c 100644 --- a/bigbluebutton-html5/imports/ui/components/components-data/chat-context/context.jsx +++ b/bigbluebutton-html5/imports/ui/components/components-data/chat-context/context.jsx @@ -328,6 +328,7 @@ const reducer = (state, action) => { timeWindowIds.forEach((timeWindowId)=> { const timeWindow = messages[timeWindowId]; if (timeWindow.messageType === MESSAGE_TYPES.STREAM) { + chat.unreadTimeWindows.delete(timeWindowId); delete newState[chatId][group][timeWindowId]; } }); diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/component.jsx b/bigbluebutton-html5/imports/ui/components/external-video-player/component.jsx index b659cd52dc4d3f846aa4290f59cc2ba56ebe874f..a9921d00ca5f3f1608962851fca8946da9ae2c4a 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/component.jsx @@ -396,6 +396,10 @@ class VideoPlayer extends Component { this.setState({ playing: true }); this.handleFirstPlay(); + + if (!isPresenter && !playing) { + this.setState({ playing: false }); + } } handleOnPause() { @@ -410,6 +414,10 @@ class VideoPlayer extends Component { this.setState({ playing: false }); this.handleFirstPlay(); + + if (!isPresenter && playing) { + this.setState({ playing: true }); + } } render() { diff --git a/bigbluebutton-html5/imports/ui/components/layout/context.jsx b/bigbluebutton-html5/imports/ui/components/layout/context.jsx index 0cd9feda2bda3e5a383a7129408d9e7542fe8376..e250b3dc17869e1d09f24715fa1a836e2f5ec041 100644 --- a/bigbluebutton-html5/imports/ui/components/layout/context.jsx +++ b/bigbluebutton-html5/imports/ui/components/layout/context.jsx @@ -1,8 +1,6 @@ import React, { createContext, useReducer, useEffect } from 'react'; import Storage from '/imports/ui/services/storage/session'; -const { webcamsDefaultPlacement } = Meteor.settings.public.layout; - export const LayoutContext = createContext(); const initialState = { @@ -50,7 +48,7 @@ const initialState = { }, webcamsAreaUserSetsHeight: 0, webcamsAreaUserSetsWidth: 0, - webcamsPlacement: webcamsDefaultPlacement || 'top', + webcamsPlacement: 'top', presentationAreaSize: { width: 0, height: 0, diff --git a/bigbluebutton-html5/imports/ui/components/layout/layout-manager/container.jsx b/bigbluebutton-html5/imports/ui/components/layout/layout-manager/container.jsx index c38735a33e9a61f1fe8e3f42d80be4a7959ef564..2136bbc5fae43275d23d1190741d0d7c8309c901 100644 --- a/bigbluebutton-html5/imports/ui/components/layout/layout-manager/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/layout/layout-manager/container.jsx @@ -1,7 +1,5 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; -import Auth from '/imports/ui/services/auth'; -import Screenshare from '/imports/api/screenshare'; import { isVideoBroadcasting } from '/imports/ui/components/screenshare/service'; import LayoutManagerComponent from '/imports/ui/components/layout/layout-manager/component'; diff --git a/bigbluebutton-html5/imports/ui/components/modal/random-user/component.jsx b/bigbluebutton-html5/imports/ui/components/modal/random-user/component.jsx index 4c68acf9a63a1dedfc08253b60f2b4706e7066d2..667476dcac2ae3039efa9afeb9cf6d47bbdc6746 100644 --- a/bigbluebutton-html5/imports/ui/components/modal/random-user/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/modal/random-user/component.jsx @@ -107,7 +107,6 @@ class RandomUserSelect extends Component { intl, mountModal, numAvailableViewers, - randomUserReq, currentUser, clearRandomlySelectedUser, mappedRandomlySelectedUsers, diff --git a/bigbluebutton-html5/imports/ui/components/muted-alert/component.jsx b/bigbluebutton-html5/imports/ui/components/muted-alert/component.jsx index d86f24d0ad9e7f48dc515b1b2cdfa955cf6062ad..3a5d03ff4487930faba45ab60899137b835e9243 100644 --- a/bigbluebutton-html5/imports/ui/components/muted-alert/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/muted-alert/component.jsx @@ -62,16 +62,27 @@ class MutedAlert extends Component { this._isMounted = false; if (this.speechEvents) this.speechEvents.stop(); if (this.inputStream) { - this.inputStream.getTracks().forEach(t => t.stop()); + this.inputStream.getTracks().forEach((t) => t.stop()); } this.resetTimer(); } cloneMediaStream() { if (this.inputStream) return; - const { inputStream, muted } = this.props; - if (inputStream && !muted) this.inputStream = inputStream.clone(); + const { inputStream } = this.props; + + if (inputStream) { + this.inputStream = inputStream.clone(); + this.enableInputStreamAudioTracks(this.inputStream); + } + } + + /* eslint-disable no-param-reassign */ + enableInputStreamAudioTracks() { + if (!this.inputStream) return; + this.inputStream.getAudioTracks().forEach((t) => { t.enabled = true; }); } + /* eslint-enable no-param-reassign */ resetTimer() { if (this.timer) clearTimeout(this.timer); diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx index 01a7590c93a6a9804ddaae4af8610a12faa9ba45..fd4887a7cf7726f2a4cf34ad5a58d1903e6bceb5 100755 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx @@ -102,7 +102,6 @@ class NavBar extends Component { : <Icon iconName="left_arrow" className={styles.arrowLeft} /> } <Button - data-test="userListToggleButton" onClick={NavBar.handleToggleUserList} ghost circle diff --git a/bigbluebutton-html5/imports/ui/components/presentation/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/container.jsx index 9efb966b8d552a025b27390b9a74096387f64f61..9a588bbeb7a896069f42d2d03eeaa01e311113b0 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/container.jsx @@ -15,9 +15,14 @@ import WhiteboardService from '/imports/ui/components/whiteboard/service'; const ROLE_VIEWER = Meteor.settings.public.user.role_viewer; const PresentationAreaContainer = ({ presentationPodIds, mountPresentationArea, ...props }) => { + const { layoutSwapped, podId } = props; + const usingUsersContext = useContext(UsersContext); const { users } = usingUsersContext; const currentUser = users[Auth.meetingID][Auth.userID]; + + const userIsPresenter = (podId === 'DEFAULT_PRESENTATION_POD') ? currentUser.presenter : props.isPresenter; + return mountPresentationArea && ( <PresentationArea @@ -25,6 +30,7 @@ const PresentationAreaContainer = ({ presentationPodIds, mountPresentationArea, ...{ ...props, isViewer: currentUser.role === ROLE_VIEWER, + userIsPresenter: userIsPresenter && !layoutSwapped, } } /> @@ -81,7 +87,7 @@ export default withTracker(({ podId }) => { currentSlide, slidePosition, downloadPresentationUri: PresentationAreaService.downloadPresentationUri(podId), - userIsPresenter: PresentationAreaService.isPresenter(podId) && !layoutSwapped, + isPresenter: PresentationAreaService.isPresenter(podId), multiUser: WhiteboardService.hasMultiUserAccess(currentSlide && currentSlide.id, Auth.userID) && !layoutSwapped, presentationIsDownloadable, diff --git a/bigbluebutton-html5/imports/ui/components/presentation/download-presentation-button/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/download-presentation-button/component.jsx index 6c096167d1414cef07854d7fc4346021ebba2241..7ec31b72ba748ac387a64f78b7624e285b812b0b 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/download-presentation-button/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/download-presentation-button/component.jsx @@ -29,7 +29,7 @@ const DownloadPresentationButton = ({ <Button color="default" icon="template_download" - size="sm" + size="md" onClick={handleDownloadPresentation} label={intl.formatMessage(intlMessages.downloadPresentationButton)} hideLabel diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx index b9a3ab428d55c5fc90041a5938a8ab5750c9a536..11a749cb172d4589220746f04ba141e33d6741eb 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx @@ -242,7 +242,7 @@ class PresentationUploader extends Component { } componentDidUpdate(prevProps) { - const { selectedToBeNextCurrent, isOpen, presentations: propPresentations } = this.props; + const { isOpen, presentations: propPresentations } = this.props; const { presentations } = this.state; // cleared local presetation state errors and set to presentations available on the server @@ -356,10 +356,6 @@ class PresentationUploader extends Component { const hasError = item.conversion.error || item.upload.error; const isProcessing = (isUploading || isConverting) && !hasError; - const { - intl, selectedToBeNextCurrent, - } = this.props; - const itemClassName = { [styles.done]: !isProcessing && !hasError, [styles.err]: hasError, diff --git a/bigbluebutton-html5/imports/ui/components/presentation/service.js b/bigbluebutton-html5/imports/ui/components/presentation/service.js index 879283f90d8f911e8031c533021089264a019a8c..b04cab4388bb181a3eb595e74d4d1c94104c97b6 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/service.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/service.js @@ -1,7 +1,6 @@ import PresentationPods from '/imports/api/presentation-pods'; import Presentations from '/imports/api/presentations'; import { Slides, SlidePositions } from '/imports/api/slides'; -import Users from '/imports/api/users'; import Auth from '/imports/ui/services/auth'; const getCurrentPresentation = podId => Presentations.findOne({ @@ -163,25 +162,16 @@ const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue, const isPresenter = (podId) => { // a main presenter in the meeting always owns a default pod - if (podId === 'DEFAULT_PRESENTATION_POD') { - const options = { - filter: { - presenter: 1, - }, + if (podId !== 'DEFAULT_PRESENTATION_POD') { + // if a pod is not default, then we check whether this user owns a current pod + const selector = { + meetingId: Auth.meetingID, + podId, }; - const currentUser = Users.findOne({ - userId: Auth.userID, - }, options); - return currentUser ? currentUser.presenter : false; + const pod = PresentationPods.findOne(selector); + return pod.currentPresenterId === Auth.userID; } - - // if a pod is not default, then we check whether this user owns a current pod - const selector = { - meetingId: Auth.meetingID, - podId, - }; - const pod = PresentationPods.findOne(selector); - return pod.currentPresenterId === Auth.userID; + return true; }; export default { diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx b/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx index 5e960f8d65be867d52dd31f4763a3d40af351a8a..030da381cf89b543260c065b07cc192e9d11d2fd 100755 --- a/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx @@ -189,7 +189,7 @@ class ScreenshareComponent extends React.Component { render() { const { loaded, autoplayBlocked, isFullscreen, isStreamHealthy } = this.state; - const { intl, isPresenter, isGloballyBroadcasting } = this.props; + const { isPresenter, isGloballyBroadcasting } = this.props; // Conditions to render the (re)connecting spinner and the unhealthy stream // grayscale: diff --git a/bigbluebutton-html5/imports/ui/components/status-notifier/component.jsx b/bigbluebutton-html5/imports/ui/components/status-notifier/component.jsx index 163eb897b33ea0bdfdf505d404f6ec708c13eb04..eea62234a2c20972eb8228df71a3034b2654d3b5 100644 --- a/bigbluebutton-html5/imports/ui/components/status-notifier/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/status-notifier/component.jsx @@ -20,7 +20,11 @@ const messages = defineMessages({ }, raisedHandDesc: { id: 'app.statusNotifier.raisedHandDesc', - description: 'label for user with raised hands', + description: 'label for multiple users with raised hands', + }, + raisedHandDescOneUser: { + id: 'app.statusNotifier.raisedHandDescOneUser', + description: 'label for a single user with raised hand', }, and: { id: 'app.statusNotifier.and', @@ -110,7 +114,9 @@ class StatusNotifier extends Component { break; } - return intl.formatMessage(messages.raisedHandDesc, { 0: formattedNames }); + const raisedHandMessageString + = length === 1 ? messages.raisedHandDescOneUser : messages.raisedHandDesc; + return intl.formatMessage(raisedHandMessageString, { 0: formattedNames }); } raisedHandAvatars() { diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/container.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/container.jsx index caf09b7f7137cb815e8d6efe3732836c6c1cd6d7..5a0211b63b80a18f825e412a8b202d29bedf9620 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/container.jsx @@ -12,9 +12,14 @@ const CLOSED_CHAT_LIST_KEY = 'closedChatList'; const UserContentContainer = (props) => { const usingUsersContext = useContext(UsersContext); const { users } = usingUsersContext; - const currentUser = users[Auth.meetingID][Auth.userID]; + const currentUser = { + userId: Auth.userID, + presenter: users[Auth.meetingID][Auth.userID].presenter, + locked: users[Auth.meetingID][Auth.userID].locked, + role: users[Auth.meetingID][Auth.userID].role, + }; return (<UserContent {...props} currentUser={currentUser} />); -} +}; export default withTracker(() => ({ pollIsOpen: Session.equals('isPollOpen', true), diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/service.js b/bigbluebutton-html5/imports/ui/components/video-provider/service.js index 2dc93d645a0aae8c2eaa5454cf63a2f7b122c03d..fa8143c72eda4467ca1d83ef0ef32bc8137eca26 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/service.js +++ b/bigbluebutton-html5/imports/ui/components/video-provider/service.js @@ -45,7 +45,6 @@ const { } = Meteor.settings.public.kurento.cameraSortingModes; const TOKEN = '_'; -const ENABLE_PAGINATION_SESSION_VAR = 'enablePagination'; class VideoService { constructor() { diff --git a/bigbluebutton-html5/imports/ui/components/waiting-users/styles.scss b/bigbluebutton-html5/imports/ui/components/waiting-users/styles.scss index bc5791c572a2aed78bbe4736c9678127d836f81a..92a0306e46a2d770eb67329345ef16453a6b533d 100644 --- a/bigbluebutton-html5/imports/ui/components/waiting-users/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/waiting-users/styles.scss @@ -96,8 +96,7 @@ display: flex; flex-flow: row; flex-direction: row; - align-items: center; - justify-content: space-between; + align-items: center; border-radius: 5px; cursor: pointer; :global(.animationsEnabled) & { @@ -132,14 +131,15 @@ .userContentContainer { display: flex; + flex: 1; + overflow: hidden; align-items: center; - width: 64%; flex-direction: row; } .button { font-weight: 400; - + &:focus { background-color: var(--list-item-bg-hover) !important; box-shadow: inset 0 0 0 var(--border-size) var(--item-focus-border), inset 1px 0 0 1px var(--item-focus-border) ; diff --git a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js index ddbd8b0d4d6aefd9b511790622fece0af8c8ee93..3c4324488bd2ae27019e3d544b0de160ea4bd8fa 100755 --- a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js +++ b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js @@ -67,6 +67,9 @@ class AudioManager { this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this); this.monitor = this.monitor.bind(this); + this._inputStream = null; + this._inputStreamTracker = new Tracker.Dependency(); + this.BREAKOUT_AUDIO_TRANSFER_STATES = BREAKOUT_AUDIO_TRANSFER_STATES; } @@ -335,6 +338,7 @@ class AudioManager { window.parent.postMessage({ response: 'joinedAudio' }, '*'); this.notify(this.intl.formatMessage(this.messages.info.JOINED_AUDIO)); logger.info({ logCode: 'audio_joined' }, 'Audio Joined'); + this.inputStream = (this.bridge ? this.bridge.inputStream : null); if (STATS.enabled) this.monitor(); this.audioEventHandler({ name: 'started', @@ -357,7 +361,8 @@ class AudioManager { this.failedMediaElements = []; if (this.inputStream) { - this.inputStream.getTracks().forEach(track => track.stop()); + this.inputStream.getTracks().forEach((track) => track.stop()); + this.inputStream = null; this.inputDevice = { id: 'default' }; } @@ -498,11 +503,13 @@ class AudioManager { } liveChangeInputDevice(deviceId) { - const handleChangeInputDeviceSuccess = (inputDevice) => { - this.inputDevice = inputDevice; - return Promise.resolve(inputDevice); - }; - this.bridge.liveChangeInputDevice(deviceId).then(handleChangeInputDeviceSuccess); + // we force stream to be null, so MutedAlert will deallocate it and + // a new one will be created for the new stream + this.inputStream = null; + this.bridge.liveChangeInputDevice(deviceId).then((stream) => { + this.setSenderTrackEnabled(!this.isMuted); + this.inputStream = stream; + }); } async changeOutputDevice(deviceId, isLive) { @@ -517,8 +524,19 @@ class AudioManager { } get inputStream() { - this._inputDevice.tracker.depend(); - return (this.bridge ? this.bridge.inputStream : null); + this._inputStreamTracker.depend(); + return this._inputStream; + } + + set inputStream(stream) { + // We store reactive information about input stream + // because mutedalert component needs to track when it changes + // and then update hark with the new value for inputStream + if (this._inputStream !== stream) { + this._inputStreamTracker.changed(); + } + + this._inputStream = stream; } get inputDevice() { diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index e174c88df77a711cb1990f0d3f8d1b741838ca93..0ecd0abb1c0018d5fa0194668ff19215617fead7 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -276,7 +276,7 @@ public: # The algorithm names are self-explanatory. cameraSortingModes: defaultSorting: LOCAL_ALPHABETICAL - paginationSorting: LOCAL_PRESENTER_ALPHABETICAL + paginationSorting: VOICE_ACTIVITY_LOCAL # Entry `thresholds` is an array of: # - threshold: minimum number of cameras being shared for profile to applied # profile: a camera profile id from the cameraProfiles configuration array @@ -400,7 +400,6 @@ public: autoSwapLayout: false hidePresentation: false showParticipantsOnLogin: true - webcamsDefaultPlacement: 'top' media: stunTurnServersFetchAddress: '/bigbluebutton/api/stuns' cacheStunTurnServers: true diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index 88b72067bd867ab8dd1b3da73750523b652597fd..8b1468b32c8116897f2ad72096c94821cb8337d3 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -374,7 +374,8 @@ "app.settings.save-notification.label": "Settings have been saved", "app.statusNotifier.lowerHands": "Lower Hands", "app.statusNotifier.raisedHandsTitle": "Raised Hands", - "app.statusNotifier.raisedHandDesc": "{0} raised their hand", + "app.statusNotifier.raisedHandDesc": "{0} raised their hands", + "app.statusNotifier.raisedHandDescOneUser": "{0} raised hand", "app.statusNotifier.and": "and", "app.switch.onLabel": "ON", "app.switch.offLabel": "OFF",