Files
pinkudex/components/settings/WhisperJavSettings.tsx
2026-05-26 22:46:00 +02:00

347 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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&apos;s subtitle dropdown.
First run may download models (~12 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>
);
}