blob: c4692ff12a20d43bd505299fc9a59b31c452f8de (
plain) (
tree)
|
|
import { useState, useCallback } from "react";
import { Masthead } from "../components/Masthead";
import { useSpeakWebSocket } from "../hooks/useSpeakWebSocket";
export default function SpeakPage() {
const [text, setText] = useState("");
const tts = useSpeakWebSocket();
const handleSpeak = useCallback(() => {
if (!text.trim()) return;
tts.speak(text);
}, [text, tts]);
const handleCancel = useCallback(() => {
tts.cancel();
}, [tts]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl/Cmd + Enter to speak
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
handleSpeak();
}
},
[handleSpeak]
);
const statusLabel = (() => {
switch (tts.status) {
case "disconnected":
return "DISCONNECTED";
case "connecting":
return "CONNECTING...";
case "connected":
return "CONNECTED";
case "loading_model":
return "LOADING TTS MODEL...";
case "speaking":
return "SPEAKING";
case "error":
return "ERROR";
default:
return "IDLE";
}
})();
const statusColor = (() => {
switch (tts.status) {
case "connected":
case "speaking":
return "border-[#3f6fb3] text-[#75aafc]";
case "error":
return "border-red-400/50 text-red-400";
default:
return "border-[rgba(117,170,252,0.25)] text-[#9bc3ff]";
}
})();
const dotColor = (() => {
switch (tts.status) {
case "connected":
case "speaking":
return "bg-[#75aafc]";
case "error":
return "bg-red-400";
default:
return "bg-[#3f6fb3]";
}
})();
return (
<div className="relative z-10 h-screen flex flex-col overflow-hidden">
<Masthead showTicker={false} showNav />
<main className="flex-1 flex flex-col items-center justify-center p-4 md:p-8 gap-6 min-h-0 overflow-auto">
{/* Text input area */}
<div className="w-full max-w-2xl">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter text to speak..."
disabled={tts.isSpeaking || tts.isModelLoading}
className="w-full h-48 p-4 font-mono text-sm text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] focus:border-[#3f6fb3] focus:outline-none placeholder-[#3f6fb3] resize-none transition-colors disabled:opacity-50"
/>
<div className="mt-1 text-right font-mono text-xs text-[#3f6fb3]">
Ctrl+Enter to speak
</div>
</div>
{/* Controls row */}
<div className="w-full max-w-2xl flex items-center gap-4">
{/* Speak / Cancel button */}
{tts.isSpeaking || tts.isModelLoading ? (
<button
onClick={handleCancel}
className="px-6 py-2 font-mono text-sm text-red-400 bg-[#0d1b2d] border border-red-400/50 hover:border-red-400 transition-colors uppercase tracking-wide"
>
Cancel
</button>
) : (
<button
onClick={handleSpeak}
disabled={!text.trim()}
className="px-6 py-2 font-mono text-sm text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide disabled:opacity-50 disabled:cursor-not-allowed"
>
Speak
</button>
)}
{/* Status indicator */}
<div
className={`inline-flex items-center gap-1.5 px-2 py-1 border font-mono text-xs tracking-wide uppercase ${statusColor}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
{statusLabel}
</div>
</div>
{/* Loading bar (indeterminate) */}
{tts.isModelLoading && (
<div className="w-full max-w-2xl">
<div className="w-full h-1.5 bg-[#0f1c2f] overflow-hidden">
<div
className="h-full w-1/3 bg-[#75aafc]"
style={{
animation: "loading-slide 1.5s ease-in-out infinite",
}}
/>
</div>
<div className="mt-2 font-mono text-xs text-[#9bc3ff] text-center tracking-wide uppercase">
Loading TTS model... This may take a moment on first use.
</div>
</div>
)}
{/* Speaking animation bar */}
{tts.isSpeaking && (
<div className="w-full max-w-2xl">
<div className="w-full h-1.5 bg-[#0f1c2f] overflow-hidden">
<div
className="h-full w-full bg-[#75aafc] animate-pulse"
/>
</div>
</div>
)}
{/* Error display */}
{tts.error && (
<div className="w-full max-w-2xl font-mono text-xs text-red-400 text-center px-4 py-2 border border-red-400/50 bg-red-400/10">
{tts.error}
</div>
)}
</main>
</div>
);
}
|