- ChatVoicePlayer: POST /api/synthesize, play/pause controls, autoPlay support, blob URL cleanup - ChatVoiceBadge: Voice badge, SPOKEN/DETAILED parsing, collapsible full markdown for voice_full
122 lines
3 KiB
TypeScript
122 lines
3 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Loader2, Pause, Play } from "lucide-react";
|
|
|
|
interface ChatVoicePlayerProps {
|
|
text: string;
|
|
autoPlay?: boolean;
|
|
}
|
|
|
|
type PlayerStatus = "idle" | "loading" | "playing" | "paused";
|
|
|
|
export function ChatVoicePlayer({ text, autoPlay = false }: ChatVoicePlayerProps) {
|
|
const [status, setStatus] = useState<PlayerStatus>("loading");
|
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
let objectUrl: string | null = null;
|
|
let cancelled = false;
|
|
|
|
async function fetchAudio() {
|
|
setStatus("loading");
|
|
try {
|
|
const res = await fetch("/api/synthesize", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ text }),
|
|
});
|
|
if (cancelled) return;
|
|
if (!res.ok) {
|
|
setStatus("idle");
|
|
return;
|
|
}
|
|
const blob = await res.blob();
|
|
if (cancelled) return;
|
|
objectUrl = URL.createObjectURL(blob);
|
|
setAudioUrl(objectUrl);
|
|
setStatus("idle");
|
|
} catch {
|
|
if (!cancelled) {
|
|
setStatus("idle");
|
|
}
|
|
}
|
|
}
|
|
|
|
fetchAudio();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (objectUrl) {
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
};
|
|
}, [text]);
|
|
|
|
useEffect(() => {
|
|
if (autoPlay && audioUrl && audioRef.current) {
|
|
audioRef.current.play().catch(() => {
|
|
// Browser may block autoplay; fall back to idle state
|
|
setStatus("idle");
|
|
});
|
|
}
|
|
}, [autoPlay, audioUrl]);
|
|
|
|
function handlePlay() {
|
|
if (audioRef.current) {
|
|
audioRef.current.play();
|
|
}
|
|
}
|
|
|
|
function handlePause() {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
}
|
|
}
|
|
|
|
function handleAudioEnded() {
|
|
setStatus("idle");
|
|
if (audioUrl) {
|
|
URL.revokeObjectURL(audioUrl);
|
|
setAudioUrl(null);
|
|
}
|
|
}
|
|
|
|
if (status === "loading") {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Loading audio...
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span className="inline-flex items-center">
|
|
{status === "playing" ? (
|
|
<Button variant="ghost" size="sm" onClick={handlePause} aria-label="Pause voice response">
|
|
<Pause className="h-3 w-3" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handlePlay}
|
|
disabled={!audioUrl}
|
|
aria-label="Play voice response"
|
|
>
|
|
<Play className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
<audio
|
|
ref={audioRef}
|
|
src={audioUrl ?? undefined}
|
|
aria-label="Voice response"
|
|
onPlay={() => setStatus("playing")}
|
|
onPause={() => setStatus("paused")}
|
|
onEnded={handleAudioEnded}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|