import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { Captions, Check, ChevronDown, CircleDot, Copy, FileAudio, Loader2, Mic, Play, Send, Settings2, Sparkles, Square, Trash2, Upload, Volume2, } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { CapturePill } from '@/components/CapturePill/CapturePill'; import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { CaptureListResponse, CaptureResponse, CaptureSource, VoiceProfileResponse, } from '@/lib/api/types'; import type { LanguageCode } from '@/lib/constants/languages'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession'; import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness'; import { useCaptureSettings } from '@/lib/hooks/useSettings'; import { cn } from '@/lib/utils/cn'; import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes'; import { usePlayerStore } from '@/stores/playerStore'; const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm'; function formatRelative(iso: string): string { const then = new Date(iso).getTime(); const diffMs = Date.now() - then; const mins = Math.round(diffMs / 60_000); if (mins < 1) return 'Just now'; if (mins < 60) return `${mins} min ago`; const hrs = Math.round(mins / 60); if (hrs < 24) return `${hrs} hr ago`; const days = Math.round(hrs / 24); if (days === 1) return 'Yesterday'; if (days < 7) return `${days} days ago`; return new Date(iso).toLocaleDateString(); } function formatDuration(ms?: number | null): string { if (!ms || ms < 0) return '0:00'; const total = Math.round(ms / 1000); const m = Math.floor(total / 60); const s = total % 60; return `${m}:${String(s).padStart(2, '0')}`; } function formatDate(iso: string): string { return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); } function snippetOf(capture: CaptureResponse): string { const source = capture.transcript_refined || capture.transcript_raw || ''; return source.trim() || '(no transcript)'; } function ChordKeys({ keys }: { keys: string[] }) { if (keys.length === 0) return null; return (
{keys.map((k) => { const side = modifierSideHint(k); return ( {displayLabelForKey(k)} {side ? ( {side} ) : null} ); })}
); } function SourceBadge({ source }: { source: CaptureSource }) { const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio; const label = source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File'; return ( {label} ); } function FakeWaveform({ seed = 1, className }: { seed?: number; className?: string }) { const bars = useMemo(() => { return Array.from({ length: 72 }).map((_, i) => { const h = 28 + Math.sin(i * 0.35 + seed) * 22 + Math.cos(i * 0.81 + seed * 2) * 14 + Math.sin(i * 1.7 + seed * 3) * 8; return Math.max(6, Math.min(96, h)); }); }, [seed]); return (
{bars.map((h, i) => (
))}
); } type PlaybackState = 'idle' | 'generating' | 'playing'; function voiceGradient(profileId: string): string { const gradients = [ 'from-blue-400 to-indigo-500', 'from-emerald-400 to-teal-500', 'from-purple-500 to-fuchsia-500', 'from-amber-400 to-rose-500', 'from-rose-400 to-pink-500', 'from-cyan-400 to-sky-500', ]; let hash = 0; for (let i = 0; i < profileId.length; i++) hash = (hash * 31 + profileId.charCodeAt(i)) | 0; return gradients[Math.abs(hash) % gradients.length]; } export function CapturesTab() { const queryClient = useQueryClient(); const { toast } = useToast(); const fileInputRef = useRef(null); const uploadInputRef = useRef(null); const [selectedId, setSelectedId] = useState(null); const [search, setSearch] = useState(''); const [showRefined, setShowRefined] = useState(true); const [playAsVoiceId, setPlayAsVoiceId] = useState(null); const [playbackState, setPlaybackState] = useState('idle'); const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay); const audioUrl = usePlayerStore((s) => s.audioUrl); const isPlayerVisible = !!audioUrl; const { settings: captureSettings } = useCaptureSettings(); const sttModel = captureSettings?.stt_model ?? 'turbo'; const llmModel = captureSettings?.llm_model ?? '0.6B'; const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false; const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? []; const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? []; const readiness = useDictationReadiness(); const session = useCaptureRecordingSession({ onCaptureCreated: (capture) => setSelectedId(capture.id), }); const { data: capturesData, isLoading: capturesLoading } = useQuery({ queryKey: ['captures'], queryFn: () => apiClient.listCaptures(200, 0), }); const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => apiClient.listProfiles(), }); const captures = capturesData?.items ?? []; // Keep a selection. If the current selection disappears (e.g. deletion), // fall through to the first capture, then to null. useEffect(() => { if (!captures.length) { if (selectedId !== null) setSelectedId(null); return; } if (!selectedId || !captures.find((c) => c.id === selectedId)) { setSelectedId(captures[0].id); } }, [captures, selectedId]); // Default the Play-as voice to the first profile we see. useEffect(() => { if (!playAsVoiceId && profiles && profiles.length) { setPlayAsVoiceId(profiles[0].id); } }, [profiles, playAsVoiceId]); // Live sync from sibling Tauri webviews (the floating dictate window). // ``capture:created`` carries the full row so we can seed the cache before // the refetch lands and focus the new capture in one shot — without the // seed, the selection-guard effect would snap back to ``captures[0]`` in // the race window between ``setSelectedId(new)`` and the refetched list // actually containing the new row. useEffect(() => { const unlistens: Promise[] = []; unlistens.push( listen<{ capture: CaptureResponse }>('capture:created', (event) => { const capture = event.payload?.capture; if (capture) { queryClient.setQueryData(['captures'], (prev) => { if (!prev) return prev; if (prev.items.some((c) => c.id === capture.id)) return prev; return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 }; }); setSelectedId(capture.id); } queryClient.invalidateQueries({ queryKey: ['captures'] }); }), ); unlistens.push( listen('capture:updated', () => { queryClient.invalidateQueries({ queryKey: ['captures'] }); }), ); return () => { for (const p of unlistens) p.then((fn) => fn()).catch(() => {}); }; }, [queryClient]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return captures; return captures.filter((c) => { const raw = (c.transcript_raw || '').toLowerCase(); const refined = (c.transcript_refined || '').toLowerCase(); return raw.includes(q) || refined.includes(q); }); }, [search, captures]); const selected = captures.find((c) => c.id === selectedId) ?? null; const playAsVoice = profiles?.find((p) => p.id === playAsVoiceId) ?? null; const deleteMutation = useMutation({ mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['captures'] }); }, onError: (err: Error) => { toast({ title: 'Delete failed', description: err.message, variant: 'destructive' }); }, }); const playAsMutation = useMutation({ mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => { const text = capture.transcript_refined || capture.transcript_raw; if (!text.trim()) throw new Error('Capture has no transcript yet'); const language = (capture.language || voice.language) as LanguageCode; // Preset profiles (Kokoro etc.) reject the qwen default — honor the // profile's stored engine preference. Cloned profiles without an // override fall through to whatever the backend picks. const engine = voice.default_engine as | 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro' | undefined; const result = await apiClient.generateSpeech({ profile_id: voice.id, text, language, engine, }); return { capture, voice, result }; }, onSuccess: ({ capture, voice, result }) => { if (result.audio_path && result.id) { setAudioWithAutoPlay( apiClient.getAudioUrl(result.id), result.id, voice.id, `${voice.name} · ${capture.id.slice(0, 8)}`, ); setPlaybackState('playing'); } }, onError: (err: Error) => { setPlaybackState('idle'); toast({ title: 'Play-as failed', description: err.message, variant: 'destructive' }); }, }); // Pull playback state back to idle when the player closes out. useEffect(() => { if (!audioUrl) setPlaybackState('idle'); }, [audioUrl]); const handleUploadClick = () => uploadInputRef.current?.click(); const handleUploadFile = (e: React.ChangeEvent, source: CaptureSource) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; session.uploadFile(file, source); }; const handlePlayOriginal = () => { if (!selected) return; setAudioWithAutoPlay( apiClient.getCaptureAudioUrl(selected.id), `capture-${selected.id}`, null, `Capture · ${formatDate(selected.created_at)}`, ); }; const handleCopy = async () => { if (!selected) return; const text = showRefined ? selected.transcript_refined || selected.transcript_raw : selected.transcript_raw; try { await navigator.clipboard.writeText(text || ''); toast({ title: 'Transcript copied' }); } catch { toast({ title: 'Copy failed', variant: 'destructive' }); } }; const handlePlayAs = (voice?: VoiceProfileResponse) => { if (!selected) return; const target = voice ?? playAsVoice; if (!target) { toast({ title: 'No voice profile', description: 'Create a voice profile before using Play as.', variant: 'destructive', }); return; } if (voice && voice.id !== playAsVoiceId) setPlayAsVoiceId(voice.id); setPlaybackState('generating'); playAsMutation.mutate({ capture: selected, voice: target }); }; return (
handleUploadFile(e, 'file')} className="hidden" /> handleUploadFile(e, 'file')} className="hidden" /> {/* Left: capture list */}

Captures

Beta
setSearch(e.target.value)} className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" />
{capturesLoading ? (
) : filtered.length === 0 ? (
{search ? (

No captures match "{search}"

) : (

No captures yet.

)}
) : ( filtered.map((capture) => { const isActive = selectedId === capture.id; const refined = !!capture.transcript_refined; return ( ); }) )}
{/* Right: capture detail */}
{/* Top action bar */}
Whisper {sttModel.charAt(0).toUpperCase() + sttModel.slice(1)} · Qwen3 · {llmModel}
{session.pillState !== 'hidden' && ( )} {session.pillState === 'hidden' && ( <> )}
{selected ? (
{/* Meta row */}
{formatDate(selected.created_at)} {selected.language && ( <> · {selected.language.toUpperCase()} )} ·
{/* Audio player card */}
{formatDuration(selected.duration_ms)}
{/* Transcript header */}
{showRefined && selected.transcript_refined ? `Refined with Qwen3 · ${selected.llm_model ?? llmModel}` : selected.stt_model ? `Transcribed with Whisper ${selected.stt_model}` : null}
{/* Transcript body */}