Files
llink/js/mobile/src/features/compose/AudioRecordingOverlay.tsx
T
2026-04-29 14:35:53 -07:00

126 lines
3.6 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Mic } from "lucide-react-native";
import {
RecordingPresets,
setAudioModeAsync,
useAudioRecorder,
useAudioRecorderState,
} from "expo-audio";
import { logError } from "@/lib/errors";
const MAX_DURATION_S = 60;
interface AudioRecordingOverlayProps {
onComplete: (result: { uri: string; durationMs: number }) => void;
onCancel: () => void;
}
export function AudioRecordingOverlay({
onComplete,
onCancel,
}: AudioRecordingOverlayProps) {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const state = useAudioRecorderState(recorder, 250);
const finalizedRef = useRef(false);
useEffect(() => {
let active = true;
(async () => {
try {
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
});
await recorder.prepareToRecordAsync();
if (!active) return;
recorder.record();
} catch (err) {
logError(err, { scope: "compose.audio.start" });
if (active) onCancel();
}
})();
return () => {
active = false;
if (!finalizedRef.current) {
finalizedRef.current = true;
recorder.stop().catch(() => {});
}
void setAudioModeAsync({
allowsRecording: false,
playsInSilentMode: true,
}).catch((err) => logError(err, { scope: "compose.audio.exit" }));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const elapsedMs = state.durationMillis ?? 0;
useEffect(() => {
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
void finish("commit");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elapsedMs]);
const finish = async (kind: "commit" | "cancel") => {
if (finalizedRef.current) return;
finalizedRef.current = true;
const durationMs = state.durationMillis ?? 0;
try {
await recorder.stop();
} catch (err) {
logError(err, { scope: "compose.audio.stop" });
}
if (kind === "cancel") {
onCancel();
return;
}
const uri = recorder.uri;
if (!uri) {
onCancel();
return;
}
onComplete({ uri, durationMs });
};
const elapsedSec = Math.floor(elapsedMs / 1000);
return (
<View
style={StyleSheet.absoluteFill}
className="bg-black items-center justify-center px-8"
>
<View className="bg-red-500/30 h-32 w-32 items-center justify-center rounded-full">
<View className="bg-red-500/60 h-24 w-24 items-center justify-center rounded-full">
<Mic color="white" size={42} strokeWidth={1.5} />
</View>
</View>
<Text className="text-white mt-6 text-lg font-semibold">
{state.isRecording ? "Recording" : "Starting…"}
</Text>
<Text className="text-white/60 mt-1 text-sm">
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
</Text>
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
<Pressable
onPress={() => void finish("cancel")}
accessibilityLabel="Cancel recording"
className="rounded-full bg-white/15 px-6 py-3"
>
<Text className="text-white text-base font-medium">Cancel</Text>
</Pressable>
<Pressable
onPress={() => void finish("commit")}
accessibilityLabel="Stop recording"
className="rounded-full bg-white px-7 py-3"
>
<Text className="text-black text-base font-semibold">Stop</Text>
</Pressable>
</View>
</View>
);
}