Initial commit
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Captions, Folder, FolderOpen, Loader2, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { setSubtitleExtraPaths, setSubtitleCacheLimitMb } from "@/app/actions/settings";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
|
||||
/**
|
||||
* Persistent recursive-scan folders for subtitle sidecars. The player
|
||||
* always finds same-folder sidecars; configure these only if subtitles
|
||||
* live in a separate location from videos.
|
||||
*/
|
||||
export function SubtitleLibraryPaths() {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [, start] = useTransition();
|
||||
const [draftSubExtras, setDraftSubExtras] = useState<string[]>(settings.subtitleExtraPaths ?? []);
|
||||
const [newSubExtra, setNewSubExtra] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftSubExtras(settings.subtitleExtraPaths ?? []);
|
||||
}, [settings.subtitleExtraPaths]);
|
||||
|
||||
async function pickFolder(startPath: string): Promise<string | null> {
|
||||
try {
|
||||
const r = await fetch("/api/pick-folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ start: startPath }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error ?? `picker failed (${r.status})`);
|
||||
return typeof j.path === "string" && j.path ? j.path : null;
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dirty =
|
||||
draftSubExtras.length !== (settings.subtitleExtraPaths ?? []).length ||
|
||||
draftSubExtras.some((v, i) => v !== (settings.subtitleExtraPaths ?? [])[i]);
|
||||
|
||||
function addSubExtra() {
|
||||
const v = newSubExtra.trim();
|
||||
if (!v || draftSubExtras.includes(v)) return;
|
||||
setDraftSubExtras((cur) => [...cur, v]);
|
||||
setNewSubExtra("");
|
||||
}
|
||||
function removeSubExtra(idx: number) {
|
||||
setDraftSubExtras((cur) => cur.filter((_, i) => i !== idx));
|
||||
}
|
||||
function updateSubExtra(idx: number, value: string) {
|
||||
setDraftSubExtras((cur) => cur.map((v, i) => (i === idx ? value : v)));
|
||||
}
|
||||
async function browseExtra(idx: number) {
|
||||
setPicking(`extra-${idx}`);
|
||||
try {
|
||||
const p = await pickFolder(draftSubExtras[idx] ?? "");
|
||||
if (p) updateSubExtra(idx, p);
|
||||
} finally {
|
||||
setPicking(null);
|
||||
}
|
||||
}
|
||||
async function browseNewExtra() {
|
||||
setPicking("new");
|
||||
try {
|
||||
const p = await pickFolder(newSubExtra);
|
||||
if (p) {
|
||||
if (!draftSubExtras.includes(p)) {
|
||||
setDraftSubExtras((cur) => [...cur, p]);
|
||||
setNewSubExtra("");
|
||||
} else {
|
||||
setNewSubExtra(p);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setPicking(null);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setSubtitleExtraPaths(draftSubExtras);
|
||||
start(() => router.refresh());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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)]" />
|
||||
Subtitle Library Folders
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
|
||||
Recursively scanned for subtitle sidecars matching the playing video's stem or code (depth 3).
|
||||
The player always finds same-folder sidecars; configure these only if you keep subtitles in a separate location.
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{draftSubExtras.length === 0 ? (
|
||||
<div className="text-xs text-[var(--color-fg-muted)] italic px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)]">
|
||||
No subtitle folders.
|
||||
</div>
|
||||
) : (
|
||||
draftSubExtras.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={p}
|
||||
onChange={(e) => updateSubExtra(i, e.target.value)}
|
||||
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => browseExtra(i)}
|
||||
disabled={picking !== null}
|
||||
title="Browse for folder"
|
||||
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||||
>
|
||||
{picking === `extra-${i}` ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSubExtra(i)}
|
||||
title="Remove"
|
||||
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="relative flex-1">
|
||||
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={newSubExtra}
|
||||
onChange={(e) => setNewSubExtra(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSubExtra(); } }}
|
||||
placeholder="D:\\Subtitles"
|
||||
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={browseNewExtra}
|
||||
disabled={picking !== null}
|
||||
title="Browse for folder"
|
||||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||||
>
|
||||
{picking === "new" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
|
||||
Browse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSubExtra}
|
||||
disabled={!newSubExtra.trim()}
|
||||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-2 text-xs text-red-300 bg-red-500/5 border border-red-500/25 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubtitleCacheLimit />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubtitleCacheLimit() {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [, start] = useTransition();
|
||||
const [draft, setDraft] = useState<number>(settings.subtitleCacheLimitMb ?? 100);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => { setDraft(settings.subtitleCacheLimitMb ?? 100); }, [settings.subtitleCacheLimitMb]);
|
||||
const dirty = draft !== (settings.subtitleCacheLimitMb ?? 100);
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await setSubtitleCacheLimitMb(Math.max(0, Math.floor(draft)));
|
||||
start(() => router.refresh());
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="mt-section pt-section border-t border-[var(--color-glass-border)]">
|
||||
<div className="text-sm font-medium mb-1">Subtitle Cache Size Limit</div>
|
||||
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
|
||||
Soft cap on <span className="font-mono">data/subtitle-cache/</span> (converted WebVTT files).
|
||||
When exceeded, oldest entries get evicted until size drops below 80% of the cap.{" "}
|
||||
<span className="font-mono">0</span> = unlimited.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(Math.max(0, Number(e.target.value) || 0))}
|
||||
className="w-28 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)]">MB</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user