Files
upstream-jitsi-meet/react/features/recording/middleware.ts
upstream-mirror 1d2bfcac0c
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s
snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
2026-09-15 15:56:32 +05:00

727 lines
29 KiB
TypeScript

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
}));
});
}