diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/GetMicrophonePermissionReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/GetMicrophonePermissionReqMsgHdlr.scala index ff78e1859f..f3004b7da3 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/GetMicrophonePermissionReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/GetMicrophonePermissionReqMsgHdlr.scala @@ -2,6 +2,7 @@ package org.bigbluebutton.core.apps.voice import org.bigbluebutton.common2.msgs._ import org.bigbluebutton.core.running.{ LiveMeeting, MeetingActor, OutMsgRouter } +import org.bigbluebutton.core2.MeetingStatus2x trait GetMicrophonePermissionReqMsgHdlr { this: MeetingActor => @@ -16,7 +17,8 @@ trait GetMicrophonePermissionReqMsgHdlr { voiceConf: String, userId: String, sfuSessionId: String, - allowed: Boolean + allowed: Boolean, + muteOnStart: Boolean ): Unit = { val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId) val envelope = BbbCoreEnvelope(GetMicrophonePermissionRespMsg.NAME, routing) @@ -26,7 +28,8 @@ trait GetMicrophonePermissionReqMsgHdlr { voiceConf, userId, sfuSessionId, - allowed + allowed, + muteOnStart ) val event = GetMicrophonePermissionRespMsg(header, body) val eventMsg = BbbCommonEnvCoreMsg(envelope, event) @@ -47,7 +50,8 @@ trait GetMicrophonePermissionReqMsgHdlr { liveMeeting.props.voiceProp.voiceConf, msg.body.userId, msg.body.sfuSessionId, - allowed + allowed, + MeetingStatus2x.isMeetingMuted(liveMeeting.status) ) } } diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala index 1100597274..77732f90d5 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala @@ -613,7 +613,8 @@ case class GetMicrophonePermissionRespMsgBody( voiceConf: String, userId: String, sfuSessionId: String, - allowed: Boolean + allowed: Boolean, + muteOnStart: Boolean ) /** diff --git a/bbb-voice-conference/config/freeswitch/conf/dialplan/default/bbb_conference.xml b/bbb-voice-conference/config/freeswitch/conf/dialplan/default/bbb_conference.xml index bf223c33d7..428a4a85dc 100644 --- a/bbb-voice-conference/config/freeswitch/conf/dialplan/default/bbb_conference.xml +++ b/bbb-voice-conference/config/freeswitch/conf/dialplan/default/bbb_conference.xml @@ -8,7 +8,15 @@ - + + + + + + + + + diff --git a/bbb-voice-conference/config/freeswitch/conf/dialplan/public/bbb_sfu.xml b/bbb-voice-conference/config/freeswitch/conf/dialplan/public/bbb_sfu.xml index 7f6a6e08f4..00bd7ff61b 100644 --- a/bbb-voice-conference/config/freeswitch/conf/dialplan/public/bbb_sfu.xml +++ b/bbb-voice-conference/config/freeswitch/conf/dialplan/public/bbb_sfu.xml @@ -1,6 +1,6 @@ - + diff --git a/bbb-voice-conference/config/freeswitch/conf/dialplan/public/bbb_sfu_muted.xml b/bbb-voice-conference/config/freeswitch/conf/dialplan/public/bbb_sfu_muted.xml new file mode 100644 index 0000000000..c3586353f0 --- /dev/null +++ b/bbb-voice-conference/config/freeswitch/conf/dialplan/public/bbb_sfu_muted.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/bigbluebutton-html5/client/main.html b/bigbluebutton-html5/client/main.html index 9e707aa5d2..125313de36 100755 --- a/bigbluebutton-html5/client/main.html +++ b/bigbluebutton-html5/client/main.html @@ -171,7 +171,7 @@ with BigBlueButton; if not, see . - + +
diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/base.js b/bigbluebutton-html5/imports/api/audio/client/bridge/base.js index 97008cac87..0b40e5d88e 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/base.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/base.js @@ -61,7 +61,11 @@ export default class BaseAudioBridge { get inputDeviceId () { return this._inputDeviceId; + } + /* eslint-disable class-methods-use-this */ + supportsTransparentListenOnly() { + return false; } /** @@ -78,6 +82,20 @@ export default class BaseAudioBridge { let backupStream; try { + // Remove all input audio tracks from the stream + // This will effectively mute the microphone + // and keep the audio output working + if (deviceId === 'listen-only') { + const stream = this.inputStream; + if (stream) { + stream.getAudioTracks().forEach((track) => { + track.stop(); + stream.removeTrack(track); + }); + } + return stream; + } + const constraints = { audio: getAudioConstraints({ deviceId }), }; diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/service.js b/bigbluebutton-html5/imports/api/audio/client/bridge/service.js index cddc3f7d97..980005722d 100644 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/service.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/service.js @@ -36,10 +36,25 @@ const getCurrentAudioSinkId = () => { return audioElement?.sinkId || DEFAULT_OUTPUT_DEVICE_ID; }; -const getStoredAudioInputDeviceId = () => getStorageSingletonInstance().getItem(INPUT_DEVICE_ID_KEY); -const getStoredAudioOutputDeviceId = () => getStorageSingletonInstance().getItem(OUTPUT_DEVICE_ID_KEY); -const storeAudioInputDeviceId = (deviceId) => getStorageSingletonInstance().setItem(INPUT_DEVICE_ID_KEY, deviceId); -const storeAudioOutputDeviceId = (deviceId) => getStorageSingletonInstance().setItem(OUTPUT_DEVICE_ID_KEY, deviceId); +const getStoredAudioOutputDeviceId = () => getStorageSingletonInstance() + .getItem(OUTPUT_DEVICE_ID_KEY); +const storeAudioOutputDeviceId = (deviceId) => getStorageSingletonInstance() + .setItem(OUTPUT_DEVICE_ID_KEY, deviceId); +const getStoredAudioInputDeviceId = () => getStorageSingletonInstance() + .getItem(INPUT_DEVICE_ID_KEY); +const storeAudioInputDeviceId = (deviceId) => { + if (deviceId === 'listen-only') { + // Do not store listen-only "devices" and remove any stored device + // So it starts from scratch next time. + getStorageSingletonInstance().removeItem(INPUT_DEVICE_ID_KEY); + + return false; + } + + getStorageSingletonInstance().setItem(INPUT_DEVICE_ID_KEY, deviceId); + + return true; +}; /** * Filter constraints set in audioDeviceConstraints, based on diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/sfu-audio-bridge.js b/bigbluebutton-html5/imports/api/audio/client/bridge/sfu-audio-bridge.js index 6b213131fb..82ac15ce58 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/sfu-audio-bridge.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/sfu-audio-bridge.js @@ -20,6 +20,7 @@ import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils'; const SENDRECV_ROLE = 'sendrecv'; const RECV_ROLE = 'recv'; +const PASSIVE_SENDRECV_ROLE = 'passive-sendrecv'; const BRIDGE_NAME = 'fullaudio'; const IS_CHROME = browserInfo.isChrome; @@ -81,7 +82,7 @@ export default class SFUAudioBridge extends BaseAudioBridge { const MEDIA = SETTINGS.public.media; const LISTEN_ONLY_OFFERING = MEDIA.listenOnlyOffering; const FULLAUDIO_OFFERING = MEDIA.fullAudioOffering; - return isListenOnly + return isListenOnly && !isTransparentListenOnlyEnabled() ? LISTEN_ONLY_OFFERING : (!isTransparentListenOnlyEnabled() && FULLAUDIO_OFFERING); } @@ -95,12 +96,17 @@ export default class SFUAudioBridge extends BaseAudioBridge { this.reconnecting = false; this.iceServers = []; this.bridgeName = BRIDGE_NAME; + this.isListenOnly = false; + this.bypassGUM = false; + this.supportsTransparentListenOnly = isTransparentListenOnlyEnabled; this.handleTermination = this.handleTermination.bind(this); } get inputStream() { - if (this.broker) { + // Only return the stream if the broker is active and the role isn't recvonly + // Input stream == actual input-capturing stream, not the one that's being played + if (this.broker && this.role !== RECV_ROLE) { return this.broker.getLocalStream(); } @@ -111,6 +117,18 @@ export default class SFUAudioBridge extends BaseAudioBridge { return this.broker?.role; } + getBrokerRole({ hasInputStream }) { + if (this.isListenOnly) { + return isTransparentListenOnlyEnabled() + ? PASSIVE_SENDRECV_ROLE + : RECV_ROLE; + } + + if (this.bypassGUM && !hasInputStream) return PASSIVE_SENDRECV_ROLE; + + return SENDRECV_ROLE; + } + setInputStream(stream) { if (this.broker == null) return null; @@ -326,6 +344,7 @@ export default class SFUAudioBridge extends BaseAudioBridge { extension, inputStream, forceRelay: _forceRelay = false, + bypassGUM = false, } = options; const SETTINGS = window.meetingClientSettings; @@ -349,6 +368,10 @@ export default class SFUAudioBridge extends BaseAudioBridge { try { this.inEchoTest = !!extension; this.isListenOnly = isListenOnly; + this.bypassGUM = bypassGUM; + const role = this.getBrokerRole({ + hasInputStream: !!inputStream, + }); const brokerOptions = { clientSessionNumber: getAudioSessionNumber(), @@ -365,11 +388,12 @@ export default class SFUAudioBridge extends BaseAudioBridge { mediaStreamFactory: this.mediaStreamFactory, gatheringTimeout: GATHERING_TIMEOUT, transparentListenOnly: isTransparentListenOnlyEnabled(), + bypassGUM, }; this.broker = new AudioBroker( Auth.authenticateURL(SFU_URL), - isListenOnly ? RECV_ROLE : SENDRECV_ROLE, + role, brokerOptions, ); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx index 255b14066c..3c6f2b4219 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx @@ -63,6 +63,8 @@ const AudioControls: React.FC = ({ const echoTestIntervalRef = React.useRef>(); const [isAudioModalOpen, setIsAudioModalOpen] = React.useState(false); + const [audioModalContent, setAudioModalContent] = React.useState(null); + const [audioModalProps, setAudioModalProps] = React.useState<{ unmuteOnExit?: boolean } | null>(null); const handleJoinAudio = useCallback((connected: boolean) => { if (connected) { @@ -72,6 +74,12 @@ const AudioControls: React.FC = ({ } }, []); + const openAudioSettings = (props: { unmuteOnExit?: boolean } = {}) => { + setAudioModalContent('settings'); + setAudioModalProps(props); + setIsAudioModalOpen(true); + }; + const joinButton = useMemo(() => { const joinAudioLabel = away ? intlMessages.joinAudioAndSetActive : intlMessages.joinAudio; @@ -107,12 +115,18 @@ const AudioControls: React.FC = ({ return ( - {!inAudio ? joinButton : } + {!inAudio ? joinButton : } {isAudioModalOpen && ( setIsAudioModalOpen(false)} + setIsOpen={() => { + setIsAudioModalOpen(false); + setAudioModalContent(null); + setAudioModalProps(null); + }} isOpen={isAudioModalOpen} + content={audioModalContent} + unmuteOnExit={audioModalProps?.unmuteOnExit} /> )} diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx index 0faa7e9574..279aaecb9d 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx @@ -56,6 +56,26 @@ const intlMessages = defineMessages({ id: 'app.audioNotification.deviceChangeFailed', description: 'Device change failed', }, + fallbackInputLabel: { + id: 'app.audio.audioSettings.fallbackInputLabel', + description: 'Audio input device label', + }, + fallbackOutputLabel: { + id: 'app.audio.audioSettings.fallbackOutputLabel', + description: 'Audio output device label', + }, + fallbackNoPermissionLabel: { + id: 'app.audio.audioSettings.fallbackNoPermission', + description: 'No permission to access audio devices label', + }, + audioSettingsTitle: { + id: 'app.audio.audioSettings.titleLabel', + description: 'Audio settings button label', + }, + noMicListenOnlyLabel: { + id: 'app.audio.audioSettings.noMicListenOnly', + description: 'No microphone (listen only) label', + }, }); interface MuteToggleProps { @@ -75,6 +95,8 @@ interface LiveSelectionProps extends MuteToggleProps { outputDeviceId: string; meetingIsBreakout: boolean; away: boolean; + openAudioSettings: (props?: { unmuteOnExit?: boolean }) => void; + supportsTransparentListenOnly: boolean; } export const LiveSelection: React.FC = ({ @@ -90,6 +112,8 @@ export const LiveSelection: React.FC = ({ isAudioLocked, toggleMuteMicrophone, away, + openAudioSettings, + supportsTransparentListenOnly, }) => { const intl = useIntl(); @@ -105,6 +129,21 @@ export const LiveSelection: React.FC = ({ ]; } + const getFallbackLabel = (device: MediaDeviceInfo, index: number) => { + const baseLabel = device?.kind === AUDIO_OUTPUT + ? intlMessages.fallbackOutputLabel + : intlMessages.fallbackInputLabel; + let label = intl.formatMessage(baseLabel, { 0: index }); + + if (!device?.deviceId) { + label = `${label} ${intl.formatMessage(intlMessages.fallbackNoPermissionLabel)}`; + } + + return label; + }; + + const shouldTreatAsMicrophone = () => !listenOnly || supportsTransparentListenOnly; + const renderDeviceList = useCallback(( deviceKind: string, list: MediaDeviceInfo[], @@ -134,7 +173,7 @@ export const LiveSelection: React.FC = ({ { key: `${device.deviceId}-${deviceKind}`, dataTest: `${deviceKind}-${index + 1}`, - label: truncateDeviceName(device.label), + label: truncateDeviceName(device.label || getFallbackLabel(device, index + 1)), customStyles: (device.deviceId === currentDeviceId) ? Styled.SelectedLabel : null, iconRight: (device.deviceId === currentDeviceId) ? 'check' : null, onClick: () => onDeviceListClick(device.deviceId, deviceKind, callback), @@ -163,10 +202,37 @@ export const LiveSelection: React.FC = ({ ]; } + if (deviceKind === AUDIO_INPUT && supportsTransparentListenOnly) { + // "None" option for audio input devices - aka listen-only + const listenOnly = deviceKind === AUDIO_INPUT + && currentDeviceId === 'listen-only'; + + deviceList.push({ + key: `listenOnly-${deviceKind}`, + dataTest: `${deviceKind}-listenOnly`, + label: intl.formatMessage(intlMessages.noMicListenOnlyLabel), + customStyles: listenOnly && Styled.SelectedLabel, + iconRight: listenOnly ? 'check' : null, + onClick: () => onDeviceListClick('listen-only', deviceKind, callback), + } as MenuOptionItemType); + } + return listTitle.concat(deviceList); }, []); const onDeviceListClick = useCallback((deviceId: string, deviceKind: string, callback: Function) => { + if (!deviceId) { + // If there's no deviceId in an audio input device, it means + // the user doesn't have permission to access it. If we support + // transparent listen-only, fire the mount AudioSettings modal to + // acquire permission and let the user configure their stuff. + if (deviceKind === AUDIO_INPUT && supportsTransparentListenOnly) { + openAudioSettings({ unmuteOnExit: true }); + } + + return; + } + if (!deviceId) return; if (deviceKind === AUDIO_INPUT) { callback(deviceId).catch(() => { @@ -179,7 +245,7 @@ export const LiveSelection: React.FC = ({ } }, []); - const inputDeviceList = !listenOnly + const inputDeviceList = shouldTreatAsMicrophone() ? renderDeviceList( AUDIO_INPUT, inputDevices, @@ -196,6 +262,16 @@ export const LiveSelection: React.FC = ({ outputDeviceId, ); + const audioSettingsOption = { + icon: 'settings', + label: intl.formatMessage(intlMessages.audioSettingsTitle), + key: 'audioSettingsOption', + dataTest: 'input-selector-audio-settings', + customStyles: Styled.AudioSettingsOption, + dividerTop: true, + onClick: () => openAudioSettings(), + } as MenuOptionItemType; + const leaveAudioOption = { icon: 'logout', label: intl.formatMessage(intlMessages.leaveAudio), @@ -204,12 +280,14 @@ export const LiveSelection: React.FC = ({ customStyles: Styled.DangerColor, onClick: () => handleLeaveAudio(meetingIsBreakout), }; - const dropdownListComplete = inputDeviceList.concat(outputDeviceList) + const dropdownListComplete = inputDeviceList + .concat(outputDeviceList) .concat({ key: 'separator-02', isSeparator: true, - }) - .concat(leaveAudioOption); + }); + if (shouldTreatAsMicrophone()) dropdownListComplete.push(audioSettingsOption); + dropdownListComplete.push(leaveAudioOption); audioSettingsDropdownItems.forEach((audioSettingsDropdownItem: PluginSdk.AudioSettingsDropdownInterface) => { @@ -239,9 +317,11 @@ export const LiveSelection: React.FC = ({ const customStyles = { top: '-1rem' }; const { isMobile } = deviceInfo; + const noInputDevice = inputDeviceId === 'listen-only'; + return ( <> - {!listenOnly ? ( + {shouldTreatAsMicrophone() ? ( // eslint-disable-next-line jsx-a11y/no-access-key = ({ aria-hidden="true" /> ) : null} - {(!listenOnly && isMobile) && ( + {(shouldTreatAsMicrophone() && isMobile) && ( = ({ isAudioLocked={isAudioLocked} toggleMuteMicrophone={toggleMuteMicrophone} away={away} + noInputDevice={noInputDevice} + openAudioSettings={openAudioSettings} /> )} - {!listenOnly && !isMobile + {shouldTreatAsMicrophone() && !isMobile ? ( = ({ isAudioLocked={isAudioLocked} toggleMuteMicrophone={toggleMuteMicrophone} away={away} + noInputDevice={noInputDevice} + openAudioSettings={openAudioSettings} /> ) : ( diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx index d5367ae653..14db9ad675 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx @@ -33,6 +33,8 @@ interface MuteToggleProps { isAudioLocked: boolean; toggleMuteMicrophone: (muted: boolean, toggleVoice: (userId: string, muted: boolean) => void) => void; away: boolean; + noInputDevice?: boolean; + openAudioSettings: (props?: { unmuteOnExit?: boolean }) => void; } export const MuteToggle: React.FC = ({ @@ -42,6 +44,8 @@ export const MuteToggle: React.FC = ({ isAudioLocked, toggleMuteMicrophone, away, + noInputDevice = false, + openAudioSettings, }) => { const intl = useIntl(); const toggleMuteShourtcut = useShortcut('toggleMute'); @@ -57,15 +61,22 @@ export const MuteToggle: React.FC = ({ const onClickCallback = (e: React.MouseEvent) => { e.stopPropagation(); - if (muted && away) { - muteAway(muted, true, toggleVoice); - VideoService.setTrackEnabled(true); - setAway({ - variables: { - away: false, - }, - }); + if (muted) { + if (away) { + if (!noInputDevice) muteAway(muted, true, toggleVoice); + VideoService.setTrackEnabled(true); + setAway({ + variables: { + away: false, + }, + }); + } else if (noInputDevice) { + // User is in duplex audio, passive-sendrecv, but has no input device set + // Open the audio settings modal to allow them to select an input device + openAudioSettings(); + } } + toggleMuteMicrophone(muted, toggleVoice); }; return ( diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/component.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/component.tsx index db45cd9e38..376ea5d03c 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/component.tsx @@ -8,18 +8,23 @@ import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { User } from '/imports/ui/Types/user'; import { defineMessages, useIntl } from 'react-intl'; import { - handleLeaveAudio, liveChangeInputDevice, liveChangeOutputDevice, notify, toggleMuteMicrophone, + handleLeaveAudio, + liveChangeInputDevice, + liveChangeOutputDevice, + notify, + toggleMuteMicrophone, + toggleMuteMicrophoneSystem, } from './service'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import { Meeting } from '/imports/ui/Types/meeting'; import logger from '/imports/startup/client/logger'; -import Auth from '/imports/ui/services/auth'; import MutedAlert from '/imports/ui/components/muted-alert/component'; import MuteToggle from './buttons/muteToggle'; import ListenOnly from './buttons/listenOnly'; import LiveSelection from './buttons/LiveSelection'; import useWhoIsTalking from '/imports/ui/core/hooks/useWhoIsTalking'; import useWhoIsUnmuted from '/imports/ui/core/hooks/useWhoIsUnmuted'; +import useToggleVoice from '/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice'; const AUDIO_INPUT = 'audioinput'; const AUDIO_OUTPUT = 'audiooutput'; @@ -52,7 +57,11 @@ const intlMessages = defineMessages({ }, }); -interface InputStreamLiveSelectorProps { +interface InputStreamLiveSelectorContainerProps { + openAudioSettings: (props?: { unmuteOnExit?: boolean }) => void; +} + +interface InputStreamLiveSelectorProps extends InputStreamLiveSelectorContainerProps { isConnected: boolean; isPresenter: boolean; isModerator: boolean; @@ -68,6 +77,8 @@ interface InputStreamLiveSelectorProps { inputStream: string; meetingIsBreakout: boolean; away: boolean; + permissionStatus: string; + supportsTransparentListenOnly: boolean; } const InputStreamLiveSelector: React.FC = ({ @@ -86,8 +97,12 @@ const InputStreamLiveSelector: React.FC = ({ inputStream, meetingIsBreakout, away, + permissionStatus, + supportsTransparentListenOnly, + openAudioSettings, }) => { const intl = useIntl(); + const toggleVoice = useToggleVoice(); // eslint-disable-next-line no-undef const [inputDevices, setInputDevices] = React.useState([]); const [outputDevices, setOutputDevices] = React.useState([]); @@ -106,6 +121,15 @@ const InputStreamLiveSelector: React.FC = ({ const audioOutputDevices = devices.filter((i) => i.kind === AUDIO_OUTPUT); setInputDevices(audioInputDevices as InputDeviceInfo[]); setOutputDevices(audioOutputDevices); + }) + .catch((error) => { + logger.warn({ + logCode: 'audio_device_enumeration_error', + extraInfo: { + errorMessage: error.message, + errorName: error.name, + }, + }, `Error enumerating audio devices: ${error.message}`); }); if (isAudioConnected) { updateRemovedDevices(inputDevices, outputDevices); @@ -115,11 +139,11 @@ const InputStreamLiveSelector: React.FC = ({ const fallbackInputDevice = useCallback((fallbackDevice: MediaDeviceInfo) => { if (!fallbackDevice || !fallbackDevice.deviceId) return; - logger.info({ - logCode: 'audio_device_live_selector', + logger.warn({ + logCode: 'audio_input_live_selector', extraInfo: { - userId: Auth.userID, - meetingId: Auth.meetingID, + fallbackDeviceId: fallbackDevice?.deviceId, + fallbackDeviceLabel: fallbackDevice?.label, }, }, 'Current input device was removed. Fallback to default device'); liveChangeInputDevice(fallbackDevice.deviceId).catch(() => { @@ -129,11 +153,11 @@ const InputStreamLiveSelector: React.FC = ({ const fallbackOutputDevice = useCallback((fallbackDevice: MediaDeviceInfo) => { if (!fallbackDevice || !fallbackDevice.deviceId) return; - logger.info({ - logCode: 'audio_device_live_selector', + logger.warn({ + logCode: 'audio_output_live_selector', extraInfo: { - userId: Auth.userID, - meetingId: Auth.meetingID, + fallbackDeviceId: fallbackDevice?.deviceId, + fallbackDeviceLabel: fallbackDevice?.label, }, }, 'Current output device was removed. Fallback to default device'); liveChangeOutputDevice(fallbackDevice.deviceId, true).catch(() => { @@ -162,7 +186,16 @@ const InputStreamLiveSelector: React.FC = ({ if (enableDynamicAudioDeviceSelection) { updateDevices(inAudio); } - }, [inAudio]); + }, [inAudio, permissionStatus]); + + useEffect(() => { + // If the user has no input device, is connected to audio and unmuted, + // they need to be *muted* by the system. Further attempts to unmute + // will open the audio settings modal instead. + if (inputDeviceId === 'listen-only' && isConnected && !muted) { + toggleMuteMicrophoneSystem(muted, toggleVoice); + } + }, [inputDeviceId, isConnected, muted]); return ( <> @@ -190,6 +223,8 @@ const InputStreamLiveSelector: React.FC = ({ isAudioLocked={isAudioLocked} toggleMuteMicrophone={toggleMuteMicrophone} away={away} + supportsTransparentListenOnly={supportsTransparentListenOnly} + openAudioSettings={openAudioSettings} /> ) : ( <> @@ -201,6 +236,8 @@ const InputStreamLiveSelector: React.FC = ({ isAudioLocked={isAudioLocked} toggleMuteMicrophone={toggleMuteMicrophone} away={away} + openAudioSettings={openAudioSettings} + noInputDevice={inputDeviceId === 'listen-only'} /> )} = ({ ); }; -const InputStreamLiveSelectorContainer: React.FC = () => { +const InputStreamLiveSelectorContainer: React.FC = ({ + openAudioSettings, +}) => { const { data: currentUser } = useCurrentUser((u: Partial) => { if (!u.voice) { return { @@ -261,6 +300,10 @@ const InputStreamLiveSelectorContainer: React.FC = () => { const outputDeviceId = useReactiveVar(AudioManager._outputDeviceId.value) as string; // @ts-ignore - temporary while hybrid (meteor+GraphQl) const inputStream = useReactiveVar(AudioManager._inputStream) as string; + // @ts-ignore - temporary while hybrid (meteor+GraphQl) + const permissionStatus = useReactiveVar(AudioManager._permissionStatus.value) as string; + // @ts-ignore - temporary while hybrid (meteor+GraphQl) + const supportsTransparentListenOnly = useReactiveVar(AudioManager._transparentListenOnlySupported.value) as boolean; return ( { inputStream={inputStream} meetingIsBreakout={currentMeeting?.isBreakout ?? false} away={currentUser?.away ?? false} + openAudioSettings={openAudioSettings} + permissionStatus={permissionStatus} + supportsTransparentListenOnly={supportsTransparentListenOnly} /> ); }; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts index 804edd57a5..3040121751 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts @@ -40,32 +40,35 @@ export const handleLeaveAudio = (meetingIsBreakout: boolean) => { ); }; -const toggleMuteMicrophoneThrottled = throttle(( +const toggleMute = ( muted: boolean, toggleVoice: (userId: string, muted: boolean) => void, + actionType = 'user_action', ) => { - Storage.setItem(MUTED_KEY, !muted); - if (muted) { - logger.info( - { - logCode: 'audiomanager_unmute_audio', - extraInfo: { logType: 'user_action' }, - }, - 'microphone unmuted by user', - ); + if (AudioManager.inputDeviceId === 'listen-only') { + // User is in duplex audio, passive-sendrecv, but has no input device set + // Unmuting should not be allowed at all + return; + } + + logger.info({ + logCode: 'audiomanager_unmute_audio', + extraInfo: { logType: actionType }, + }, 'microphone unmuted'); + Storage.setItem(MUTED_KEY, false); toggleVoice(Auth.userID as string, false); } else { - logger.info( - { - logCode: 'audiomanager_mute_audio', - extraInfo: { logType: 'user_action' }, - }, - 'microphone muted by user', - ); + logger.info({ + logCode: 'audiomanager_mute_audio', + extraInfo: { logType: actionType }, + }, 'microphone muted'); + Storage.setItem(MUTED_KEY, true); toggleVoice(Auth.userID as string, true); } -}, TOGGLE_MUTE_THROTTLE_TIME); +}; + +const toggleMuteMicrophoneThrottled = throttle(toggleMute, TOGGLE_MUTE_THROTTLE_TIME); const toggleMuteMicrophoneDebounced = debounce(toggleMuteMicrophoneThrottled, TOGGLE_MUTE_DEBOUNCE_TIME, { leading: true, trailing: false }); @@ -74,6 +77,11 @@ export const toggleMuteMicrophone = (muted: boolean, toggleVoice: (userId: strin return toggleMuteMicrophoneDebounced(muted, toggleVoice); }; +// Debounce is not needed here, as this function should only called by the system. +export const toggleMuteMicrophoneSystem = (muted: boolean, toggleVoice: (userId: string, muted: boolean) => void) => { + return toggleMute(muted, toggleVoice, 'system_action'); +}; + export const truncateDeviceName = (deviceName: string) => { if (deviceName && deviceName.length <= DEVICE_LABEL_MAX_LENGTH) { return deviceName; @@ -141,6 +149,7 @@ export const muteAway = ( export default { handleLeaveAudio, toggleMuteMicrophone, + toggleMuteMicrophoneSystem, truncateDeviceName, notify, liveChangeInputDevice, diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/styles.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/styles.ts index c5b58bc900..7f60f37b54 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/styles.ts @@ -56,6 +56,10 @@ export const DisabledLabel = { opacity: 1, }; +export const AudioSettingsOption = { + paddingLeft: 12, +}; + export const SelectedLabel = { color: colorPrimary, backgroundColor: colorOffWhite, @@ -80,6 +84,7 @@ export default { MuteToggleButton, DisabledLabel, SelectedLabel, + AudioSettingsOption, DangerColor, AudioDropdown, }; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx index 12821d78c2..4e604c8f8e 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx @@ -1,11 +1,14 @@ -import React, { useEffect, useState } from 'react'; +import React, { + useCallback, + useEffect, + useState, +} from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage, } from 'react-intl'; import { useMutation } from '@apollo/client'; import Styled from './styles'; -import PermissionsOverlay from '../permissions-overlay/component'; import AudioSettings from '../audio-settings/component'; import EchoTest from '../echo-test/component'; import Help from '../help/component'; @@ -21,6 +24,7 @@ import { muteAway, } from '/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service'; import Session from '/imports/ui/services/storage/in-memory'; +import logger from '/imports/startup/client/logger'; const propTypes = { intl: PropTypes.shape({ @@ -39,10 +43,11 @@ const propTypes = { isConnected: PropTypes.bool.isRequired, isUsingAudio: PropTypes.bool.isRequired, isListenOnly: PropTypes.bool.isRequired, + isMuted: PropTypes.bool.isRequired, + toggleMuteMicrophoneSystem: PropTypes.func.isRequired, inputDeviceId: PropTypes.string, outputDeviceId: PropTypes.string, formattedDialNum: PropTypes.string.isRequired, - showPermissionsOvelay: PropTypes.bool.isRequired, listenOnlyMode: PropTypes.bool.isRequired, joinFullAudioImmediately: PropTypes.bool, forceListenOnlyAttendee: PropTypes.bool.isRequired, @@ -72,6 +77,14 @@ const propTypes = { }).isRequired, getTroubleshootingLink: PropTypes.func.isRequired, away: PropTypes.bool, + doGUM: PropTypes.func.isRequired, + hasMicrophonePermission: PropTypes.func.isRequired, + permissionStatus: PropTypes.string, + liveChangeInputDevice: PropTypes.func.isRequired, + content: PropTypes.string, + unmuteOnExit: PropTypes.bool, + supportsTransparentListenOnly: PropTypes.bool.isRequired, + getAudioConstraints: PropTypes.func.isRequired, }; const intlMessages = defineMessages({ @@ -116,7 +129,7 @@ const intlMessages = defineMessages({ description: 'Title for the echo test', }, settingsTitle: { - id: 'app.audioModal.settingsTitle', + id: 'app.audio.audioSettings.titleLabel', description: 'Title for the audio modal', }, helpTitle: { @@ -139,6 +152,10 @@ const intlMessages = defineMessages({ id: 'app.audioModal.autoplayBlockedDesc', description: 'Message for autoplay audio block', }, + findingDevicesTitle: { + id: 'app.audio.audioSettings.findingDevicesTitle', + description: 'Message for finding audio devices', + }, }); const AudioModal = ({ @@ -148,6 +165,8 @@ const AudioModal = ({ audioLocked, isUsingAudio, isListenOnly, + isMuted, + toggleMuteMicrophoneSystem, autoplayBlocked, closeModal, isEchoTest, @@ -174,19 +193,27 @@ const AudioModal = ({ notify, formattedTelVoice, handleAllowAutoplay, - showPermissionsOvelay, isIE, isOpen, priority, setIsOpen, getTroubleshootingLink, away = false, + doGUM, + getAudioConstraints, + hasMicrophonePermission, + liveChangeInputDevice, + content: initialContent, + supportsTransparentListenOnly, + unmuteOnExit = false, + permissionStatus = null, }) => { - const [content, setContent] = useState(null); + const [content, setContent] = useState(initialContent); const [hasError, setHasError] = useState(false); const [disableActions, setDisableActions] = useState(false); const [errorInfo, setErrorInfo] = useState(null); const [autoplayChecked, setAutoplayChecked] = useState(false); + const [findingDevices, setFindingDevices] = useState(false); const [setAway] = useMutation(SET_AWAY); const voiceToggle = useToggleVoice(); @@ -257,6 +284,55 @@ const AudioModal = ({ }); }; + const handleGUMFailure = (error) => { + const { MIC_ERROR } = AudioError; + + logger.error({ + logCode: 'audio_gum_failed', + extraInfo: { + errorMessage: error.message, + errorName: error.name, + }, + }, `Audio gUM failed: ${error.name}`); + + setContent('help'); + setDisableActions(false); + setHasError(true); + setErrorInfo({ + errCode: error?.name === 'NotAllowedError' + ? MIC_ERROR.NO_PERMISSION + : 0, + errMessage: error?.name || 'NotAllowedError', + }); + }; + + const checkMicrophonePermission = (options) => { + setFindingDevices(true); + + return hasMicrophonePermission(options) + .then((hasPermission) => { + // null means undetermined, so we don't want to show the error modal + // and let downstream components figure it out + if (hasPermission === true || hasPermission === null) { + return hasPermission; + } + + handleGUMFailure(new DOMException( + 'Permissions API says denied', + 'NotAllowedError', + )); + + return false; + }) + .catch((error) => { + handleGUMFailure(error); + return null; + }) + .finally(() => { + setFindingDevices(false); + }); + }; + const handleGoToAudioOptions = () => { setContent(null); setHasError(true); @@ -318,14 +394,19 @@ const AudioModal = ({ }); }; - const handleJoinLocalEcho = (inputStream) => { + const handleAudioSettingsConfirmation = useCallback((inputStream) => { // Reset the modal to a connecting state - this kind of sucks? // prlanzarin Apr 04 2022 setContent(null); if (inputStream) changeInputStream(inputStream); - handleJoinMicrophone(); - disableAwayMode(); - }; + + if (!isConnected) { + handleJoinMicrophone(); + disableAwayMode(); + } else { + closeModal(); + } + }, [changeInputStream, isConnected]); const skipAudioOptions = () => (isConnecting || (forceListenOnlyAttendee && !autoplayChecked)) && !content @@ -333,7 +414,6 @@ const AudioModal = ({ const renderAudioOptions = () => { const hideMicrophone = forceListenOnlyAttendee || audioLocked; - const arrow = isRTL ? '←' : '→'; const dialAudioLabel = `${intl.formatMessage(intlMessages.audioDialTitle)} ${arrow}`; @@ -400,40 +480,46 @@ const AudioModal = ({ /> ); + const handleBack = useCallback(() => { + if (isConnecting || isConnected || skipAudioOptions()) { + closeModal(); + } else { + handleGoToAudioOptions(); + } + }, [isConnecting, isConnected, skipAudioOptions]); + const renderAudioSettings = () => { + const { animations } = getSettingsSingletonInstance().application; const confirmationCallback = !localEchoEnabled ? handleRetryGoToEchoTest - : handleJoinLocalEcho; - - const handleGUMFailure = (error) => { - const code = error?.name === 'NotAllowedError' - ? AudioError.MIC_ERROR.NO_PERMISSION - : 0; - setContent('help'); - setErrorInfo({ - errCode: code, - errMessage: error?.name || 'NotAllowedError', - }); - setDisableActions(false); - }; + : handleAudioSettingsConfirmation; return ( ); }; @@ -445,9 +531,19 @@ const AudioModal = ({ message: errorInfo?.errMessage, }; + const _joinListenOnly = () => { + // Erase the content state so that the modal transitions to the connecting + // state if the user chooses listen only + setContent(null); + handleJoinListenOnly(); + }; + return ( { const { animations } = getSettingsSingletonInstance().application; + if (findingDevices && content === null) { + return ( + + + {intl.formatMessage(intlMessages.findingDevicesTitle)} + + + + ); + } + if (skipAudioOptions()) { return ( @@ -505,6 +612,7 @@ const AudioModal = ({ ); } + return content ? contents[content].component() : renderAudioOptions(); }; @@ -512,16 +620,23 @@ const AudioModal = ({ if (!isUsingAudio) { if (forceListenOnlyAttendee || audioLocked) { handleJoinListenOnly(); - return; - } + } else if (!listenOnlyMode) { + if (joinFullAudioImmediately) { + checkMicrophonePermission({ doGUM: true, permissionStatus }) + .then((hasPermission) => { + // No permission - let the Help screen be shown as it's triggered + // by the checkMicrophonePermission function + if (hasPermission === false) return; - if (joinFullAudioImmediately && !listenOnlyMode) { - handleJoinMicrophone(); - return; - } - - if (!listenOnlyMode) { - handleGoToEchoTest(); + // Permission is granted or undetermined, so we can proceed + handleJoinMicrophone(); + }); + } else { + checkMicrophonePermission({ doGUM: false, permissionStatus }).then((hasPermission) => { + if (hasPermission === false) return; + handleGoToEchoTest(); + }); + } } } }, [ @@ -551,40 +666,37 @@ const AudioModal = ({ let title = content ? intl.formatMessage(contents[content].title) : intl.formatMessage(intlMessages.audioChoiceLabel); - title = !skipAudioOptions() ? title : null; + title = !skipAudioOptions() && !findingDevices ? title : null; return ( - <> - {showPermissionsOvelay ? : null} - - {isIE ? ( - - Chrome, - 1: Firefox, - }} - /> - - ) : null} - - {renderContent()} - - - + + {isIE ? ( + + Chrome, + 1: Firefox, + }} + /> + + ) : null} + + {renderContent()} + + ); }; 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 f1a795e643..dae7ef9856 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx @@ -62,7 +62,6 @@ const AudioModalContainer = (props) => { combinedDialInNum = `${dialNumber.replace(/\D+/g, '')},,,${telVoice.replace(/\D+/g, '')}`; } } - const { isIe } = browserInfo; const SHOW_VOLUME_METER = window.meetingClientSettings.public.media.showVolumeMeter; @@ -81,26 +80,26 @@ const AudioModalContainer = (props) => { const isListenOnly = useReactiveVar(AudioManager._isListenOnly.value); const isEchoTest = useReactiveVar(AudioManager._isEchoTest.value); const autoplayBlocked = useReactiveVar(AudioManager._autoplayBlocked.value); + const isMuted = useReactiveVar(AudioManager._isMuted.value); const meetingIsBreakout = AppService.useMeetingIsBreakout(); + const supportsTransparentListenOnly = useReactiveVar( + AudioManager._transparentListenOnlySupported.value, + ); + const permissionStatus = useReactiveVar(AudioManager._permissionStatus.value); const { userLocks } = useLockContext(); - + const isListenOnlyInputDevice = Service.inputDeviceId() === 'listen-only'; + const devicesAlreadyConfigured = skipEchoTestIfPreviousDevice + && Service.inputDeviceId(); + const joinFullAudioImmediately = !isListenOnlyInputDevice + && (skipCheck || (skipCheckOnJoin && !getEchoTest) || devicesAlreadyConfigured); const { setIsOpen } = props; const close = useCallback(() => closeModal(() => setIsOpen(false)), [setIsOpen]); const joinMic = useCallback( - (skipEchoTest) => joinMicrophone(skipEchoTest || skipCheck || skipCheckOnJoin), + (options = {}) => joinMicrophone({ + skipEchoTest: options.skipEchoTest || joinFullAudioImmediately, + }), [skipCheck, skipCheckOnJoin], ); - const joinFullAudioImmediately = ( - autoJoin - && ( - skipCheck - || (skipCheckOnJoin && !getEchoTest) - )) - || ( - skipCheck - || (skipCheckOnJoin && !getEchoTest) - || (skipEchoTestIfPreviousDevice && (inputDeviceId || outputDeviceId)) - ); return ( { isConnected={isConnected} isListenOnly={isListenOnly} isEchoTest={isEchoTest} + isMuted={isMuted} + toggleMuteMicrophoneSystem={Service.toggleMuteMicrophoneSystem} autoplayBlocked={autoplayBlocked} getEchoTest={getEchoTest} joinFullAudioImmediately={joinFullAudioImmediately} @@ -123,6 +124,7 @@ const AudioModalContainer = (props) => { joinListenOnly={joinListenOnly} leaveEchoTest={leaveEchoTest} changeInputDevice={Service.changeInputDevice} + liveChangeInputDevice={Service.liveChangeInputDevice} changeInputStream={Service.changeInputStream} changeOutputDevice={Service.changeOutputDevice} joinEchoTest={Service.joinEchoTest} @@ -144,7 +146,14 @@ const AudioModalContainer = (props) => { isRTL={isRTL} AudioError={AudioError} getTroubleshootingLink={AudioModalService.getTroubleshootingLink} + getMicrophonePermissionStatus={Service.getMicrophonePermissionStatus} + getAudioConstraints={Service.getAudioConstraints} + doGUM={Service.doGUM} + bypassGUM={Service.bypassGUM} + supportsTransparentListenOnly={supportsTransparentListenOnly} setIsOpen={setIsOpen} + hasMicrophonePermission={Service.hasMicrophonePermission} + permissionStatus={permissionStatus} {...props} /> ); 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 67ae971935..0f22573939 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/service.js +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/service.js @@ -20,7 +20,10 @@ export const didUserSelectedListenOnly = () => ( !!Storage.getItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY) ); -export const joinMicrophone = (skipEchoTest = false) => { +export const joinMicrophone = (options = {}) => { + const { skipEchoTest = false } = options; + const shouldSkipEcho = skipEchoTest && Service.inputDeviceId() !== 'listen-only'; + Storage.setItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY, true); Storage.setItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY, false); @@ -30,8 +33,8 @@ export const joinMicrophone = (skipEchoTest = false) => { const call = new Promise((resolve, reject) => { try { - if ((skipEchoTest && !Service.isConnected()) || LOCAL_ECHO_TEST_ENABLED) { - return resolve(Service.joinMicrophone()); + if ((shouldSkipEcho && !Service.isConnected()) || LOCAL_ECHO_TEST_ENABLED) { + return resolve(Service.joinMicrophone(options)); } return resolve(Service.transferCall()); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js index 382b474fa2..18feb0ad33 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js @@ -63,6 +63,7 @@ const Connecting = styled.div` margin-top: auto; margin-bottom: auto; font-size: 2rem; + text-align: center; `; const ellipsis = keyframes` diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-settings/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-settings/component.jsx index 8946840743..f0a4154276 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-settings/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-settings/component.jsx @@ -8,36 +8,47 @@ import logger from '/imports/startup/client/logger'; import AudioStreamVolume from '/imports/ui/components/audio/audio-stream-volume/component'; import LocalEchoContainer from '/imports/ui/components/audio/local-echo/container'; import DeviceSelector from '/imports/ui/components/audio/device-selector/component'; -import { - getAudioConstraints, - doGUM, -} from '/imports/api/audio/client/bridge/service'; import MediaStreamUtils from '/imports/utils/media-stream-utils'; -import audioManager from '/imports/ui/services/audio-manager'; +import AudioManager from '/imports/ui/services/audio-manager'; import Session from '/imports/ui/services/storage/in-memory'; const propTypes = { intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired, }).isRequired, + animations: PropTypes.bool, changeInputDevice: PropTypes.func.isRequired, + liveChangeInputDevice: PropTypes.func.isRequired, changeOutputDevice: PropTypes.func.isRequired, handleBack: PropTypes.func.isRequired, handleConfirmation: PropTypes.func.isRequired, handleGUMFailure: PropTypes.func.isRequired, isConnecting: PropTypes.bool.isRequired, + isConnected: PropTypes.bool.isRequired, + isMuted: PropTypes.bool.isRequired, + toggleMuteMicrophoneSystem: PropTypes.func.isRequired, inputDeviceId: PropTypes.string.isRequired, outputDeviceId: PropTypes.string.isRequired, produceStreams: PropTypes.bool, withEcho: PropTypes.bool, withVolumeMeter: PropTypes.bool, notify: PropTypes.func.isRequired, + unmuteOnExit: PropTypes.bool, + doGUM: PropTypes.func.isRequired, + getAudioConstraints: PropTypes.func.isRequired, + checkMicrophonePermission: PropTypes.func.isRequired, + supportsTransparentListenOnly: PropTypes.bool.isRequired, + toggleVoice: PropTypes.func.isRequired, + permissionStatus: PropTypes.string, }; const defaultProps = { + animations: true, produceStreams: false, withEcho: false, withVolumeMeter: false, + unmuteOnExit: false, + permissionStatus: null, }; const intlMessages = defineMessages({ @@ -45,10 +56,6 @@ const intlMessages = defineMessages({ id: 'app.audio.backLabel', description: 'audio settings back button label', }, - descriptionLabel: { - id: 'app.audio.audioSettings.descriptionLabel', - description: 'audio settings description label', - }, micSourceLabel: { id: 'app.audio.audioSettings.microphoneSourceLabel', description: 'Label for mic source', @@ -69,17 +76,36 @@ const intlMessages = defineMessages({ id: 'app.audioNotification.deviceChangeFailed', description: 'Device change failed', }, + confirmLabel: { + id: 'app.audio.audioSettings.confirmLabel', + description: 'Audio settings confirmation button label', + }, + cancelLabel: { + id: 'app.audio.audioSettings.cancelLabel', + description: 'Audio settings cancel button label', + }, + findingDevicesTitle: { + id: 'app.audio.audioSettings.findingDevicesTitle', + description: 'Message for finding audio devices', + }, }); class AudioSettings extends React.Component { constructor(props) { super(props); - const { inputDeviceId, outputDeviceId } = props; + const { + inputDeviceId, + outputDeviceId, + unmuteOnExit, + } = props; this.handleInputChange = this.handleInputChange.bind(this); this.handleOutputChange = this.handleOutputChange.bind(this); this.handleConfirmationClick = this.handleConfirmationClick.bind(this); + this.handleCancelClick = this.handleCancelClick.bind(this); + this.unmuteOnExit = this.unmuteOnExit.bind(this); + this.updateDeviceList = this.updateDeviceList.bind(this); this.state = { inputDeviceId, @@ -88,32 +114,80 @@ class AudioSettings extends React.Component { // blocked until at least one stream is generated producingStreams: props.produceStreams, stream: null, + unmuteOnExit, + audioInputDevices: [], + audioOutputDevices: [], + findingDevices: true, }; this._isMounted = false; } componentDidMount() { - const { inputDeviceId, outputDeviceId } = this.state; + const { + inputDeviceId, + outputDeviceId, + } = this.state; + const { + isConnected, + isMuted, + toggleMuteMicrophoneSystem, + checkMicrophonePermission, + toggleVoice, + permissionStatus, + } = this.props; Session.setItem('inEchoTest', true); this._isMounted = true; // Guarantee initial in/out devices are initialized on all ends - this.setInputDevice(inputDeviceId); - this.setOutputDevice(outputDeviceId); - audioManager.isEchoTest = true; + AudioManager.isEchoTest = true; + checkMicrophonePermission({ gumOnPrompt: true, permissionStatus }) + .then(this.updateDeviceList) + .then(() => { + if (!this._isMounted) return; + + navigator.mediaDevices.addEventListener( + 'devicechange', + this.updateDeviceList, + ); + this.setState({ findingDevices: false }); + this.setInputDevice(inputDeviceId); + this.setOutputDevice(outputDeviceId); + }); + + // If connected and unmuted, we need to mute the audio and revert it + // back to the original state on exit. + if (isConnected && !isMuted) { + toggleMuteMicrophoneSystem(isMuted, toggleVoice); + // We only need to revert the mute state if the user is not listen-only + if (inputDeviceId !== 'listen-only') this.setState({ unmuteOnExit: true }); + } + } + + componentDidUpdate(prevProps) { + const { permissionStatus } = this.props; + + if (prevProps.permissionStatus !== permissionStatus) { + this.updateDeviceList(); + } } componentWillUnmount() { const { stream } = this.state; Session.setItem('inEchoTest', false); - this._mounted = false; + this._isMounted = false; if (stream) { MediaStreamUtils.stopMediaStreamTracks(stream); } - audioManager.isEchoTest = false; + + AudioManager.isEchoTest = false; + navigator.mediaDevices.removeEventListener( + 'devicechange', this.updateDeviceList, + ); + + this.unmuteOnExit(); } handleInputChange(deviceId) { @@ -125,8 +199,17 @@ class AudioSettings extends React.Component { } handleConfirmationClick() { - const { stream } = this.state; - const { produceStreams, handleConfirmation } = this.props; + const { stream, inputDeviceId } = this.state; + const { + isConnected, + produceStreams, + handleConfirmation, + liveChangeInputDevice, + } = this.props; + + // If connected, we need to use the in-call device change method so that all + // components pick up the change and the peer is properly updated. + if (isConnected) liveChangeInputDevice(inputDeviceId); // Stream generation disabled or there isn't any stream: just run the provided callback if (!produceStreams || !stream) return handleConfirmation(); @@ -139,49 +222,63 @@ class AudioSettings extends React.Component { return handleConfirmation(clonedStream); } - setInputDevice(deviceId) { - const { handleGUMFailure, changeInputDevice, produceStreams, intl, notify } = this.props; - const { inputDeviceId: currentInputDeviceId } = this.state; + handleCancelClick() { + const { handleBack } = this.props; + handleBack(); + } + + setInputDevice(deviceId) { + const { + isConnected, + handleGUMFailure, + changeInputDevice, + produceStreams, + intl, + notify, + } = this.props; + const { inputDeviceId: currentInputDeviceId } = this.state; try { - changeInputDevice(deviceId); + if (!isConnected) changeInputDevice(deviceId); + // Only generate input streams if they're going to be used with something // In this case, the volume meter or local echo test. if (produceStreams) { - this.generateInputStream(deviceId) - .then((stream) => { - // Extract the deviceId again from the stream to guarantee consistency - // between stream DID vs chosen DID. That's necessary in scenarios where, - // eg, there's no default/pre-set deviceId ('') and the browser's - // default device has been altered by the user (browser default != system's - // default). - const extractedDeviceId = MediaStreamUtils.extractDeviceIdFromStream(stream, 'audio'); - if (extractedDeviceId && extractedDeviceId !== deviceId) + this.generateInputStream(deviceId).then((stream) => { + // Extract the deviceId again from the stream to guarantee consistency + // between stream DID vs chosen DID. That's necessary in scenarios where, + // eg, there's no default/pre-set deviceId ('') and the browser's + // default device has been altered by the user (browser default != system's + // default). + let extractedDeviceId = deviceId; + + if (stream) { + extractedDeviceId = MediaStreamUtils.extractDeviceIdFromStream(stream, 'audio'); + + if (extractedDeviceId !== deviceId && !isConnected) { changeInputDevice(extractedDeviceId); + } + } - // Component unmounted after gUM resolution -> skip echo rendering - if (!this._isMounted) return; + // Component unmounted after gUM resolution -> skip echo rendering + if (!this._isMounted) return; - this.setState({ - inputDeviceId: extractedDeviceId, - stream, - producingStreams: false, - }); - }) - .catch((error) => { - logger.warn( - { - logCode: 'audiosettings_gum_failed', - extraInfo: { - deviceId, - errorMessage: error.message, - errorName: error.name, - }, - }, - `Audio settings gUM failed: ${error.name}` - ); - handleGUMFailure(error); + this.setState({ + inputDeviceId: extractedDeviceId, + stream, + producingStreams: false, }); + }).catch((error) => { + logger.warn({ + logCode: 'audiosettings_gum_failed', + extraInfo: { + deviceId, + errorMessage: error.message, + errorName: error.name, + }, + }, `Audio settings gUM failed: ${error.name}`); + handleGUMFailure(error); + }); } else { this.setState({ inputDeviceId: deviceId, @@ -198,7 +295,7 @@ class AudioSettings extends React.Component { newDeviceId: deviceId, }, }, - `Audio settings: error changing input device - {${error.name}: ${error.message}}` + `Audio settings: error changing input device - {${error.name}: ${error.message}}`, ); notify(intl.formatMessage(intlMessages.deviceChangeFailed), true); } @@ -233,7 +330,29 @@ class AudioSettings extends React.Component { }); } + updateDeviceList() { + return navigator.mediaDevices.enumerateDevices() + .then((devices) => { + const audioInputDevices = devices.filter((i) => i.kind === 'audioinput'); + const audioOutputDevices = devices.filter((i) => i.kind === 'audiooutput'); + + this.setState({ + audioInputDevices, + audioOutputDevices, + }); + }); + } + + unmuteOnExit() { + const { toggleMuteMicrophoneSystem, toggleVoice } = this.props; + const { unmuteOnExit } = this.state; + + // Unmutes microphone if flagged to do so + if (unmuteOnExit) toggleMuteMicrophoneSystem(true, toggleVoice); + } + generateInputStream(inputDeviceId) { + const { doGUM, getAudioConstraints } = this.props; const { stream } = this.state; if (inputDeviceId && stream) { @@ -244,6 +363,8 @@ class AudioSettings extends React.Component { MediaStreamUtils.stopMediaStreamTracks(stream); } + if (inputDeviceId === 'listen-only') return Promise.resolve(null); + const constraints = { audio: getAudioConstraints({ deviceId: inputDeviceId }), }; @@ -285,9 +406,16 @@ class AudioSettings extends React.Component { } renderDeviceSelectors() { - const { inputDeviceId, outputDeviceId, producingStreams } = this.state; - const { intl, isConnecting } = this.props; - const blocked = producingStreams || isConnecting; + const { + inputDeviceId, + outputDeviceId, + producingStreams, + audioInputDevices, + audioOutputDevices, + findingDevices, + } = this.state; + const { intl, isConnecting, supportsTransparentListenOnly } = this.props; + const blocked = producingStreams || isConnecting || findingDevices; return ( @@ -298,10 +426,12 @@ class AudioSettings extends React.Component { @@ -313,10 +443,12 @@ class AudioSettings extends React.Component { @@ -326,32 +458,46 @@ class AudioSettings extends React.Component { } render() { - const { isConnecting, intl, handleBack } = this.props; - const { producingStreams } = this.state; + const { + findingDevices, + producingStreams, + } = this.state; + const { + isConnecting, + isConnected, + intl, + animations, + } = this.props; return ( - - {intl.formatMessage(intlMessages.descriptionLabel)} - {this.renderDeviceSelectors()} {this.renderOutputTest()} {this.renderVolumeMeter()} - + {findingDevices && ( + + {intl.formatMessage(intlMessages.findingDevicesTitle)} + + + )}