import { IconX } from "@tabler/icons-react"; import { emit, listen } from "@tauri-apps/api/event"; import { useEffect, useRef, useState } from "react"; import { onAudioLevel } from "../lib/transcription-engine"; type FlowState = "idle" | "recording" | "processing" | "complete" | "error"; /** * Dictation overlay — a slim dark floating panel, * horizontally centered. The bar only ever appears once the user has * triggered a voice shortcut, so it mounts in "recording" state and * shows the waveform immediately. State transitions arrive via Tauri * events as the recorder progresses through processing → complete/error. * * Events: * - `voice:state-change` { state: "idle"|"recording"|"processing"|"complete"|"error" } * - `voice:audio-level` { level: number } (0-1) for waveform visualization * - `voice:dictation-preview` { text: string } */ export function FlowBar() { // Default to "recording" not "idle" — there's a race between the Rust // window opening and the React listener registering, so a default of // "idle" caused the bar to flash an "EN" language pill that never went // away if the start event was missed. const [state, setState] = useState("recording"); const [transcript, setTranscript] = useState(""); const transcriptRef = useRef(null); const canvasRef = useRef(null); const levelRef = useRef(0); const rafRef = useRef(null); useEffect(() => { const unlistens: Array<() => void> = []; let stopped = false; const trackListen = (p: Promise<() => void>) => { p.then((u) => { if (stopped) { try { u(); } catch { // ignore } return; } unlistens.push(u); }).catch(() => {}); }; trackListen( listen<{ state: FlowState }>("voice:state-change", (ev) => { setState(ev.payload.state); if (ev.payload.state === "recording") setTranscript(""); }), ); trackListen( onAudioLevel(({ level }) => { levelRef.current = Math.max(0, Math.min(1, level)); }), ); trackListen( listen<{ text: string }>("voice:dictation-preview", (ev) => { setTranscript(ev.payload.text.trim()); }), ); return () => { stopped = true; unlistens.forEach((u) => { try { u(); } catch { // ignore } }); unlistens.length = 0; }; }, []); useEffect(() => { const preview = transcriptRef.current; if (preview) preview.scrollTop = preview.scrollHeight; }, [transcript]); // Waveform canvas rendering loop — only runs during the "recording" state. useEffect(() => { if (state !== "recording") { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } return; } const BAR_COUNT = 14; const BAR_WIDTH = 2; const BAR_GAP = 3; // A bar waveform reads the same well below display refresh rate // (60-120Hz); cap the actual draw work to ~20fps while still scheduling // via rAF every frame so the loop still pauses when the bar is hidden. const FRAME_INTERVAL_MS = 1000 / 20; let lastDrawMs = 0; function draw(timestamp: number) { rafRef.current = requestAnimationFrame(draw); if (timestamp - lastDrawMs < FRAME_INTERVAL_MS) return; lastDrawMs = timestamp; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const logicalW = BAR_COUNT * (BAR_WIDTH + BAR_GAP) - BAR_GAP; const logicalH = 18; if (canvas.width !== logicalW * dpr || canvas.height !== logicalH * dpr) { canvas.width = logicalW * dpr; canvas.height = logicalH * dpr; canvas.style.width = `${logicalW}px`; canvas.style.height = `${logicalH}px`; ctx.scale(dpr, dpr); } ctx.clearRect(0, 0, logicalW, logicalH); ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; const level = levelRef.current; const now = Date.now(); for (let i = 0; i < BAR_COUNT; i++) { // Each bar gets a slightly different phase so the waveform looks // organic rather than uniform. The level controls overall amplitude. const phase = Math.sin(now / 200 + i * 0.6) * 0.5 + 0.5; const barLevel = Math.max(0.08, level * phase); const h = barLevel * logicalH; const x = i * (BAR_WIDTH + BAR_GAP); const y = (logicalH - h) / 2; ctx.beginPath(); ctx.roundRect(x, y, BAR_WIDTH, h, 1); ctx.fill(); } } rafRef.current = requestAnimationFrame(draw); return () => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; }, [state]); const handleCancel = () => { // Broadcast to the popover webview where voice-dictation.ts lives — // it will abort any in-flight transcribe, stop recording, hide the // bar without pasting text, and own the delayed defensive re-hide // (gated on no new session having started since) so a fast re-press // right after cancel doesn't hide a brand-new session's bar (R21). emit("voice:cancel").catch(() => {}); }; return (
{transcript ? (
{transcript}
) : null} {/* Pill is ALWAYS mounted — when state goes idle we fade the opacity to 0 (see CSS) instead of removing it from the DOM. Inner content keeps its last frame rendered during the fade so the canvas doesn't pop. */}
{(state === "recording" || state === "idle") && (
)} {state === "processing" ? (
Cleaning up...
) : null} {state === "error" ? (
Could not transcribe
) : null} {(state === "recording" || state === "processing") && ( )}
); }