nexus/ui/src/components/ChatVoicePlayer.tsx
Nexus Dev 45339bdac1 feat(37-03): ChatVoicePlayer + ChatVoiceBadge components
- ChatVoicePlayer: POST /api/synthesize, play/pause controls, autoPlay support, blob URL cleanup
- ChatVoiceBadge: Voice badge, SPOKEN/DETAILED parsing, collapsible full markdown for voice_full
2026-04-04 02:39:19 +00:00

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>
);
}