ishaq101's picture
[KM-475] [DED][FE] Align API service layer with backend contracts
5eefa6a
raw
history blame
4.22 kB
import { useCallback, useEffect, useRef, useState } from "react";
import { Mic, MicOff, AlertCircle } from "lucide-react";
export interface AudioRecorderProps {
onChunk: (chunk: ArrayBuffer) => void;
onEndUtterance: () => void;
disabled?: boolean;
}
type RecorderState = "idle" | "recording" | "unsupported";
const PREFERRED_MIME = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/mp4",
];
function getSupportedMimeType(): string | null {
if (typeof MediaRecorder === "undefined") return null;
for (const mime of PREFERRED_MIME) {
if (MediaRecorder.isTypeSupported(mime)) return mime;
}
return null;
}
export default function AudioRecorder({ onChunk, onEndUtterance, disabled }: AudioRecorderProps) {
const [state, setState] = useState<RecorderState>("idle");
const [permissionDenied, setPermissionDenied] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const mimeType = useRef<string | null>(null);
// Check support on mount
useEffect(() => {
if (
typeof navigator.mediaDevices?.getUserMedia === "undefined" ||
getSupportedMimeType() === null
) {
setState("unsupported");
}
mimeType.current = getSupportedMimeType();
}, []);
const startRecording = useCallback(async () => {
if (state !== "idle" || disabled) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const recorder = new MediaRecorder(stream, {
mimeType: mimeType.current ?? undefined,
audioBitsPerSecond: 16000,
});
recorder.ondataavailable = async (e) => {
if (e.data.size > 0) {
const buf = await e.data.arrayBuffer();
onChunk(buf);
}
};
recorder.start(100); // 100ms chunks
mediaRecorderRef.current = recorder;
setState("recording");
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "NotAllowedError") {
setPermissionDenied(true);
}
}
}, [state, disabled, onChunk]);
const stopRecording = useCallback(() => {
if (state !== "recording") return;
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== "inactive") {
recorder.onstop = () => {
onEndUtterance();
};
recorder.stop();
}
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
mediaRecorderRef.current = null;
setState("idle");
}, [state, onEndUtterance]);
// Cleanup on unmount
useEffect(() => {
return () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, []);
if (state === "unsupported") {
return (
<div className="flex items-center gap-1.5 text-slate-400 text-xs px-2">
<AlertCircle className="w-3.5 h-3.5" />
<span>Audio tidak didukung browser ini</span>
</div>
);
}
if (permissionDenied) {
return (
<div className="flex items-center gap-1.5 text-amber-500 text-xs px-2">
<MicOff className="w-3.5 h-3.5" />
<span>Izin mikrofon ditolak</span>
</div>
);
}
const isRecording = state === "recording";
return (
<button
onPointerDown={startRecording}
onPointerUp={stopRecording}
onPointerLeave={stopRecording}
disabled={disabled}
title={isRecording ? "Lepas untuk kirim" : "Tahan untuk merekam"}
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200 flex-shrink-0 ${
isRecording
? "bg-red-500 text-white scale-110 shadow-lg shadow-red-200"
: disabled
? "bg-slate-100 text-slate-300 cursor-not-allowed"
: "bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:scale-105"
}`}
>
{isRecording ? (
<>
{/* Pulse ring */}
<span className="absolute inset-0 rounded-full bg-red-400 animate-ping opacity-50" />
<Mic className="w-4 h-4 relative z-10" />
</>
) : (
<Mic className="w-4 h-4" />
)}
</button>
);
}