347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
"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<WhisperJavSettingsT>(w);
|
||
const [saving, setSaving] = useState(false);
|
||
const [verifying, setVerifying] = useState(false);
|
||
const [verify, setVerify] = useState<VerifyResult | null>(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 (
|
||
<div className="py-2 space-y-5">
|
||
<div>
|
||
<div className="text-sm font-medium mb-1 flex items-center gap-1.5">
|
||
<Captions className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
|
||
WhisperJAV Subtitle Generator
|
||
</div>
|
||
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
|
||
Local AI subtitle generation. Configure the CLI path, then use{" "}
|
||
<span className="font-mono">Generate Subtitles</span> from the player's subtitle dropdown.
|
||
First run may download models (~1–2 GB).
|
||
</div>
|
||
</div>
|
||
|
||
{/* CLI path */}
|
||
<div>
|
||
<div className="text-sm font-medium mb-1">CLI Path</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={draft.cliPath}
|
||
onChange={(e) => 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)]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={browse}
|
||
title="Browse for whisperjav.exe"
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
|
||
>
|
||
<FolderOpen className="w-4 h-4" /> Browse
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={runVerify}
|
||
disabled={verifying || !draft.cliPath.trim()}
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||
>
|
||
{verifying ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||
Verify
|
||
</button>
|
||
</div>
|
||
{verify && (
|
||
<div className={cn(
|
||
"mt-2 text-xs rounded-lg px-3 py-2 border",
|
||
verify.ok
|
||
? "text-[var(--color-mint)] bg-[var(--color-mint)]/5 border-[var(--color-mint)]/25"
|
||
: "text-red-300 bg-red-500/5 border-red-500/25",
|
||
)}>
|
||
<div className="flex items-start gap-2">
|
||
{verify.ok
|
||
? <CheckCircle2 className="w-4 h-4 mt-0.5 shrink-0" />
|
||
: <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />}
|
||
<div className="min-w-0">
|
||
{verify.ok
|
||
? <>Detected: <strong>WhisperJAV {verify.version}</strong> at <span className="font-mono break-all">{verify.resolvedPath}</span></>
|
||
: <>Verify failed: {verify.error}</>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quality */}
|
||
<RadioGroup<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 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
<RadioGroup<SourceLang>
|
||
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: "" },
|
||
]}
|
||
/>
|
||
<RadioGroup<OutputMode>
|
||
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." },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
<RadioGroup<Sensitivity>
|
||
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: "" },
|
||
]}
|
||
/>
|
||
<RadioGroup<Location>
|
||
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/<code>/" },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={draft.noSignature}
|
||
onChange={(e) => setDraft({ ...draft, noSignature: e.target.checked })}
|
||
/>
|
||
Disable WhisperJAV signature cue at end of subtitles
|
||
</label>
|
||
|
||
<div>
|
||
<div className="text-sm font-medium mb-1">Job History Retention</div>
|
||
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
|
||
Days to keep failed and cancelled job temp folders for debugging.
|
||
Successful job folders are deleted immediately.{" "}
|
||
<span className="font-mono">0</span> = keep forever.
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={draft.retentionDays}
|
||
onChange={(e) => 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)]"
|
||
/>
|
||
<span className="text-xs text-[var(--color-fg-dim)]">days</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)] flex-wrap">
|
||
<button
|
||
type="button"
|
||
onClick={save}
|
||
disabled={!dirty || saving}
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||
Save
|
||
</button>
|
||
<Link
|
||
href="/subtitles/batch"
|
||
onClick={closeSettings}
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
|
||
>
|
||
<Sparkles className="w-4 h-4 text-[var(--color-cyan)]" />
|
||
Open Batch Generator
|
||
</Link>
|
||
<ClearHistoryButton />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ClearHistoryButton() {
|
||
const [busy, setBusy] = useState(false);
|
||
const [result, setResult] = useState<string | null>(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 (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={clearAll}
|
||
disabled={busy}
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
|
||
>
|
||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||
Clear Job History
|
||
</button>
|
||
{result && <span className="text-xs text-[var(--color-fg-dim)]">{result}</span>}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function RadioGroup<T extends string>({ label, value, onChange, options }: {
|
||
label: string;
|
||
value: T;
|
||
onChange: (v: T) => void;
|
||
options: Array<{ value: T; title: string; desc: string }>;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<div className="text-sm font-medium mb-1">{label}</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
{options.map((opt) => {
|
||
const active = value === opt.value;
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => onChange(opt.value)}
|
||
className={cn(
|
||
"text-left rounded-lg border px-3 py-2 transition-colors cursor-pointer",
|
||
active
|
||
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/10 shadow-[var(--shadow-glow-cyan)]"
|
||
: "border-[var(--color-glass-border)] bg-[var(--color-glass)]/30 hover:border-[var(--color-glass-border-strong)]",
|
||
)}
|
||
>
|
||
<div className={cn(
|
||
"text-sm font-medium",
|
||
active ? "text-[var(--color-cyan)]" : "text-[var(--color-fg)]",
|
||
)}>{opt.title}</div>
|
||
{opt.desc && (
|
||
<div className="text-[11px] text-[var(--color-fg-muted)] mt-0.5 leading-snug">{opt.desc}</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|