d3ro-voice/apps/mobile-rn/src/screens/RecordScreen.tsx
Yun Chan 5ca3709b32
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
feat: add mobile shell and unified search
2026-09-28 23:23:36 +09:00

1035 lines
38 KiB
TypeScript

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<ReturnType<typeof useI18n>['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<RecordScreenParams>) => 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<RecordingState>('idle');
const [transcript, setTranscript] = useState('');
const [error, setError] = useState<string | null>(null);
const [durationMs, setDurationMs] = useState(0);
const [meteringDb, setMeteringDb] = useState<number | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [provider, setProvider] = useState<string | null>(null);
const [deduplicated, setDeduplicated] = useState(false);
const [actionPending, setActionPending] = useState(false);
const [sharePending, setSharePending] = useState(false);
const [pendingIncomingMediaId, setPendingIncomingMediaId] = useState<string | null>(null);
// Meeting whose capture produced the result on screen (for "view meeting").
const [completedMeetingId, setCompletedMeetingId] = useState<string | null>(null);
const actionPendingRef = useRef(false);
const mountedRef = useRef(true);
const recorderActiveRef = useRef(false);
const operationUserRef = useRef<string | null>(null);
const queueItemIdRef = useRef<string | null>(null);
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
const activeMeetingIdRef = useRef<string | null>(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<string | null>(route.params?.meetingId ?? null);
// Record's owner-scoped handle on the shared recorder; see recordSession().
const recorderSessionRef = useRef<RecorderSession | null>(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<string>());
const handledImportRequests = useRef(new Set<number>());
const importAudioFileRef = useRef<() => Promise<void>>(async () => undefined);
// Latest route binding, read by callbacks registered on an earlier render.
const routeMeetingIdRef = useRef<string | null>(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<RecorderSession> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 (
<NativeThemeProvider palette={palette}>
<ScrollView style={styles.container} contentContainerStyle={styles.content} testID="record-screen">
<ScreenHeader
title={captureActive ? t('mobile.rec.session') : t('mobile.rec.title')}
rightContent={captureActive ? (
<View style={styles.headerRight}>
<PhosphorText variant="label" color="amber" style={styles.headerTime}>
{formatTime(durationMs)}
</PhosphorText>
</View>
) : undefined}
testID="record-header"
/>
<WaveBars active={state === 'recording'} />
{activeMeetingIdRef.current !== null && (
<View style={styles.meetingContext} testID="record-meeting-context">
<PhosphorText variant="label" color="amber">
{t('mobile.rec.meetingRecording')}
</PhosphorText>
<PhosphorText variant="small" color="muted" numberOfLines={1}>
{routeMeetingTitle ?? activeMeetingIdRef.current}
</PhosphorText>
</View>
)}
<View accessibilityLiveRegion="polite" style={styles.statusRow} testID="record-status">
<Led color={state === 'error' ? 'red' : state === 'done' ? 'green' : 'amber'} size={6} />
<PhosphorText variant="label" color="muted" style={styles.statusText}>
{t(stateLabelKey(state))}
</PhosphorText>
</View>
<View style={styles.transcriptArea}>
{state === 'idle' && (
<PhosphorText variant="small" color="muted" style={styles.initLog}>{t('mobile.rec.initLog')}</PhosphorText>
)}
{busy && !captureActive && (
<ActivityIndicator size="large" color={palette.accent.main} testID="record-processing-spinner" />
)}
{fileName !== null && !captureActive && (
<PhosphorText variant="small" color="muted" style={styles.fileName}>{fileName}</PhosphorText>
)}
{state === 'done' && (
<View style={styles.transcriptBox} testID="record-transcript-result">
<PhosphorText variant="body" color="primary" selectable>
{transcript === '' ? t('mobile.rec.noSpeech') : transcript}
</PhosphorText>
<View style={styles.resultMeta}>
{provider !== null && <PhosphorText variant="label" color="muted">{provider.toUpperCase()}</PhosphorText>}
<PhosphorText variant="label" color="muted">{formatTime(durationMs)}</PhosphorText>
{deduplicated && (
<PhosphorText variant="label" color="amber">
{t('mobile.rec.syncedCopy')}
</PhosphorText>
)}
</View>
</View>
)}
{error !== null && (
<View
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
style={styles.errorBox}
testID="record-error"
>
<PhosphorText variant="small" color="label" style={styles.errorText}>{error}</PhosphorText>
</View>
)}
{state === 'recoverable' && (
<PhosphorText variant="small" color="muted" style={styles.initLog}>
{t('mobile.rec.recoveredHelp')}
</PhosphorText>
)}
{state === 'queued' && (
<PhosphorText variant="small" color="muted" style={styles.initLog}>
{t('mobile.rec.queuedHelp')}
</PhosphorText>
)}
</View>
{captureActive && (
<View style={styles.listeningRow}>
<Led color={state === 'recording' ? 'amber' : 'green'} size={6} />
<PhosphorText variant="label" color="amber" style={styles.listeningText}>
{state === 'recording' ? t('mobile.rec.listening') : t('mobile.rec.paused')}
</PhosphorText>
<PhosphorText variant="label" color="muted">{t('mobile.rec.realtime')}</PhosphorText>
{meteringDb !== null && <PhosphorText variant="label" color="muted">{`${meteringDb.toFixed(1)} dBFS`}</PhosphorText>}
</View>
)}
<View style={styles.buttons}>
{(state === 'idle' || state === 'done' || state === 'error') && (
<>
<PhysicalButton
label={state === 'done' ? t('mobile.rec.newRecording') : t('mobile.rec.start')}
variant="primary"
disabled={actionPending}
onPress={() => void startRecording()}
testID="record-start"
/>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.importAudio')}
variant="secondary"
disabled={actionPending}
onPress={() => void importAudioFile()}
testID="record-import-file"
/>
</>
)}
{state === 'done' && transcript.trim() !== '' && (
<>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={sharePending ? t('mobile.rec.sharing') : t('mobile.rec.shareTranscript')}
variant="secondary"
disabled={sharePending}
onPress={() => void shareTranscript()}
testID="record-share-transcript"
/>
</>
)}
{state === 'done' && completedMeetingId !== null && (
<>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.viewMeeting')}
variant="secondary"
onPress={() => {
navigation.getParent()?.navigate('MeetingDetail', { meetingId: completedMeetingId });
}}
testID="record-view-meeting"
/>
</>
)}
{state === 'recoverable' && (
<>
<PhysicalButton
label={t('mobile.rec.processRecovered')}
variant="primary"
disabled={actionPending}
onPress={() => void processRecoveredRecording()}
testID="record-process-recovered"
/>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.discardRecovered')}
variant="danger"
disabled={actionPending}
onPress={() => void discardRecoveredRecording()}
testID="record-discard-recovered"
/>
</>
)}
{state === 'queued' && (
<>
<PhysicalButton
label={t('mobile.rec.retryNow')}
variant="primary"
disabled={actionPending}
onPress={() => void retryQueuedWork()}
testID="record-retry-queued"
/>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.cancelProcessing')}
variant="danger"
disabled={actionPending}
onPress={() => void cancelActiveWork()}
testID="record-cancel-queued"
/>
</>
)}
{captureActive && (
<>
<PhysicalButton label={t('mobile.rec.stop')} variant="danger" disabled={actionPending} onPress={() => void stopRecording()} testID="record-stop" />
<View style={styles.buttonSpacer} />
<PhysicalButton label={state === 'recording' ? t('mobile.rec.pause') : t('mobile.rec.resume')} variant="secondary" disabled={actionPending} onPress={() => void pauseOrResumeRecording()} testID="record-pause-resume" />
<View style={styles.buttonSpacer} />
<PhysicalButton label={t('mobile.rec.cancel')} variant="secondary" disabled={actionPending} onPress={() => void cancelActiveWork()} testID="record-cancel" />
</>
)}
{pipelineActive && (
<PhysicalButton
label={state === 'cancelling' ? t('mobile.rec.cancelling') : t('mobile.rec.cancelProcessing')}
variant="danger"
disabled={state === 'saving' || state === 'cancelling'}
onPress={() => void cancelActiveWork()}
testID="record-cancel-processing"
/>
)}
</View>
</ScrollView>
</NativeThemeProvider>
);
}
type Palette = ReturnType<typeof useMobilePreferences>['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 },
});
}