fix(mobile-recorder): scope recorder control to its owner and keep Android recordings on stop failure
This commit is contained in:
parent
72c4ddaefb
commit
ace946d5c7
10 changed files with 1673 additions and 697 deletions
|
|
@ -22,7 +22,9 @@ import { useI18n } from '@d3ro/i18n';
|
|||
import { useAuth } from '../lib/auth-context';
|
||||
import {
|
||||
audioRecorder,
|
||||
RecorderBusyError,
|
||||
type RecordedAudio,
|
||||
type RecorderSession,
|
||||
type RecordingProgress,
|
||||
type RecordingRuntimeSnapshot,
|
||||
} from '../lib/audio-recorder';
|
||||
|
|
@ -67,6 +69,8 @@ type RecordingState =
|
|||
| '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;
|
||||
|
|
@ -142,12 +146,32 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
const queueItemIdRef = useRef<string | null>(null);
|
||||
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
|
||||
const activeMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
|
||||
const recorder = audioRecorder;
|
||||
// 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 handledIncomingMediaIds = useRef(new Set<string>());
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
|
@ -178,24 +202,20 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void recorder.restore(
|
||||
(progress: RecordingProgress) => {
|
||||
if (!mountedRef.current) return;
|
||||
setDurationMs(progress.durationMs);
|
||||
setMeteringDb(progress.meteringDb);
|
||||
},
|
||||
applyRecorderSnapshot,
|
||||
).catch(restoreError => {
|
||||
if (!mountedRef.current) return;
|
||||
setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed')));
|
||||
setState('error');
|
||||
});
|
||||
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
|
||||
}, [recorder]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => subscribeToDurableQueue(event => {
|
||||
if (event.type === 'completed') {
|
||||
|
|
@ -320,7 +340,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
recorderActiveRef.current = true;
|
||||
let meetingBegun = false;
|
||||
try {
|
||||
const granted = await recorder.requestPermission();
|
||||
// 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;
|
||||
|
|
@ -336,11 +360,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
} else {
|
||||
activeMeetingIdRef.current = null;
|
||||
}
|
||||
await recorder.start((progress: RecordingProgress) => {
|
||||
if (!mountedRef.current) return;
|
||||
setDurationMs(progress.durationMs);
|
||||
setMeteringDb(progress.meteringDb);
|
||||
}, {
|
||||
await recorder.start(handleRecorderProgress, {
|
||||
meetingId: activeMeetingIdRef.current ?? undefined,
|
||||
onStateChange: applyRecorderSnapshot,
|
||||
});
|
||||
|
|
@ -367,6 +387,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
if (!beginAction()) return;
|
||||
setError(null);
|
||||
try {
|
||||
const recorder = await recordSession();
|
||||
if (state === 'recording') {
|
||||
await recorder.pause();
|
||||
if (mountedRef.current) {
|
||||
|
|
@ -391,7 +412,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
setError(null);
|
||||
setState('stopping');
|
||||
try {
|
||||
const recording = await recorder.stop();
|
||||
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) {
|
||||
|
|
@ -420,12 +448,36 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
async function processRecoveredRecording(): Promise<void> {
|
||||
const recording = recoveredRecordingRef.current;
|
||||
if (recording === null || !beginAction()) return;
|
||||
setError(null);
|
||||
setState('validating');
|
||||
try {
|
||||
const recorder = await recordSession();
|
||||
const input = await prepareRecordedAudio(recording, async () => {
|
||||
await recorder.cleanup(recording);
|
||||
});
|
||||
|
|
@ -444,6 +496,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
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;
|
||||
|
|
@ -584,6 +637,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
|||
}
|
||||
if (!recorderActiveRef.current || !beginAction()) return;
|
||||
try {
|
||||
const recorder = await recordSession();
|
||||
await recorder.cancel();
|
||||
recorderActiveRef.current = false;
|
||||
const meetingId = activeMeetingIdRef.current;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue