import { batch } from 'react-redux'; import { createRecordingEvent } from '../analytics/AnalyticsEvents'; import { sendAnalytics } from '../analytics/functions'; import { IStore } from '../app/types'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes'; import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes'; import { getCurrentConference } from '../base/conference/functions'; import { openDialog } from '../base/dialog/actions'; import { MEET_FEATURES } from '../base/jwt/constants'; import { isJwtFeatureEnabled } from '../base/jwt/functions'; import JitsiMeetJS, { JitsiConferenceEvents, JitsiRecordingConstants } from '../base/lib-jitsi-meet'; import { setAudioMuted, setAudioUnmutePermissions, setVideoMuted, setVideoUnmutePermissions } from '../base/media/actions'; import { MEDIA_TYPE } from '../base/media/constants'; import { PARTICIPANT_UPDATED } from '../base/participants/actionTypes'; import { updateLocalRecordingStatus } from '../base/participants/actions'; import { PARTICIPANT_ROLE } from '../base/participants/constants'; import { getLocalParticipant, getParticipantDisplayName, isLocalParticipantModerator } from '../base/participants/functions'; import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; import StateListenerRegistry from '../base/redux/StateListenerRegistry'; import { playSound, stopSound } from '../base/sounds/actions'; import { TRACK_ADDED } from '../base/tracks/actionTypes'; import { hideNotification, showErrorNotification, showNotification } from '../notifications/actions'; import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants'; import { canAddTranscriber, isRecorderTranscriptionsRunning, isTranscribing } from '../transcribing/functions'; import { RECORDING_SESSION_UPDATED, START_LOCAL_RECORDING, STOP_LOCAL_RECORDING } from './actionTypes'; import { clearRecordingSessions, hidePendingRecordingNotification, markConsentRequested, setLocalRecordingRunning, setStartRecordingIntent, setStopRecordingIntent, showPendingRecordingNotification, showRecordingError, showRecordingWarning, showStartRecordingNotification, showStartedRecordingNotification, showStoppedRecordingNotification, updateRecordingSessionData } from './actions'; import { RecordingConsentDialog } from './components/Recording'; import LocalRecordingManager from './components/Recording/LocalRecordingManager'; import { LIVE_STREAMING_OFF_SOUND_ID, LIVE_STREAMING_ON_SOUND_ID, RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID, RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID, RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID, START_RECORDING_NOTIFICATION_ID, TRANSCRIPTION_OFF_SOUND_ID, TRANSCRIPTION_ON_SOUND_ID } from './constants'; import { getResourceId, getSessionById, registerRecordingAudioFiles, shouldRequireRecordingConsent, unregisterRecordingAudioFiles } from './functions'; import logger from './logger'; import { getNudge } from './nudge'; import { ISessionData } from './reducer'; /** * Evaluates whether all intended services (recording and/or transcription) have * resolved (succeeded or failed) and plays the appropriate start sound and notification. * * Intent source: {@code startRecordingIntent} — populated on the local side by the * start dialog / auto-start callback, and on remote observers by the metadata listener * when a false→true transition of isRecordingRequested / isTranscribingEnabled is seen. * * Resolution is derived from existing Redux state — no separate tracking needed. * The recording notification's "started by …" name is read directly from the active * FILE session in {@code sessionDatas}. When recording is on but the initiator has not * yet been delivered by jicofo, the function waits — keeping the intent alive. * * Called from: * - Recording middleware when RECORDING_SESSION_UPDATED arrives with the initiator * (jicofo update) or with an error. * - Transcription subscriber when isRecorderTranscriptionsRunning becomes true. * - Subtitles middleware when conference.dial() fails. * - Transcribing middleware when TRANSCRIBER_LEFT abruptly. * - Metadata change listener when room metadata updates. * * @param {Function} dispatch - Redux dispatch. * @param {Function} getState - Redux getState. * @returns {void} */ export function maybeNotifyRecordingStart(dispatch: IStore['dispatch'], getState: IStore['getState']) { const state = getState(); const intent = state['features/recording'].startRecordingIntent; const wantsRecording = Boolean(intent?.recording); const wantsTranscription = Boolean(intent?.transcription); // No intent — nothing to coordinate. if (!wantsRecording && !wantsTranscription) { return; } const { sessionDatas } = state['features/recording']; const { mode: modeConstants, status: statusConstants } = JitsiRecordingConstants; // Locate the active FILE recording session (if any) and derive its state. const fileSession = sessionDatas.find(sd => sd.mode === modeConstants.FILE && (sd.status === statusConstants.ON || sd.error)); const recordingOn = fileSession?.status === statusConstants.ON; const recordingFailed = Boolean(fileSession?.error); const recordingResolved = !wantsRecording || recordingOn || recordingFailed; // Derive transcription resolution from existing state. const transcriptionOn = isRecorderTranscriptionsRunning(state) || isTranscribing(state); const transcriptionFailed = state['features/subtitles']._hasError; const transcriptionResolved = !wantsTranscription || transcriptionOn || transcriptionFailed; // Wait until all intended services have resolved. // Note: In theory (it should never happen here) wantsTranscription/wantsRecording might be false and in the same // time the transcriptionOn/recordingOn might be true. In this case we would play a notification no matter that // wantsTranscription/wantsRecording is false. This is better because the recording/transcription are on and the // user has to be informed. Also if this ever happens it will be noticeable and we will be able to debug/fix. if (!recordingResolved || !transcriptionResolved) { return; } // Recording is on but jicofo hasn't yet delivered the initiator. Wait for it // so the notification text reflects who started the recording. if (recordingOn && (!fileSession?.initiator || !fileSession.id)) { return; } // Clear the intent BEFORE dispatching below to avoid a re-entrancy double // fire — see the matching comment in maybeNotifyRecordingStop. if (intent) { dispatch(setStartRecordingIntent(null)); } // Determine sound to play now that all intended services have resolved. let soundID: string | undefined; if (recordingOn && transcriptionOn) { soundID = RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID; } else if (recordingOn) { soundID = RECORDING_ON_SOUND_ID; } else if (transcriptionOn) { soundID = TRANSCRIPTION_ON_SOUND_ID; } // If both failed — no start sound (error notifications handle it). if (soundID) { dispatch(playSound(soundID)); } // Nudge to start the complementary service when only one was just started and the other is not // already running — shown to any participant that has the right to start that other service: // transcription needs the transcription feature; cloud recording needs moderator + the recording // feature. (A recording-only participant is never nudged to transcribe, nor vice versa.) const canStartTranscription = canAddTranscriber(state); const canStartRecording = isLocalParticipantModerator(state) && isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false); const nudgeNeeded = (wantsRecording !== wantsTranscription) && (wantsRecording ? (!transcriptionOn && canStartTranscription) : (!recordingOn && canStartRecording)); const nudge = nudgeNeeded ? getNudge(wantsRecording ? 'recording' : 'transcription', dispatch) : null; if (recordingOn && fileSession?.initiator && fileSession.id) { dispatch(showStartedRecordingNotification( modeConstants.FILE, fileSession.initiator, fileSession.id, recordingOn && transcriptionOn, nudge ?? undefined)); } else if (transcriptionOn && !recordingOn) { // Transcription-only case (recording failed or wasn't requested). dispatch(showNotification({ descriptionKey: 'transcribing.on', titleKey: 'dialog.recording', ...(nudge ? { description: nudge.descriptionText, customActionNameKey: [ nudge.actionNameKey ], customActionHandler: [ nudge.handler ] } : {}) }, nudge ? NOTIFICATION_TIMEOUT_TYPE.LONG : NOTIFICATION_TIMEOUT_TYPE.SHORT)); } } /** * Evaluates whether all intended services (recording and/or transcription) that * are being stopped have resolved, and plays the appropriate off sound and * notification. Mirror of {@link maybeNotifyRecordingStart}. * * Intent source: {@code stopRecordingIntent} — populated on the local side by the * stop dialog, and on remote observers by the metadata listener when a true→false * transition of isRecordingRequested / isTranscribingEnabled is seen. * * Called from: * - Metadata change listener on true→false transitions. * - Recording middleware when RECORDING_SESSION_UPDATED arrives with OFF. * - Transcription subscriber when isRecorderTranscriptionsRunning becomes false. * - Transcribing middleware when TRANSCRIBER_LEFT abruptly. * * @param {Function} dispatch - Redux dispatch. * @param {Function} getState - Redux getState. * @returns {void} */ export function maybeNotifyRecordingStop(dispatch: IStore['dispatch'], getState: IStore['getState']) { const state = getState(); const intent = state['features/recording'].stopRecordingIntent; if (!intent) { return; } const { sessionDatas } = state['features/recording']; const { mode: modeConstants, status: statusConstants } = JitsiRecordingConstants; // Recording resolves when there's no active/pending FILE session. const activeFileSession = sessionDatas.find(sd => sd.mode === modeConstants.FILE && (sd.status === statusConstants.ON || sd.status === statusConstants.PENDING)); const recordingResolved = !intent.recording || !activeFileSession; // Transcription resolves when the selector flips false. const transcriptionResolved = !intent.transcription || !isRecorderTranscriptionsRunning(state); if (!recordingResolved || !transcriptionResolved) { return; } // Determine sounds + notification. let onSoundID: string | undefined; let offSoundID: string | undefined; if (intent.recording && intent.transcription) { onSoundID = RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID; offSoundID = RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID; } else if (intent.recording) { onSoundID = RECORDING_ON_SOUND_ID; offSoundID = RECORDING_OFF_SOUND_ID; } else if (intent.transcription) { onSoundID = TRANSCRIPTION_ON_SOUND_ID; offSoundID = TRANSCRIPTION_OFF_SOUND_ID; } // Clear the intent BEFORE dispatching below. Otherwise the first dispatch // re-enters StateListenerRegistry synchronously; the isRecorderTranscriptionsRunning // subscriber (in ../transcribing/subscriber) can fire in that nested walk // with an unresolved prevSel and call back into this function while intent // is still set — producing a duplicate notification. Reading intent into a // local const above keeps the branches below correct. dispatch(setStopRecordingIntent(null)); if (offSoundID) { // Always stop the combined sound in case both services were started together. dispatch(stopSound(RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID)); if (onSoundID && onSoundID !== RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID) { dispatch(stopSound(onSoundID)); } dispatch(playSound(offSoundID)); } if (intent.recording) { // Pick the most recent FILE OFF session so the "stopped by …" name // reflects the current stop, not a stale earlier cycle left in // sessionDatas. sessionDatas is append-order, so the last match wins. let offSession: ISessionData | undefined; sessionDatas.forEach(sd => { if (sd.mode === modeConstants.FILE && sd.status === statusConstants.OFF) { offSession = sd; } }); const participantName = offSession?.terminator ? getParticipantDisplayName(state, getResourceId(offSession.terminator)) : undefined; dispatch(showStoppedRecordingNotification( modeConstants.FILE, participantName, intent.transcription)); } else if (intent.transcription) { dispatch(showNotification({ descriptionKey: 'transcribing.off', titleKey: 'dialog.recording' }, NOTIFICATION_TIMEOUT_TYPE.SHORT)); } } /** * StateListenerRegistry provides a reliable way to detect the leaving of a * conference, where we need to clean up the recording sessions. */ StateListenerRegistry.register( /* selector */ state => getCurrentConference(state), /* listener */ (conference, { dispatch }) => { if (!conference) { dispatch(clearRecordingSessions()); } } ); /** * Listen for metadata changes to coordinate start/stop sound on the remote side. * Detects false↔true transitions for {@code isRecordingRequested} and * {@code isTranscribingEnabled} and seeds the corresponding intent for remote * observers. Local initiators already have the intent set synchronously from * their dialog — the {@code if (!existing)} guard prevents clobbering. */ StateListenerRegistry.register( /* selector */ state => state['features/base/conference'].metadata?.recording, /* listener */ (recordingMetadata, { dispatch, getState }, previousValue) => { const prevRec = Boolean(previousValue?.isRecordingRequested); const prevTrans = Boolean(previousValue?.isTranscribingEnabled); const curRec = Boolean(recordingMetadata?.isRecordingRequested); const curTrans = Boolean(recordingMetadata?.isTranscribingEnabled); const recordingStarting = !prevRec && curRec; const transcriptionStarting = !prevTrans && curTrans; const recordingStopping = prevRec && !curRec; const transcriptionStopping = prevTrans && !curTrans; if (recordingStarting || transcriptionStarting) { const existing = getState()['features/recording'].startRecordingIntent; if (!existing) { dispatch(setStartRecordingIntent({ recording: recordingStarting, transcription: transcriptionStarting })); } maybeNotifyRecordingStart(dispatch, getState); } if (recordingStopping || transcriptionStopping) { const existing = getState()['features/recording'].stopRecordingIntent; if (!existing) { dispatch(setStopRecordingIntent({ recording: recordingStopping, transcription: transcriptionStopping })); } maybeNotifyRecordingStop(dispatch, getState); } } ); /** * The redux middleware to handle the recorder updates in a React way. * * @param {Store} store - The redux store. * @returns {Function} */ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { let oldSessionData; if (action.type === RECORDING_SESSION_UPDATED) { oldSessionData = getSessionById(getState(), action.sessionData.id); } const result = next(action); switch (action.type) { case APP_WILL_MOUNT: registerRecordingAudioFiles(dispatch); break; case APP_WILL_UNMOUNT: unregisterRecordingAudioFiles(dispatch); break; case CONFERENCE_JOIN_IN_PROGRESS: { const { conference } = action; conference.on( JitsiConferenceEvents.RECORDER_STATE_CHANGED, (recorderSession: any) => { if (recorderSession) { recorderSession.getID() && dispatch(updateRecordingSessionData(recorderSession)); if (recorderSession.getError()) { _showRecordingErrorNotification(recorderSession, dispatch, getState); } else { _showExplicitConsentDialog(recorderSession, dispatch, getState); } } return; }); break; } case START_LOCAL_RECORDING: { const { localRecording } = getState()['features/base/config']; const { onlySelf } = action; LocalRecordingManager.startLocalRecording({ dispatch, getState }, action.onlySelf) .then(() => { const props = { descriptionKey: 'recording.on', titleKey: 'dialog.recording' }; if (localRecording?.notifyAllParticipants && !onlySelf) { dispatch(playSound(RECORDING_ON_SOUND_ID)); } dispatch(showNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM)); dispatch(showNotification({ titleKey: 'recording.localRecordingStartWarningTitle', descriptionKey: 'recording.localRecordingStartWarning' }, NOTIFICATION_TIMEOUT_TYPE.STICKY)); dispatch(setLocalRecordingRunning(true)); dispatch(updateLocalRecordingStatus(true, onlySelf)); sendAnalytics(createRecordingEvent('started', `local${onlySelf ? '.self' : ''}`)); if (typeof APP !== 'undefined') { APP.API.notifyRecordingStatusChanged( true, 'local', undefined, isRecorderTranscriptionsRunning(getState())); } }) .catch(err => { logger.error('Capture failed', err); let descriptionKey = 'recording.error'; if (err.message === 'WrongSurfaceSelected') { descriptionKey = 'recording.surfaceError'; } else if (err.message === 'NoLocalStreams') { descriptionKey = 'recording.noStreams'; } else if (err.message === 'NoMicTrack') { descriptionKey = 'recording.noMicPermission'; } const props = { descriptionKey, titleKey: 'recording.failedToStart' }; if (typeof APP !== 'undefined') { APP.API.notifyRecordingStatusChanged( false, 'local', err.message, isRecorderTranscriptionsRunning(getState())); } dispatch(showErrorNotification(props)); }); break; } case STOP_LOCAL_RECORDING: { const { localRecording } = getState()['features/base/config']; if (LocalRecordingManager.isRecordingLocally()) { // Capture before stopping — the recorder's stop handler resets the flag. const wasSelfRecording = LocalRecordingManager.selfRecording.on; LocalRecordingManager.stopLocalRecording(); dispatch(setLocalRecordingRunning(false)); dispatch(updateLocalRecordingStatus(false)); if (localRecording?.notifyAllParticipants && !wasSelfRecording) { dispatch(playSound(RECORDING_OFF_SOUND_ID)); } if (typeof APP !== 'undefined') { APP.API.notifyRecordingStatusChanged( false, 'local', undefined, isRecorderTranscriptionsRunning(getState())); } } break; } case RECORDING_SESSION_UPDATED: { const state = getState(); // When in recorder mode no notifications are shown // or extra sounds are also not desired // but we want to indicate those in case of sip gateway const { iAmRecorder, iAmSipGateway } = state['features/base/config']; if (iAmRecorder && !iAmSipGateway) { break; } const updatedSessionData = getSessionById(state, action.sessionData.id); const { initiator, mode = '', terminator } = updatedSessionData ?? {}; const { PENDING, OFF, ON } = JitsiRecordingConstants.status; const isRecordingStarting = updatedSessionData?.status === PENDING && oldSessionData?.status !== PENDING; if (isRecordingStarting || updatedSessionData?.status === ON) { dispatch(hideNotification(START_RECORDING_NOTIFICATION_ID)); } if (isRecordingStarting) { dispatch(showPendingRecordingNotification(mode)); break; } dispatch(hidePendingRecordingNotification(mode)); if (updatedSessionData?.status === ON) { // We receive 2 updates of the session status ON. The first one is from jibri when it joins. // The second one is from jicofo which will deliver the initiator value. Since the start // recording notification uses the initiator value we skip the jibri update and show the // notification on the update from jicofo. // FIXME: simplify checks when the backend start sending only one status ON update containing // the initiator. if (initiator && !oldSessionData?.initiator) { // Initiator just became known — let maybeNotifyRecordingStart // decide whether to emit the notification now. It reads the // initiator directly from sessionDatas and gates on its presence. maybeNotifyRecordingStart(dispatch, getState); } if (oldSessionData?.status !== ON) { sendAnalytics(createRecordingEvent('start', mode)); if (mode === JitsiRecordingConstants.mode.STREAM) { dispatch(playSound(LIVE_STREAMING_ON_SOUND_ID)); } // FILE: no call here — handled by the initiator branch above with maybeNotifyRecordingStart. if (typeof APP !== 'undefined') { APP.API.notifyRecordingStatusChanged( true, mode, undefined, isRecorderTranscriptionsRunning(state)); } } } else if (updatedSessionData?.status === OFF && oldSessionData?.status !== OFF) { let duration = 0; if (oldSessionData?.timestamp) { duration = (Date.now() / 1000) - oldSessionData.timestamp; } sendAnalytics(createRecordingEvent('stop', mode, duration)); if (mode === JitsiRecordingConstants.mode.FILE) { // Recording OFF is one of the resolution points — let the stop // coordinator decide which sound/notification to play based on // stopRecordingIntent (combined vs recording-only). maybeNotifyRecordingStop(dispatch, getState); } else if (mode === JitsiRecordingConstants.mode.STREAM) { const participantName = terminator ? getParticipantDisplayName(state, getResourceId(terminator)) : undefined; dispatch(showStoppedRecordingNotification(mode, participantName, false)); dispatch(stopSound(LIVE_STREAMING_ON_SOUND_ID)); dispatch(playSound(LIVE_STREAMING_OFF_SOUND_ID)); } if (typeof APP !== 'undefined') { APP.API.notifyRecordingStatusChanged( false, mode, undefined, isRecorderTranscriptionsRunning(state)); } } break; } case TRACK_ADDED: { const { track } = action; if (LocalRecordingManager.isRecordingLocally() && track.mediaType === MEDIA_TYPE.AUDIO && track.local) { const audioTrack = track.jitsiTrack.track; LocalRecordingManager.addAudioTrackToLocalRecording(audioTrack); } break; } case PARTICIPANT_UPDATED: { const { id, role } = action.participant; const state = getState(); const localParticipant = getLocalParticipant(state); if (localParticipant?.id !== id) { return next(action); } if (role === PARTICIPANT_ROLE.MODERATOR) { dispatch(showStartRecordingNotification()); } return next(action); } } return result; }); /** * Shows a notification about an error in the recording session. A * default notification will display if no error is specified in the passed * in recording session. * * @private * @param {Object} session - The recorder session model from the * lib. * @param {Dispatch} dispatch - The Redux Dispatch function. * @param {Function} getState - The Redux getState function. * @returns {void} */ function _showRecordingErrorNotification(session: any, dispatch: IStore['dispatch'], getState: IStore['getState']) { const mode = session.getMode(); const error = session.getError(); const isStreamMode = mode === JitsiMeetJS.constants.recording.mode.STREAM; switch (error) { case JitsiMeetJS.constants.recording.error.SERVICE_UNAVAILABLE: dispatch(showRecordingError({ descriptionKey: 'recording.unavailable', descriptionArguments: { serviceName: isStreamMode ? '$t(liveStreaming.serviceName)' : '$t(recording.serviceName)' }, titleKey: isStreamMode ? 'liveStreaming.unavailableTitle' : 'recording.unavailableTitle' })); break; case JitsiMeetJS.constants.recording.error.RESOURCE_CONSTRAINT: dispatch(showRecordingError({ descriptionKey: isStreamMode ? 'liveStreaming.busy' : 'recording.busy', titleKey: isStreamMode ? 'liveStreaming.busyTitle' : 'recording.busyTitle' })); break; case JitsiMeetJS.constants.recording.error.UNEXPECTED_REQUEST: dispatch(showRecordingWarning({ descriptionKey: isStreamMode ? 'liveStreaming.sessionAlreadyActive' : 'recording.sessionAlreadyActive', titleKey: isStreamMode ? 'liveStreaming.inProgress' : 'recording.inProgress' })); break; case JitsiMeetJS.constants.recording.error.POLICY_VIOLATION: dispatch(showRecordingWarning({ descriptionKey: isStreamMode ? 'liveStreaming.policyError' : 'recording.policyError', titleKey: isStreamMode ? 'liveStreaming.failedToStart' : 'recording.failedToStart' })); break; default: dispatch(showRecordingError({ descriptionKey: isStreamMode ? 'liveStreaming.error' : 'recording.error', titleKey: isStreamMode ? 'liveStreaming.failedToStart' : 'recording.failedToStart' })); break; } if (typeof APP !== 'undefined') { APP.API.notifyRecordingStatusChanged(false, mode, error, isRecorderTranscriptionsRunning(getState())); } // Recording failed — re-evaluate. maybeNotifyRecordingStart derives state from // sessionDatas (which now has the error) and will play transcription-only sound // if transcription succeeded. const intent = getState()['features/recording'].startRecordingIntent; if (intent) { maybeNotifyRecordingStart(dispatch, getState); } } /** * Mutes audio and video and displays the RecordingConsentDialog when the conditions are met. * * @param {any} recorderSession - The recording session. * @param {Function} dispatch - The Redux dispatch function. * @param {Function} getState - The Redux getState function. * @returns {void} */ function _showExplicitConsentDialog(recorderSession: any, dispatch: IStore['dispatch'], getState: IStore['getState']) { if (!shouldRequireRecordingConsent(recorderSession, getState())) { return; } // Capture the current mute state BEFORE forcing mute for consent // This preserves the user's intentional mute choices from prejoin or initial settings const state = getState(); const audioWasMuted = state['features/base/media'].audio.muted; const videoWasMuted = state['features/base/media'].video.muted; batch(() => { dispatch(markConsentRequested(recorderSession.getID())); dispatch(setAudioUnmutePermissions(true, true)); dispatch(setVideoUnmutePermissions(true, true)); dispatch(setAudioMuted(true)); dispatch(setVideoMuted(true)); dispatch(openDialog('RecordingConsentDialog', RecordingConsentDialog, { audioWasMuted, videoWasMuted })); }); }