126 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|