import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, AppState, ScrollView, Share, StyleSheet, View, } from 'react-native'; import { Led, NativeThemeProvider, PhosphorText, PhysicalButton, WaveBars, d3roNativeFonts, d3roNativeNumerals, } from '@d3ro/ui-native'; import { useI18n } from '@d3ro/i18n'; import { useAuth } from '../lib/auth-context'; import { ScreenHeader } from '../components/ScreenHeader'; import { audioRecorder, RecorderBusyError, type RecordedAudio, type RecorderSession, type RecordingProgress, type RecordingRuntimeSnapshot, } from '../lib/audio-recorder'; import { pickAudioFile } from '../features/import/audio-file-picker'; import { consumeIncomingMediaAudio } from '../features/import/incoming-media-intent'; import { AudioPipelineError, type AudioPipelinePhase, type LocalAudioInput, } from '../features/import/audio-import-types'; import { prepareRecordedAudio } from '../features/import/recorded-audio-input'; import { mayDiscardOriginalOnFailure } from '../features/import/audio-input-ownership'; import { useMobilePreferences } from '../lib/preferences-context'; import { showLocalTranscriptionCompleted } from '../features/notifications/notification-native'; import { discardQueuedAudio, enqueueAudioProcessing, processQueuedAudioNow, QueuedAudioPendingError, retryQueuedAudio, subscribeToDurableQueue, } from '../features/recording/durable-processing-queue'; import { resolveRecordingLanguage } from '../features/recording/recording-language'; import { beginMeetingRecording, cancelMeetingRecording, failMeetingRecording, getMeetingRecordingLanguage, } from '../features/meetings/meetings-service'; type RecordingState = | 'idle' | 'selecting' | 'starting' | 'recording' | 'paused' | 'stopping' | AudioPipelinePhase | 'cancelling' | 'done' | 'queued' | 'recoverable' | 'error'; function errorMessage(error: unknown, fallback: string): string { // Talk holding the microphone is not a failure detail worth showing raw. if (error instanceof RecorderBusyError) return fallback; return error instanceof Error && error.message.trim() !== '' ? error.message : fallback; } function formatTime(milliseconds: number): string { const totalSeconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } type TranslationKey = Parameters['t']>[0]; function stateLabelKey(state: RecordingState): TranslationKey { switch (state) { case 'selecting': return 'mobile.rec.stateSelecting'; case 'starting': return 'mobile.rec.stateStarting'; case 'recording': return 'mobile.rec.stateRecording'; case 'paused': return 'mobile.rec.statePaused'; case 'stopping': return 'mobile.rec.stateStopping'; case 'validating': return 'mobile.rec.stateValidating'; case 'uploading': return 'mobile.rec.stateUploading'; case 'transcribing': return 'mobile.rec.stateTranscribing'; case 'saving': return 'mobile.rec.stateSaving'; case 'cancelling': return 'mobile.rec.stateCancelling'; case 'done': return 'mobile.rec.stateDone'; case 'queued': return 'mobile.rec.stateQueued'; case 'recoverable': return 'mobile.rec.stateRecoverable'; case 'error': return 'mobile.rec.stateError'; default: return 'mobile.rec.stateReady'; } } function isPipelineState(state: RecordingState): boolean { return ( state === 'validating' || state === 'uploading' || state === 'transcribing' || state === 'saving' || state === 'cancelling' ); } interface RecordScreenMeetingParams { meetingId?: string; meetingTitle?: string; meetingLanguage?: string; } interface RecordScreenParams extends RecordScreenMeetingParams { incomingMediaId?: string; importRequest?: number; } interface RecordScreenProps { route: { params?: RecordScreenParams } navigation: { getParent: () => { navigate: (name: 'MeetingDetail', params: { meetingId: string }) => void } | undefined /** Clears one-shot route bindings once their action is committed. */ setParams?: (params: Partial) => void } } export default function RecordScreen({ route, navigation }: RecordScreenProps): React.ReactElement { const { t, locale } = useI18n(); const { user } = useAuth(); const { palette } = useMobilePreferences(); const styles = useMemo(() => createStyles(palette), [palette]); const [state, setState] = useState('idle'); const [transcript, setTranscript] = useState(''); const [error, setError] = useState(null); const [durationMs, setDurationMs] = useState(0); const [meteringDb, setMeteringDb] = useState(null); const [fileName, setFileName] = useState(null); const [provider, setProvider] = useState(null); const [deduplicated, setDeduplicated] = useState(false); const [actionPending, setActionPending] = useState(false); const [sharePending, setSharePending] = useState(false); const [pendingIncomingMediaId, setPendingIncomingMediaId] = useState(null); // Meeting whose capture produced the result on screen (for "view meeting"). const [completedMeetingId, setCompletedMeetingId] = useState(null); const actionPendingRef = useRef(false); const mountedRef = useRef(true); const recorderActiveRef = useRef(false); const operationUserRef = useRef(null); const queueItemIdRef = useRef(null); const recoveredRecordingRef = useRef(null); const activeMeetingIdRef = useRef(route.params?.meetingId ?? null); // Meeting the next START binds to. MeetingDetail arms it through route // params; it is released once that meeting's capture is committed, so a // later "NEW RECORDING" cannot re-begin (and restart) a finished meeting. const armedMeetingIdRef = useRef(route.params?.meetingId ?? null); // Record's owner-scoped handle on the shared recorder; see recordSession(). const recorderSessionRef = useRef(null); const routeMeetingId = route.params?.meetingId ?? null; const routeMeetingTitle = route.params?.meetingTitle ?? null; const routeIncomingMediaId = route.params?.incomingMediaId ?? null; const routeImportRequest = route.params?.importRequest; const handledIncomingMediaIds = useRef(new Set()); const handledImportRequests = useRef(new Set()); const importAudioFileRef = useRef<() => Promise>(async () => undefined); // Latest route binding, read by callbacks registered on an earlier render. const routeMeetingIdRef = useRef(routeMeetingId); routeMeetingIdRef.current = routeMeetingId; /** * A meeting binding covers one capture. Once the capture's transcript is * committed, Record falls back to plain recording and the route params are * cleared, so returning through the tab bar does not re-arm the meeting. * MeetingDetail re-arms it explicitly ("record again") with fresh params. */ function releaseMeetingBinding(meetingId: string | null): void { setCompletedMeetingId(meetingId); if (meetingId === null) return; if (armedMeetingIdRef.current === meetingId) armedMeetingIdRef.current = null; if (activeMeetingIdRef.current === meetingId) activeMeetingIdRef.current = null; if (routeMeetingIdRef.current === meetingId) { navigation.setParams?.({ meetingId: undefined, meetingTitle: undefined, meetingLanguage: undefined }); } } // Latest release callback, for subscriptions registered on an earlier render. const releaseMeetingBindingRef = useRef(releaseMeetingBinding); releaseMeetingBindingRef.current = releaseMeetingBinding; /** * Record owns meeting captures, including one the Android foreground service * kept alive across a JS restart. Throws RecorderBusyError, with no side * effects, while Talk holds the microphone. */ async function recordSession(): Promise { const existing = recorderSessionRef.current; if (existing !== null) return existing; const session = await audioRecorder.acquire('record'); recorderSessionRef.current = session; return session; } function handleRecorderProgress(progress: RecordingProgress): void { if (!mountedRef.current) return; setDurationMs(progress.durationMs); setMeteringDb(progress.meteringDb); } function applyRecorderSnapshot(snapshot: RecordingRuntimeSnapshot): void { if (!mountedRef.current) return; activeMeetingIdRef.current = snapshot.meetingId ?? activeMeetingIdRef.current; if (snapshot.recording !== null) { setDurationMs(snapshot.recording.durationMs); setFileName(snapshot.recording.fileName); } if (snapshot.state === 'recording' || snapshot.state === 'paused') { recorderActiveRef.current = true; recoveredRecordingRef.current = null; setState(snapshot.state); if (snapshot.interruptionReason !== null && snapshot.state === 'paused') { setError(t('mobile.rec.interrupted', { reason: snapshot.interruptionReason })); } } else if ( (snapshot.state === 'stopped' || snapshot.state === 'recoverable') && snapshot.recording !== null ) { recorderActiveRef.current = false; recoveredRecordingRef.current = snapshot.recording; setMeteringDb(null); setState('recoverable'); if (snapshot.interruptionReason !== null) { setError(t('mobile.rec.recoveredReason', { reason: snapshot.interruptionReason })); } } } useEffect(() => { mountedRef.current = true; void recordSession() .then(session => session.restore(handleRecorderProgress, applyRecorderSnapshot)) .catch(restoreError => { // A Talk capture is not Record's to restore; leave Record idle. if (!mountedRef.current || restoreError instanceof RecorderBusyError) return; setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed'))); setState('error'); }); return () => { mountedRef.current = false; }; // Foreground recording intentionally survives tab/screen unmount. Logout cleanup is global. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => subscribeToDurableQueue(event => { if (event.type === 'completed') { if (event.itemId !== queueItemIdRef.current || !mountedRef.current) return; queueItemIdRef.current = null; operationUserRef.current = null; releaseMeetingBindingRef.current(event.result.meetingId ?? activeMeetingIdRef.current); setTranscript(event.result.transcript); setProvider(event.result.provider); setDurationMs(Math.max(0, Math.round(event.result.durationSeconds * 1_000))); setDeduplicated(event.result.deduplicated); setError(null); setState('done'); return; } if (event.type === 'removed') return; if (event.item.id !== queueItemIdRef.current || !mountedRef.current) return; if (event.item.status === 'retry') { setState('queued'); setError(t('mobile.rec.queuedRetry')); } else if (event.item.status === 'terminal') { setState('error'); setError(event.item.lastErrorMessage ?? t('mobile.rec.processFailed')); } else if (event.item.phase !== null) { setState(event.item.phase); } }), [t]); useEffect(() => { const operationUserId = operationUserRef.current; if (operationUserId !== null && operationUserId !== user?.id) { const queueItemId = queueItemIdRef.current; if (queueItemId !== null) { void discardQueuedAudio(operationUserId, queueItemId).catch(() => undefined); } } }, [user?.id]); useEffect(() => { armedMeetingIdRef.current = routeMeetingId; if (!recorderActiveRef.current && recoveredRecordingRef.current === null) { activeMeetingIdRef.current = routeMeetingId; } }, [routeMeetingId]); useEffect(() => { if (routeIncomingMediaId !== null) setPendingIncomingMediaId(routeIncomingMediaId); }, [routeIncomingMediaId]); function beginAction(): boolean { if (actionPendingRef.current) return false; actionPendingRef.current = true; setActionPending(true); return true; } function endAction(): void { actionPendingRef.current = false; if (mountedRef.current) setActionPending(false); } /** * Hands the input to the durable queue and processes it. `onPersisted` runs * once the queue owns its own copy; before that, a recording is still the * caller's only copy and must not be deleted on failure. */ async function processInput( input: LocalAudioInput, meetingId: string | null = activeMeetingIdRef.current, onPersisted?: () => void, ): Promise { if (user === null) { if (mayDiscardOriginalOnFailure(input.source)) await input.dispose(); throw new AudioPipelineError('auth', t('mobile.rec.loginRequired')); } operationUserRef.current = user.id; const routeLanguage = meetingId !== null && meetingId === route.params?.meetingId ? route.params?.meetingLanguage : undefined; // The language lookup is an optimisation, not a precondition: offline it // falls back to the app locale instead of blocking the hand-off. const storedLanguage = meetingId !== null && routeLanguage === undefined ? await getMeetingRecordingLanguage(user.id, meetingId).catch(() => null) : null; const queued = await enqueueAudioProcessing(input, { userId: user.id, languageCode: resolveRecordingLanguage(routeLanguage ?? storedLanguage ?? undefined, locale), meetingId: meetingId ?? undefined, }); queueItemIdRef.current = queued.id; onPersisted?.(); try { const result = await processQueuedAudioNow(user.id, queued.id); if (!mountedRef.current) return; releaseMeetingBinding(result.meetingId ?? meetingId); setTranscript(result.transcript); setProvider(result.provider); setDurationMs(Math.max(0, Math.round(result.durationSeconds * 1000))); setDeduplicated(result.deduplicated); if (AppState.currentState !== 'active' && result.historyId !== null) { void showLocalTranscriptionCompleted(result.historyId).catch(() => false); } setState('done'); queueItemIdRef.current = null; operationUserRef.current = null; } catch (processingError) { if (processingError instanceof QueuedAudioPendingError) { if (mountedRef.current) { setError(t('mobile.rec.queuedRetry')); setState('queued'); } return; } queueItemIdRef.current = null; operationUserRef.current = null; throw processingError; } finally { if (queueItemIdRef.current === null) { operationUserRef.current = null; } } } async function startRecording(): Promise { if (!beginAction()) return; setError(null); setTranscript(''); setProvider(null); setFileName(null); setDeduplicated(false); setDurationMs(0); setMeteringDb(null); setCompletedMeetingId(null); recoveredRecordingRef.current = null; setState('starting'); recorderActiveRef.current = true; const meetingToBegin = armedMeetingIdRef.current; let meetingBegun = false; try { // Re-check on every start (not the cached handle) so a busy recorder is // reported before a meeting is marked as recording. const recorder = await audioRecorder.acquire('record'); recorderSessionRef.current = recorder; const granted = await audioRecorder.requestPermission(); if (!mountedRef.current) return; if (!granted) { recorderActiveRef.current = false; setError(t('mobile.rec.micDenied')); setState('error'); return; } if (meetingToBegin !== null) { if (user === null) throw new AudioPipelineError('auth', t('mobile.rec.loginRequired')); await beginMeetingRecording(user.id, meetingToBegin); meetingBegun = true; activeMeetingIdRef.current = meetingToBegin; } else { activeMeetingIdRef.current = null; } await recorder.start(handleRecorderProgress, { meetingId: activeMeetingIdRef.current ?? undefined, onStateChange: applyRecorderSnapshot, }); if (mountedRef.current) setState('recording'); } catch (recordingError) { recorderActiveRef.current = false; if (meetingBegun && user !== null && meetingToBegin !== null) { await failMeetingRecording( user.id, meetingToBegin, errorMessage(recordingError, t('mobile.rec.startFailed')), ).catch(() => undefined); } if (mountedRef.current) { setError(errorMessage(recordingError, t('mobile.rec.startFailed'))); setState('error'); } } finally { endAction(); } } async function pauseOrResumeRecording(): Promise { if (!beginAction()) return; setError(null); try { const recorder = await recordSession(); if (state === 'recording') { await recorder.pause(); if (mountedRef.current) { setMeteringDb(null); setState('paused'); } } else if (state === 'paused') { await recorder.resume(); if (mountedRef.current) setState('recording'); } } catch (recordingError) { if (mountedRef.current) { setError(errorMessage(recordingError, t('mobile.rec.stateChangeFailed'))); } } finally { endAction(); } } async function stopRecording(): Promise { if (!beginAction()) return; setError(null); setState('stopping'); try { const recorder = await recordSession(); let recording: RecordedAudio; try { recording = await recorder.stop(); } catch (stopError) { await recoverFromFailedStop(recorder, stopError); return; } recorderActiveRef.current = false; recoveredRecordingRef.current = null; if (!mountedRef.current) { await recorder.cleanup(recording); return; } setDurationMs(recording.durationMs); setFileName(recording.fileName); await handOffRecording(recorder, recording); } catch (recordingError) { recorderActiveRef.current = false; if (!mountedRef.current) return; if (recordingError instanceof AudioPipelineError && recordingError.code === 'cancelled') { setState('idle'); setTranscript(''); setFileName(null); return; } setError(errorMessage(recordingError, t('mobile.rec.processFailed'))); setState('error'); } finally { endAction(); } } /** * A failed stop does not mean the audio is gone: the native capture may * still be running or may have left a recoverable file. Re-read it so the * user keeps the stop/cancel or process/discard actions. */ async function recoverFromFailedStop( recorder: RecorderSession, stopError: unknown, ): Promise { let snapshot: RecordingRuntimeSnapshot | null; try { snapshot = await recorder.restore(handleRecorderProgress, applyRecorderSnapshot); } catch { snapshot = null; } if (snapshot !== null && snapshot.state === 'idle') recorderActiveRef.current = false; if (!mountedRef.current) return; setError(errorMessage(stopError, t('mobile.rec.processFailed'))); // Otherwise applyRecorderSnapshot already moved the UI to the live capture // or to the recoverable file. if (snapshot === null || snapshot.state === 'idle') setState('error'); } /** * Hands a finished capture to the durable queue. Until the queue has * persisted its own copy the capture is the only copy, so a failure before * that point (too large to upload, unreadable, no session, storage full) * keeps it as a recovered recording the user can process again or discard * explicitly, instead of deleting it. Failures after that point belong to * the queue item and are rethrown. */ async function handOffRecording( recorder: RecorderSession, recording: RecordedAudio, ): Promise { let persisted = false; try { const input = await prepareRecordedAudio( recording, async () => { await recorder.cleanup(recording); }, { onInvalid: 'retain' }, ); await processInput(input, activeMeetingIdRef.current, () => { persisted = true; }); } catch (handOffError) { if (persisted) throw handOffError; recoveredRecordingRef.current = recording; if (!mountedRef.current) return; setMeteringDb(null); setError(errorMessage(handOffError, t('mobile.rec.processFailed'))); setState('recoverable'); } } async function processRecoveredRecording(): Promise { const recording = recoveredRecordingRef.current; if (recording === null || !beginAction()) return; setError(null); setState('validating'); try { const recorder = await recordSession(); recoveredRecordingRef.current = null; await handOffRecording(recorder, recording); } catch (processingError) { if (!mountedRef.current) return; setError(errorMessage(processingError, t('mobile.rec.processFailed'))); setState('error'); } finally { endAction(); } } async function discardRecoveredRecording(): Promise { const recording = recoveredRecordingRef.current; if (recording === null || !beginAction()) return; try { const recorder = await recordSession(); await recorder.cleanup(recording); recoveredRecordingRef.current = null; const meetingId = activeMeetingIdRef.current; if (meetingId !== null && user !== null) { await cancelMeetingRecording(user.id, meetingId).catch(() => undefined); } activeMeetingIdRef.current = armedMeetingIdRef.current; if (mountedRef.current) { setState('idle'); setError(null); setDurationMs(0); setFileName(null); } } catch (cleanupError) { if (mountedRef.current) { setError(errorMessage(cleanupError, t('mobile.rec.cancelFailed'))); setState('recoverable'); } } finally { endAction(); } } async function importAudioFile(): Promise { if (!beginAction()) return; const previousState = state; let selectedForProcessing = false; setError(null); setState('selecting'); setCompletedMeetingId(null); try { const input = await pickAudioFile(); selectedForProcessing = true; if (!mountedRef.current) { await input.dispose(); return; } setTranscript(''); setProvider(null); setDeduplicated(false); setDurationMs(0); setFileName(input.fileName); await processInput(input); } catch (importError) { if (!mountedRef.current) return; if (importError instanceof AudioPipelineError && importError.code === 'cancelled') { if (selectedForProcessing) { setState('idle'); setTranscript(''); setFileName(null); setProvider(null); setDeduplicated(false); setDurationMs(0); } else { setState(previousState === 'done' ? 'done' : 'idle'); } return; } setError(errorMessage(importError, t('mobile.rec.importFailed'))); setState('error'); } finally { endAction(); } } importAudioFileRef.current = importAudioFile; useEffect(() => { if ( routeImportRequest === undefined || handledImportRequests.current.has(routeImportRequest) || actionPending || !['idle', 'done', 'error'].includes(state) ) return; handledImportRequests.current.add(routeImportRequest); navigation.setParams?.({ importRequest: undefined }); void importAudioFileRef.current(); }, [actionPending, navigation, routeImportRequest, state]); async function importIncomingMedia(): Promise { if (!beginAction()) return; const previousState = state; setError(null); setState('selecting'); setCompletedMeetingId(null); try { const input = await consumeIncomingMediaAudio(); if (input === null) { if (mountedRef.current) setState(previousState === 'done' ? 'done' : 'idle'); return; } if (!mountedRef.current) { await input.dispose(); return; } setTranscript(''); setProvider(null); setDeduplicated(false); setDurationMs(0); setFileName(input.fileName); await processInput(input); } catch (importError) { if (!mountedRef.current) return; setError(errorMessage(importError, t('mobile.rec.sharedImportFailed'))); setState('error'); } finally { setPendingIncomingMediaId(null); endAction(); } } useEffect(() => { if ( pendingIncomingMediaId === null || handledIncomingMediaIds.current.has(pendingIncomingMediaId) || actionPending || !['idle', 'done', 'error'].includes(state) ) return; handledIncomingMediaIds.current.add(pendingIncomingMediaId); void importIncomingMedia(); // The operation intentionally starts once for a native delivery id. State // changes during processing must not re-consume the descriptor. // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionPending, pendingIncomingMediaId, state]); async function cancelActiveWork(): Promise { const queueItemId = queueItemIdRef.current; const operationUserId = operationUserRef.current; if (queueItemId !== null && operationUserId !== null) { setState('cancelling'); try { await discardQueuedAudio(operationUserId, queueItemId); queueItemIdRef.current = null; operationUserRef.current = null; const meetingId = activeMeetingIdRef.current; if (meetingId !== null && user?.id === operationUserId) { await failMeetingRecording( operationUserId, meetingId, t('mobile.rec.processingCancelled'), ).catch(() => undefined); } if (mountedRef.current) { setState('idle'); setError(null); } } catch (cancelError) { if (mountedRef.current) { setError(errorMessage(cancelError, t('mobile.rec.cancelFailed'))); setState('queued'); } } return; } if (!recorderActiveRef.current || !beginAction()) return; try { const recorder = await recordSession(); await recorder.cancel(); recorderActiveRef.current = false; const meetingId = activeMeetingIdRef.current; if (meetingId !== null && user !== null) { await cancelMeetingRecording(user.id, meetingId).catch(() => undefined); } activeMeetingIdRef.current = armedMeetingIdRef.current; if (!mountedRef.current) return; setState('idle'); setTranscript(''); setError(null); setDurationMs(0); setMeteringDb(null); setFileName(null); setProvider(null); setDeduplicated(false); } catch (recordingError) { if (mountedRef.current) { setError(errorMessage(recordingError, t('mobile.rec.cancelFailed'))); setState('error'); } } finally { endAction(); } } async function retryQueuedWork(): Promise { const itemId = queueItemIdRef.current; if (itemId === null || user === null || !beginAction()) return; setError(null); setState('validating'); try { await retryQueuedAudio(user.id, itemId); } catch (retryError) { if (mountedRef.current) { setError(errorMessage(retryError, t('mobile.rec.processFailed'))); setState('queued'); } } finally { endAction(); } } async function shareTranscript(): Promise { if (sharePending || transcript.trim() === '') return; setSharePending(true); setError(null); try { await Share.share( { title: fileName === null ? t('mobile.rec.shareTitle') : fileName, message: transcript, }, { dialogTitle: t('mobile.rec.shareDialogTitle') }, ); } catch (shareError) { if (mountedRef.current) { setError(errorMessage(shareError, t('mobile.rec.shareFailed'))); } } finally { if (mountedRef.current) setSharePending(false); } } const captureActive = state === 'recording' || state === 'paused'; const pipelineActive = isPipelineState(state); const busy = state === 'selecting' || state === 'starting' || state === 'stopping' || pipelineActive; return ( {formatTime(durationMs)} ) : undefined} testID="record-header" /> {activeMeetingIdRef.current !== null && ( {t('mobile.rec.meetingRecording')} {routeMeetingTitle ?? activeMeetingIdRef.current} )} {t(stateLabelKey(state))} {state === 'idle' && ( {t('mobile.rec.initLog')} )} {busy && !captureActive && ( )} {fileName !== null && !captureActive && ( {fileName} )} {state === 'done' && ( {transcript === '' ? t('mobile.rec.noSpeech') : transcript} {provider !== null && {provider.toUpperCase()}} {formatTime(durationMs)} {deduplicated && ( {t('mobile.rec.syncedCopy')} )} )} {error !== null && ( {error} )} {state === 'recoverable' && ( {t('mobile.rec.recoveredHelp')} )} {state === 'queued' && ( {t('mobile.rec.queuedHelp')} )} {captureActive && ( {state === 'recording' ? t('mobile.rec.listening') : t('mobile.rec.paused')} {t('mobile.rec.realtime')} {meteringDb !== null && {`${meteringDb.toFixed(1)} dBFS`}} )} {(state === 'idle' || state === 'done' || state === 'error') && ( <> void startRecording()} testID="record-start" /> void importAudioFile()} testID="record-import-file" /> )} {state === 'done' && transcript.trim() !== '' && ( <> void shareTranscript()} testID="record-share-transcript" /> )} {state === 'done' && completedMeetingId !== null && ( <> { navigation.getParent()?.navigate('MeetingDetail', { meetingId: completedMeetingId }); }} testID="record-view-meeting" /> )} {state === 'recoverable' && ( <> void processRecoveredRecording()} testID="record-process-recovered" /> void discardRecoveredRecording()} testID="record-discard-recovered" /> )} {state === 'queued' && ( <> void retryQueuedWork()} testID="record-retry-queued" /> void cancelActiveWork()} testID="record-cancel-queued" /> )} {captureActive && ( <> void stopRecording()} testID="record-stop" /> void pauseOrResumeRecording()} testID="record-pause-resume" /> void cancelActiveWork()} testID="record-cancel" /> )} {pipelineActive && ( void cancelActiveWork()} testID="record-cancel-processing" /> )} ); } type Palette = ReturnType['palette']; function createStyles(palette: Palette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg.app }, content: { paddingBottom: 100 }, headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 }, headerTime: { ...d3roNativeNumerals }, meetingContext: { paddingHorizontal: 20, paddingTop: 12, gap: 4 }, statusRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingHorizontal: 20, paddingTop: 12 }, statusText: { letterSpacing: 2 }, transcriptArea: { minHeight: 190, justifyContent: 'center', alignItems: 'center', gap: 14, paddingHorizontal: 20, paddingVertical: 24 }, initLog: { fontFamily: d3roNativeFonts.sans, textAlign: 'center' }, fileName: { fontFamily: d3roNativeFonts.sans, ...d3roNativeNumerals, textAlign: 'center' }, transcriptBox: { backgroundColor: palette.bg.inset, padding: 14, borderRadius: 8, width: '100%', gap: 14 }, resultMeta: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 12 }, errorBox: { borderWidth: 1, borderColor: palette.tag.red, backgroundColor: palette.bg.card, padding: 10, borderRadius: 6, width: '100%' }, errorText: { color: palette.tag.red }, listeningRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap', gap: 8, paddingHorizontal: 16, paddingBottom: 16 }, listeningText: { letterSpacing: 2 }, buttons: { paddingHorizontal: 20, paddingBottom: 16 }, buttonSpacer: { height: 12 }, }); }