Initial commit
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user