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 */}
{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 */}
{/* Bottom actions */}
Play transcript as
{profiles?.map((v) => (
handlePlayAs(v)}
className="gap-2.5 py-2"
>
{v.name}
{v.description || v.language.toUpperCase()}
{v.id === playAsVoiceId && (
)}
))}
) : (
{capturesLoading ? (
) : captures.length ? (
Pick a capture to see the transcript.
) : hotkeyEnabled && !readiness.allReady ? (
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
{pushToTalkKeys.length ? (
Hold to record
) : null}
{toggleToTalkKeys.length ? (
Toggle hands-free
) : null}
Press the shortcut anywhere on your machine to start your first capture.
) : (
No captures yet.
Turn on the global shortcut to dictate from anywhere — or click
Dictate above for an in-app capture.
)}
)}
);
}