diff --git a/conference.js b/conference.js index c9db7b7e56..d9b2187086 100644 --- a/conference.js +++ b/conference.js @@ -76,12 +76,16 @@ import { import { getStartWithAudioMuted, getStartWithVideoMuted, + isAudioMuted, + isVideoMuted, isVideoMutedByUser, MEDIA_TYPE, setAudioAvailable, setAudioMuted, + setAudioUnmutePermissions, setVideoAvailable, - setVideoMuted + setVideoMuted, + setVideoUnmutePermissions } from './react/features/base/media'; import { dominantSpeakerChanged, @@ -2257,6 +2261,27 @@ export default { APP.store.dispatch(suspendDetected()); }); + room.on( + JitsiConferenceEvents.AUDIO_UNMUTE_PERMISSIONS_CHANGED, + disableAudioMuteChange => { + const muted = isAudioMuted(APP.store.getState()); + + // Disable the mute button only if its muted. + if (!disableAudioMuteChange || (disableAudioMuteChange && muted)) { + APP.store.dispatch(setAudioUnmutePermissions(disableAudioMuteChange)); + } + }); + room.on( + JitsiConferenceEvents.VIDEO_UNMUTE_PERMISSIONS_CHANGED, + disableVideoMuteChange => { + const muted = isVideoMuted(APP.store.getState()); + + // Disable the mute button only if its muted. + if (!disableVideoMuteChange || (disableVideoMuteChange && muted)) { + APP.store.dispatch(setVideoUnmutePermissions(disableVideoMuteChange)); + } + }); + APP.UI.addListener(UIEvents.AUDIO_MUTED, muted => { this.muteAudio(muted); }); diff --git a/lang/main.json b/lang/main.json index 71739aa267..3216ed36ec 100644 --- a/lang/main.json +++ b/lang/main.json @@ -572,6 +572,8 @@ "notify": { "allowAction": "Allow", "allowedUnmute": "You can unmute your microphone, start your camera or share your screen.", + "audioUnmuteBlockedTitle": "Mic unmute blocked!", + "audioUnmuteBlockedDescription": "Mic unmute operation has been temporarily blocked because of system limits.", "connectedOneMember": "{{name}} joined the meeting", "connectedThreePlusMembers": "{{name}} and many others joined the meeting", "connectedTwoMembers": "{{first}} and {{second}} joined the meeting", @@ -622,7 +624,9 @@ "moderationToggleDescription": "by {{participantDisplayName}}", "raiseHandAction": "Raise hand", "reactionSounds": "Disable sounds", - "groupTitle": "Notifications" + "groupTitle": "Notifications", + "videoUnmuteBlockedTitle": "Camera unmute blocked!", + "videoUnmuteBlockedDescription": "Camera unmute operation has been temporarily blocked because of system limits." }, "participantsPane": { "close": "Close", diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index 6ca9b3f27c..7abc75945a 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -10,7 +10,15 @@ import { endpointMessageReceived } from '../../subtitles'; import { getReplaceParticipant } from '../config/functions'; import { JITSI_CONNECTION_CONFERENCE_KEY } from '../connection'; import { JitsiConferenceEvents } from '../lib-jitsi-meet'; -import { MEDIA_TYPE, setAudioMuted, setVideoMuted } from '../media'; +import { + MEDIA_TYPE, + isAudioMuted, + isVideoMuted, + setAudioMuted, + setAudioUnmutePermissions, + setVideoMuted, + setVideoUnmutePermissions +} from '../media'; import { dominantSpeakerChanged, getNormalizedDisplayName, @@ -146,6 +154,27 @@ function _addConferenceListeners(conference, dispatch, state) { } }); + conference.on( + JitsiConferenceEvents.AUDIO_UNMUTE_PERMISSIONS_CHANGED, + disableAudioMuteChange => { + const muted = isAudioMuted(state); + + // Disable the mute button only if its muted. + if (!disableAudioMuteChange || (disableAudioMuteChange && muted)) { + APP.store.dispatch(setAudioUnmutePermissions(disableAudioMuteChange)); + } + }); + conference.on( + JitsiConferenceEvents.VIDEO_UNMUTE_PERMISSIONS_CHANGED, + disableVideoMuteChange => { + const muted = isVideoMuted(state); + + // Disable the mute button only if its muted. + if (!disableVideoMuteChange || (disableVideoMuteChange && muted)) { + APP.store.dispatch(setVideoUnmutePermissions(disableVideoMuteChange)); + } + }); + // Dispatches into features/base/tracks follow: conference.on( diff --git a/react/features/base/media/actionTypes.js b/react/features/base/media/actionTypes.js index db54f1c610..625b87174f 100644 --- a/react/features/base/media/actionTypes.js +++ b/react/features/base/media/actionTypes.js @@ -1,3 +1,14 @@ + +/** + * The type of (redux) action to adjust the availability of the local audio. + * + * { + * type: SET_AUDIO_AVAILABLE, + * muted: boolean + * } + */ +export const SET_AUDIO_AVAILABLE = 'SET_AUDIO_AVAILABLE'; + /** * The type of (redux) action to set the muted state of the local audio. * @@ -9,14 +20,14 @@ export const SET_AUDIO_MUTED = 'SET_AUDIO_MUTED'; /** - * The type of (redux) action to adjust the availability of the local audio. + * The type of (redux) action to enable/disable the audio mute icon. * * { - * type: SET_AUDIO_AVAILABLE, - * muted: boolean + * type: SET_AUDIO_UNMUTE_PERMISSIONS, + * blocked: boolean * } */ -export const SET_AUDIO_AVAILABLE = 'SET_AUDIO_AVAILABLE'; +export const SET_AUDIO_UNMUTE_PERMISSIONS = 'SET_AUDIO_UNMUTE_PERMISSIONS'; /** * The type of (redux) action to set the facing mode of the local video camera @@ -61,6 +72,16 @@ export const SET_VIDEO_MUTED = 'SET_VIDEO_MUTED'; */ export const STORE_VIDEO_TRANSFORM = 'STORE_VIDEO_TRANSFORM'; +/** + * The type of (redux) action to enable/disable the video mute icon. + * + * { + * type: SET_VIDEO_UNMUTE_PERMISSIONS, + * blocked: boolean + * } + */ + export const SET_VIDEO_UNMUTE_PERMISSIONS = 'SET_VIDEO_UNMUTE_PERMISSIONS'; + /** * The type of (redux) action to toggle the local video camera facing mode. In * contrast to SET_CAMERA_FACING_MODE, allows the toggling to be optimally diff --git a/react/features/base/media/actions.js b/react/features/base/media/actions.js index 4195c361ee..b85e9707dd 100644 --- a/react/features/base/media/actions.js +++ b/react/features/base/media/actions.js @@ -9,9 +9,11 @@ import { isModerationNotificationDisplayed } from '../../notifications'; import { SET_AUDIO_MUTED, SET_AUDIO_AVAILABLE, + SET_AUDIO_UNMUTE_PERMISSIONS, SET_CAMERA_FACING_MODE, SET_VIDEO_AVAILABLE, SET_VIDEO_MUTED, + SET_VIDEO_UNMUTE_PERMISSIONS, STORE_VIDEO_TRANSFORM, TOGGLE_CAMERA_FACING_MODE } from './actionTypes'; @@ -59,6 +61,19 @@ export function setAudioMuted(muted: boolean, ensureTrack: boolean = false) { }; } +/** + * Action to disable/enable the audio mute icon. + * + * @param {boolean} blocked - True if the audio mute icon needs to be disabled. + * @returns {Function} + */ +export function setAudioUnmutePermissions(blocked: boolean) { + return { + type: SET_AUDIO_UNMUTE_PERMISSIONS, + blocked + }; +} + /** * Action to set the facing mode of the local camera. * @@ -136,6 +151,19 @@ export function setVideoMuted( }; } +/** + * Action to disable/enable the video mute icon. + * + * @param {boolean} blocked - True if the video mute icon needs to be disabled. + * @returns {Function} + */ +export function setVideoUnmutePermissions(blocked: boolean) { + return { + type: SET_VIDEO_UNMUTE_PERMISSIONS, + blocked + }; +} + /** * Creates an action to store the last video {@link Transform} applied to a * stream. diff --git a/react/features/base/media/functions.js b/react/features/base/media/functions.js index cce22cc218..97ab03c92d 100644 --- a/react/features/base/media/functions.js +++ b/react/features/base/media/functions.js @@ -88,6 +88,16 @@ export function getStartWithVideoMuted(stateful: Object | Function) { return Boolean(getPropertyValue(stateful, 'startWithVideoMuted', START_WITH_AUDIO_VIDEO_MUTED_SOURCES)); } +/** + * Determines whether video is currently muted. + * + * @param {Function|Object} stateful - The redux store, state, or {@code getState} function. + * @returns {boolean} + */ +export function isVideoMuted(stateful: Function | Object) { + return Boolean(toState(stateful)['features/base/media'].video.muted); +} + /** * Determines whether video is currently muted by the user authority. * diff --git a/react/features/base/media/middleware.js b/react/features/base/media/middleware.js index c0d8b9b37e..9733554237 100644 --- a/react/features/base/media/middleware.js +++ b/react/features/base/media/middleware.js @@ -8,6 +8,10 @@ import { sendAnalytics } from '../../analytics'; import { APP_STATE_CHANGED } from '../../mobile/background'; +import { + NOTIFICATION_TIMEOUT_TYPE, + showWarningNotification +} from '../../notifications'; import { isForceMuted } from '../../participants-pane/functions'; import { SET_AUDIO_ONLY, setAudioOnly } from '../audio-only'; import { isRoomValid, SET_ROOM } from '../conference'; @@ -21,7 +25,12 @@ import { TRACK_ADDED } from '../tracks'; -import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from './actionTypes'; +import { + SET_AUDIO_MUTED, + SET_AUDIO_UNMUTE_PERMISSIONS, + SET_VIDEO_MUTED, + SET_VIDEO_UNMUTE_PERMISSIONS +} from './actionTypes'; import { setAudioMuted, setCameraFacingMode, setVideoMuted } from './actions'; import { CAMERA_FACING_MODE, @@ -74,6 +83,18 @@ MiddlewareRegistry.register(store => next => action => { break; } + case SET_AUDIO_UNMUTE_PERMISSIONS: { + const { blocked } = action; + + if (blocked) { + store.dispatch(showWarningNotification({ + descriptionKey: 'notify.audioUnmuteBlockedDescription', + titleKey: 'notify.audioUnmuteBlockedTitle' + }, NOTIFICATION_TIMEOUT_TYPE.LONG)); + } + break; + } + case SET_VIDEO_MUTED: { const state = store.getState(); const participant = getLocalParticipant(state); @@ -83,6 +104,18 @@ MiddlewareRegistry.register(store => next => action => { } break; } + + case SET_VIDEO_UNMUTE_PERMISSIONS: { + const { blocked } = action; + + if (blocked) { + store.dispatch(showWarningNotification({ + descriptionKey: 'notify.videoUnmuteBlockedDescription', + titleKey: 'notify.videoUnmuteBlockedTitle' + }, NOTIFICATION_TIMEOUT_TYPE.LONG)); + } + break; + } } return next(action); diff --git a/react/features/base/media/reducer.js b/react/features/base/media/reducer.js index 0813e59ce4..10db2e96b0 100644 --- a/react/features/base/media/reducer.js +++ b/react/features/base/media/reducer.js @@ -7,9 +7,11 @@ import { TRACK_REMOVED } from '../tracks/actionTypes'; import { SET_AUDIO_AVAILABLE, SET_AUDIO_MUTED, + SET_AUDIO_UNMUTE_PERMISSIONS, SET_CAMERA_FACING_MODE, SET_VIDEO_AVAILABLE, SET_VIDEO_MUTED, + SET_VIDEO_UNMUTE_PERMISSIONS, STORE_VIDEO_TRANSFORM, TOGGLE_CAMERA_FACING_MODE } from './actionTypes'; @@ -33,6 +35,7 @@ import { CAMERA_FACING_MODE } from './constants'; */ export const _AUDIO_INITIAL_MEDIA_STATE = { available: true, + blocked: false, muted: false }; @@ -59,6 +62,12 @@ function _audio(state = _AUDIO_INITIAL_MEDIA_STATE, action) { muted: action.muted }; + case SET_AUDIO_UNMUTE_PERMISSIONS: + return { + ...state, + blocked: action.blocked + }; + default: return state; } @@ -83,6 +92,7 @@ function _audio(state = _AUDIO_INITIAL_MEDIA_STATE, action) { */ export const _VIDEO_INITIAL_MEDIA_STATE = { available: true, + blocked: false, facingMode: CAMERA_FACING_MODE.USER, muted: 0, @@ -126,6 +136,12 @@ function _video(state = _VIDEO_INITIAL_MEDIA_STATE, action) { muted: action.muted }; + case SET_VIDEO_UNMUTE_PERMISSIONS: + return { + ...state, + blocked: action.blocked + }; + case STORE_VIDEO_TRANSFORM: return _storeVideoTransform(state, action); diff --git a/react/features/talk-while-muted/middleware.js b/react/features/talk-while-muted/middleware.js index 276535e7c7..ea70ba5f20 100644 --- a/react/features/talk-while-muted/middleware.js +++ b/react/features/talk-while-muted/middleware.js @@ -13,6 +13,7 @@ import { showNotification } from '../notifications'; import { isForceMuted } from '../participants-pane/functions'; +import { isAudioMuteButtonDisabled } from '../toolbox/functions.any'; import { setCurrentNotificationUid } from './actions'; import { TALK_WHILE_MUTED_SOUND_ID } from './constants'; @@ -46,24 +47,27 @@ MiddlewareRegistry.register(store => next => action => { JitsiConferenceEvents.TALK_WHILE_MUTED, async () => { const state = getState(); const local = getLocalParticipant(state); - const forceMuted = isForceMuted(local, MEDIA_TYPE.AUDIO, state); - const notification = await dispatch(showNotification({ - titleKey: 'toolbar.talkWhileMutedPopup', - customActionNameKey: forceMuted ? 'notify.raiseHandAction' : 'notify.unmute', - customActionHandler: () => dispatch(forceMuted ? raiseHand(true) : setAudioMuted(false)) - }, NOTIFICATION_TIMEOUT_TYPE.LONG)); - const { soundsTalkWhileMuted } = getState()['features/base/settings']; + // Display the talk while muted notification only when the audio button is not disabled. + if (!isAudioMuteButtonDisabled(state)) { + const forceMuted = isForceMuted(local, MEDIA_TYPE.AUDIO, state); + const notification = await dispatch(showNotification({ + titleKey: 'toolbar.talkWhileMutedPopup', + customActionNameKey: forceMuted ? 'notify.raiseHandAction' : 'notify.unmute', + customActionHandler: () => dispatch(forceMuted ? raiseHand(true) : setAudioMuted(false)) + }, NOTIFICATION_TIMEOUT_TYPE.LONG)); - if (soundsTalkWhileMuted) { - dispatch(playSound(TALK_WHILE_MUTED_SOUND_ID)); - } + const { soundsTalkWhileMuted } = getState()['features/base/settings']; + if (soundsTalkWhileMuted) { + dispatch(playSound(TALK_WHILE_MUTED_SOUND_ID)); + } - if (notification) { - // we store the last start muted notification id that we showed, - // so we can hide it when unmuted mic is detected - dispatch(setCurrentNotificationUid(notification.uid)); + if (notification) { + // we store the last start muted notification id that we showed, + // so we can hide it when unmuted mic is detected + dispatch(setCurrentNotificationUid(notification.uid)); + } } }); break; diff --git a/react/features/toolbox/components/AudioMuteButton.js b/react/features/toolbox/components/AudioMuteButton.js index 2a6d0aa8ef..41d982e55d 100644 --- a/react/features/toolbox/components/AudioMuteButton.js +++ b/react/features/toolbox/components/AudioMuteButton.js @@ -14,6 +14,7 @@ import { AbstractAudioMuteButton } from '../../base/toolbox/components'; import type { AbstractButtonProps } from '../../base/toolbox/components'; import { isLocalTrackMuted } from '../../base/tracks'; import { muteLocal } from '../../video-menu/actions'; +import { isAudioMuteButtonDisabled } from '../functions'; declare var APP: Object; @@ -151,7 +152,7 @@ class AudioMuteButton extends AbstractAudioMuteButton { */ function _mapStateToProps(state): Object { const _audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO); - const _disabled = state['features/base/config'].startSilent; + const _disabled = state['features/base/config'].startSilent || isAudioMuteButtonDisabled(state); const enabledFlag = getFeatureFlag(state, AUDIO_MUTE_BUTTON_ENABLED, true); return { diff --git a/react/features/toolbox/functions.any.js b/react/features/toolbox/functions.any.js new file mode 100644 index 0000000000..4821db409e --- /dev/null +++ b/react/features/toolbox/functions.any.js @@ -0,0 +1,13 @@ +// @flow + +/** + * Indicates if the audio mute button is disabled or not. + * + * @param {Object} state - The state from the Redux store. + * @returns {boolean} + */ +export function isAudioMuteButtonDisabled(state: Object) { + const { audio } = state['features/base/media']; + + return !(audio?.available && !audio?.blocked); +} diff --git a/react/features/toolbox/functions.native.js b/react/features/toolbox/functions.native.js index 47ccd5cea0..792d266c77 100644 --- a/react/features/toolbox/functions.native.js +++ b/react/features/toolbox/functions.native.js @@ -6,6 +6,8 @@ import { getParticipantCountWithFake } from '../base/participants'; import { toState } from '../base/redux'; import { isLocalVideoTrackDesktop } from '../base/tracks'; +export * from './functions.any'; + const WIDTH = { FIT_9_ICONS: 560, FIT_8_ICONS: 500, @@ -78,5 +80,7 @@ export function isToolboxVisible(stateful: Object | Function) { * @returns {boolean} */ export function isVideoMuteButtonDisabled(state: Object) { - return !hasAvailableDevices(state, 'videoInput') || isLocalVideoTrackDesktop(state); + const { video } = state['features/base/media']; + + return !hasAvailableDevices(state, 'videoInput') || video?.blocked || isLocalVideoTrackDesktop(state); } diff --git a/react/features/toolbox/functions.web.js b/react/features/toolbox/functions.web.js index 2df9bcd5fd..ba231aed71 100644 --- a/react/features/toolbox/functions.web.js +++ b/react/features/toolbox/functions.web.js @@ -5,6 +5,8 @@ import { hasAvailableDevices } from '../base/devices'; import { TOOLBAR_TIMEOUT } from './constants'; +export * from './functions.any'; + /** * Helper for getting the height of the toolbox. * @@ -58,8 +60,9 @@ export function isToolboxVisible(state: Object) { * @returns {boolean} */ export function isAudioSettingsButtonDisabled(state: Object) { - return (!hasAvailableDevices(state, 'audioInput') - && !hasAvailableDevices(state, 'audioOutput')) + + return !(hasAvailableDevices(state, 'audioInput') + && hasAvailableDevices(state, 'audioOutput')) || state['features/base/config'].startSilent; } @@ -80,7 +83,9 @@ export function isVideoSettingsButtonDisabled(state: Object) { * @returns {boolean} */ export function isVideoMuteButtonDisabled(state: Object) { - return !hasAvailableDevices(state, 'videoInput'); + const { video } = state['features/base/media']; + + return !hasAvailableDevices(state, 'videoInput') || video?.blocked; } /**