264 lines
7.6 KiB
TypeScript
264 lines
7.6 KiB
TypeScript
import { useEffect, useRef, useState } from "preact/hooks";
|
||
|
||
interface VoiceRecorderProps {
|
||
text: string;
|
||
audio_file: string;
|
||
role: string;
|
||
recordingCallback: (data: Blob | null) => void;
|
||
}
|
||
|
||
export default function VoiceRecorder(
|
||
{ text, audio_file, role, recordingCallback }: VoiceRecorderProps,
|
||
) {
|
||
const [recording, setRecording] = useState(false);
|
||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||
const [initialAudioUrl, setInitialAudioUrl] = useState<string | null>(null);
|
||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||
const chunksRef = useRef<Blob[]>([]);
|
||
const selectedMimeTypeRef = useRef<string | undefined>(undefined);
|
||
const initialAudioRef = useRef<HTMLAudioElement>(null);
|
||
|
||
const pickBestAudioMimeType = (): string | undefined => {
|
||
if (typeof MediaRecorder === "undefined") return undefined;
|
||
if (typeof MediaRecorder.isTypeSupported !== "function") return undefined;
|
||
|
||
const candidates = [
|
||
"audio/mp4",
|
||
"audio/mp4;codecs=mp4a.40.2",
|
||
"audio/webm;codecs=opus",
|
||
"audio/webm",
|
||
"audio/ogg;codecs=opus",
|
||
"audio/ogg",
|
||
];
|
||
|
||
return candidates.find((t) => MediaRecorder.isTypeSupported(t));
|
||
};
|
||
|
||
// Fetch and play the initial audio_file when the component mounts
|
||
useEffect(() => {
|
||
if (!audio_file) return;
|
||
|
||
const fetchAudio = async () => {
|
||
try {
|
||
const isDirectUrl = audio_file.startsWith("blob:") ||
|
||
audio_file.startsWith("http") ||
|
||
audio_file.startsWith("/");
|
||
if (isDirectUrl) {
|
||
setInitialAudioUrl(audio_file);
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(
|
||
`/api/audio-blob/${encodeURIComponent(audio_file)}`,
|
||
{ credentials: "include" },
|
||
);
|
||
if (!response.ok) {
|
||
console.error("Failed to fetch audio file:", response.statusText);
|
||
return;
|
||
}
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
setInitialAudioUrl(url);
|
||
} catch (err) {
|
||
console.error("Error fetching audio file:", err);
|
||
}
|
||
};
|
||
|
||
fetchAudio();
|
||
|
||
// Cleanup blob URL on unmount
|
||
return () => {
|
||
if (initialAudioUrl) {
|
||
URL.revokeObjectURL(initialAudioUrl);
|
||
}
|
||
};
|
||
}, [audio_file]);
|
||
|
||
// Auto-play when initial audio is loaded
|
||
useEffect(() => {
|
||
if (initialAudioUrl && initialAudioRef.current) {
|
||
initialAudioRef.current.play().catch(() => {
|
||
// Autoplay might be blocked, user must interact
|
||
});
|
||
}
|
||
}, [initialAudioUrl]);
|
||
|
||
const startRecording = async () => {
|
||
if (audioUrl) {
|
||
URL.revokeObjectURL(audioUrl);
|
||
}
|
||
setAudioUrl(null);
|
||
setAudioBlob(null);
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
|
||
if (typeof MediaRecorder === "undefined") {
|
||
alert("Audio recording is not supported in this browser.");
|
||
stream.getTracks().forEach((t) => t.stop());
|
||
return;
|
||
}
|
||
|
||
const bestMimeType = pickBestAudioMimeType();
|
||
const mimeTypeCandidates = [
|
||
bestMimeType,
|
||
"audio/mp4",
|
||
"audio/mp4;codecs=mp4a.40.2",
|
||
"audio/webm;codecs=opus",
|
||
"audio/webm",
|
||
"audio/ogg;codecs=opus",
|
||
"audio/ogg",
|
||
undefined,
|
||
].filter((t, i, arr) => arr.indexOf(t) === i);
|
||
|
||
let mediaRecorder: MediaRecorder | null = null;
|
||
let lastError: unknown = undefined;
|
||
|
||
for (const candidate of mimeTypeCandidates) {
|
||
try {
|
||
mediaRecorder = candidate
|
||
? new MediaRecorder(stream, { mimeType: candidate })
|
||
: new MediaRecorder(stream);
|
||
selectedMimeTypeRef.current = candidate;
|
||
break;
|
||
} catch (err) {
|
||
lastError = err;
|
||
mediaRecorder = null;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (!mediaRecorder) {
|
||
console.error("Failed to create MediaRecorder", lastError);
|
||
alert("Audio recording is not supported in this browser.");
|
||
stream.getTracks().forEach((t) => t.stop());
|
||
return;
|
||
}
|
||
|
||
mediaRecorderRef.current = mediaRecorder;
|
||
|
||
mediaRecorder.onerror = (e) => {
|
||
const recErr = (e as ErrorEvent | Event) as unknown as {
|
||
error?: DOMException;
|
||
};
|
||
const err = recErr?.error;
|
||
console.error("MediaRecorder error", err || e);
|
||
stream.getTracks().forEach((t) => t.stop());
|
||
setRecording(false);
|
||
alert(
|
||
"Recording failed. This Safari version may not support MediaRecorder with audio-only streams.",
|
||
);
|
||
};
|
||
|
||
mediaRecorder.ondataavailable = (e) => {
|
||
if (e.data.size > 0) {
|
||
chunksRef.current.push(e.data);
|
||
}
|
||
};
|
||
|
||
mediaRecorder.onstop = () => {
|
||
const fallbackType = chunksRef.current[0]?.type ||
|
||
mediaRecorder.mimeType ||
|
||
selectedMimeTypeRef.current ||
|
||
"";
|
||
|
||
const blob = new Blob(
|
||
chunksRef.current,
|
||
fallbackType ? { type: fallbackType } : undefined,
|
||
);
|
||
chunksRef.current = [];
|
||
setAudioBlob(blob);
|
||
setAudioUrl(URL.createObjectURL(blob));
|
||
|
||
stream.getTracks().forEach((t) => t.stop());
|
||
};
|
||
|
||
mediaRecorder.start();
|
||
setRecording(true);
|
||
} catch (err) {
|
||
alert("Microphone access denied or not supported.");
|
||
}
|
||
};
|
||
|
||
const stopRecording = () => {
|
||
mediaRecorderRef.current?.stop();
|
||
setRecording(false);
|
||
};
|
||
|
||
const handleSave = () => {
|
||
if (audioBlob) {
|
||
recordingCallback(audioBlob);
|
||
}
|
||
};
|
||
|
||
const handleClose = () => {
|
||
recordingCallback(null);
|
||
};
|
||
|
||
return (
|
||
<div className="theme-popup-overlay">
|
||
<div
|
||
className="theme-recorder-container"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
onClick={handleClose}
|
||
aria-label="Close"
|
||
className="theme-recorder-close"
|
||
>
|
||
×
|
||
</button>
|
||
<div className="flex-1 flex items-center justify-center w-full mb-6">
|
||
<div className="text-center w-full">{text}</div>
|
||
</div>
|
||
{/* Initial audio playback */}
|
||
<div className="mb-4 w-full flex justify-center">
|
||
{initialAudioUrl && (
|
||
<audio
|
||
ref={initialAudioRef}
|
||
src={initialAudioUrl}
|
||
autoPlay
|
||
controls
|
||
className="w-full"
|
||
/>
|
||
)}
|
||
</div>
|
||
{role === "admin" && (
|
||
<div className="flex gap-4 justify-center w-full mb-4">
|
||
<button
|
||
className={`theme-recorder-button theme-recorder-start ${
|
||
recording ? "opacity-60 pointer-events-none" : ""
|
||
}`}
|
||
onClick={startRecording}
|
||
disabled={recording}
|
||
>
|
||
🎤 Start
|
||
</button>
|
||
<button
|
||
className={`theme-recorder-button theme-recorder-stop ${
|
||
!recording ? "opacity-50 cursor-not-allowed" : ""
|
||
}`}
|
||
onClick={stopRecording}
|
||
disabled={!recording}
|
||
>
|
||
⏹ Stop
|
||
</button>
|
||
<button
|
||
className={`theme-recorder-button theme-recorder-save ${
|
||
!audioUrl ? "opacity-50 cursor-not-allowed" : ""
|
||
}`}
|
||
onClick={handleSave}
|
||
disabled={!audioUrl}
|
||
>
|
||
💾 Save
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="mt-4 h-[30px] w-full flex justify-center items-center">
|
||
{audioUrl && <audio controls src={audioUrl}></audio>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|