"use client"; import { useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Captions, Loader2, Save, FolderOpen, CheckCircle2, AlertCircle, RefreshCw, Trash2, Sparkles } from "lucide-react"; import type { WhisperJavSettings as WhisperJavSettingsT } from "@/lib/db/appSettings"; import { setWhisperJavSettings } from "@/app/actions/settings"; import { useSettings } from "./SettingsProvider"; import { useSettingsPanel } from "./SettingsPanelProvider"; import { cn } from "@/lib/utils"; type Quality = WhisperJavSettingsT["quality"]; type SourceLang = WhisperJavSettingsT["sourceLanguage"]; type OutputMode = WhisperJavSettingsT["outputMode"]; type Sensitivity = WhisperJavSettingsT["sensitivity"]; type Location = WhisperJavSettingsT["outputLocation"]; interface VerifyResult { ok: boolean; version?: string; resolvedPath?: string; error?: string; } export function WhisperJavSettings() { const { settings } = useSettings(); const w = settings.whisperjav; const router = useRouter(); const { close: closeSettings } = useSettingsPanel(); const [, start] = useTransition(); const [draft, setDraft] = useState(w); const [saving, setSaving] = useState(false); const [verifying, setVerifying] = useState(false); const [verify, setVerify] = useState(null); const [autodetected, setAutodetected] = useState(false); useEffect(() => { setDraft(w); }, [w]); // First-open autodetect — only when cliPath is empty AND no draft change. useEffect(() => { if (autodetected) return; if (draft.cliPath) return; setAutodetected(true); void (async () => { try { const r = await fetch("/api/whisperjav-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autodetect: true }), }); const data = (await r.json()) as VerifyResult; if (data.ok && data.resolvedPath) { setDraft((cur) => (cur.cliPath ? cur : { ...cur, cliPath: data.resolvedPath! })); setVerify(data); } } catch { /* no autodetect available */ } })(); }, [autodetected, draft.cliPath]); const dirty = JSON.stringify(draft) !== JSON.stringify(w); async function browse() { try { const r = await fetch("/api/pick-file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ purpose: "whisperjav" }), }); const data = await r.json(); if (data.path) setDraft((cur) => ({ ...cur, cliPath: data.path })); } catch { /* ignore */ } } async function runVerify() { setVerifying(true); setVerify(null); try { const r = await fetch("/api/whisperjav-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: draft.cliPath }), }); const data = (await r.json()) as VerifyResult; setVerify(data); } catch (e) { setVerify({ ok: false, error: (e as Error).message }); } finally { setVerifying(false); } } async function save() { setSaving(true); try { await setWhisperJavSettings(draft); start(() => router.refresh()); } finally { setSaving(false); } } return (
WhisperJAV Subtitle Generator
Local AI subtitle generation. Configure the CLI path, then use{" "} Generate Subtitles from the player's subtitle dropdown. First run may download models (~1–2 GB).
{/* CLI path */}
CLI Path
setDraft({ ...draft, cliPath: e.target.value })} placeholder="whisperjav (or absolute path)" className="flex-1 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]" />
{verify && (
{verify.ok ? : }
{verify.ok ? <>Detected: WhisperJAV {verify.version} at {verify.resolvedPath} : <>Verify failed: {verify.error}}
)}
{/* Quality */} label="Quality" value={draft.quality} onChange={(v) => setDraft({ ...draft, quality: v })} options={[ { value: "fast", title: "Fast", desc: "Quickest pass; lower fidelity." }, { value: "balanced", title: "Balanced", desc: "Default. Good speed/quality tradeoff." }, { value: "qwen", title: "Best (Qwen)", desc: "Qwen3-ASR with JAV-tuned post-processing. Slow + heavy on VRAM." }, ]} /> {/* Source language + Output mode */}
label="Source Language" value={draft.sourceLanguage} onChange={(v) => setDraft({ ...draft, sourceLanguage: v })} options={[ { value: "japanese", title: "Japanese", desc: "" }, { value: "korean", title: "Korean", desc: "" }, { value: "chinese", title: "Chinese", desc: "" }, { value: "english", title: "English", desc: "" }, ]} /> label="Output Mode" value={draft.outputMode} onChange={(v) => setDraft({ ...draft, outputMode: v })} options={[ { value: "native", title: "Native", desc: "Transcribe in source language." }, { value: "direct-to-english", title: "Direct To English", desc: "Whisper-translate to English." }, ]} />
label="Sensitivity" value={draft.sensitivity} onChange={(v) => setDraft({ ...draft, sensitivity: v })} options={[ { value: "conservative", title: "Conservative", desc: "" }, { value: "balanced", title: "Balanced", desc: "" }, { value: "aggressive", title: "Aggressive", desc: "" }, ]} /> label="Output Location" value={draft.outputLocation} onChange={(v) => setDraft({ ...draft, outputLocation: v })} options={[ { value: "beside-video", title: "Beside Video", desc: "Falls back to data folder if read-only." }, { value: "data-folder", title: "Data Folder", desc: "data/generated-subtitles//" }, ]} />
Job History Retention
Days to keep failed and cancelled job temp folders for debugging. Successful job folders are deleted immediately.{" "} 0 = keep forever.
setDraft({ ...draft, retentionDays: Math.max(0, Number(e.target.value) || 0) })} className="w-24 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]" /> days
Open Batch Generator
); } function ClearHistoryButton() { const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); async function clearAll() { if (!window.confirm("Clear all WhisperJAV job history? Temp folders for past jobs will be deleted.")) return; setBusy(true); setResult(null); try { const r = await fetch("/api/whisperjav-jobs", { method: "DELETE" }); const data = (await r.json()) as { rows?: number; dirs?: number }; setResult(`Removed ${data.rows ?? 0} row(s), ${data.dirs ?? 0} folder(s).`); } catch (e) { setResult(`Failed: ${(e as Error).message}`); } finally { setBusy(false); } } return ( <> {result && {result}} ); } function RadioGroup({ label, value, onChange, options }: { label: string; value: T; onChange: (v: T) => void; options: Array<{ value: T; title: string; desc: string }>; }) { return (
{label}
{options.map((opt) => { const active = value === opt.value; return ( ); })}
); }