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
1035 lines
38 KiB
TypeScript
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 },
|
|
});
|
|
}
|