Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, Captions, Sparkles, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type LangIso = "eng" | "zho" | "jpn";
export interface EmbeddedEntry {
id: string;
streamIndex: number;
codec: string;
language: LangIso | null;
label: string;
renderable: boolean;
}
export interface SidecarEntry {
id: string;
abs: string;
filename: string;
ext: string;
language: LangIso | null;
label: string;
origin: "same-folder" | "library" | "browsed" | "manual";
}
export interface SubtitleSources {
embedded: EmbeddedEntry[];
sidecar: SidecarEntry[];
}
export interface RunningJobSummary {
jobId: string;
/** Wall-clock seconds since startedAt, computed by parent. */
elapsedSec: number;
/** "Step 4/6: Transcribing Scenes" or null when not yet known. */
status: string | null;
/** Estimated remaining seconds. Null when ETA can't be computed. */
etaSec: number | null;
}
interface Props {
sources: SubtitleSources | null;
selectedId: string | "off";
loading: boolean;
onChange: (id: string | "off") => void;
onBrowse: () => void;
/** When set, picker hides the Generate row and shows "Generating ..." */
runningJob?: RunningJobSummary | null;
/** Called when user clicks Generate Subtitles / Regenerate. */
onGenerate?: (overwrite: boolean) => void;
/** Called when user clicks Cancel during a running job. */
onCancel?: () => void;
/** True iff WhisperJAV CLI path is configured. Hides Generate row otherwise. */
generateEnabled?: boolean;
/** True iff a generated sidecar already exists for the current video.
* Switches "Generate Subtitles" → "Regenerate Subtitles" (overwrite=true). */
hasGenerated?: boolean;
/** Current timing offset in seconds. Positive = subs appear later. */
offsetSec?: number;
/** Called when user changes the offset (delta in seconds, signed). */
onOffsetChange?: (newOffsetSec: number) => void;
}
function shortLabel(
sources: SubtitleSources | null,
selectedId: string | "off",
loading: boolean,
): string {
// While the initial source list is in flight the sticky-pref auto-
// select hasn't fired yet — masking with "..." avoids a misleading
// "Off" flash that would flip to "EN"/"JP"/etc once the fetch lands.
if (loading && selectedId === "off") return "...";
if (selectedId === "off") return "Off";
if (!sources) return "Subtitles";
const emb = sources.embedded.find((e) => e.id === selectedId);
if (emb) return emb.language ? langShort(emb.language) : labelShort(emb.label, "Embedded");
const side = sources.sidecar.find((s) => s.id === selectedId);
if (side) return side.language ? langShort(side.language) : labelShort(side.label, "Subtitles");
return "Subtitles";
}
// For compound-language entries (lang=null, label="English/Japanese")
// derive a short code from the label so the trigger reads "EN" rather
// than the generic fallback.
function labelShort(label: string, fallback: string): string {
if (/\benglish\b/i.test(label)) return "EN";
if (/\bchinese\b/i.test(label)) return "CN";
if (/\bjapanese\b/i.test(label)) return "JP";
return fallback;
}
function langShort(iso: LangIso): string {
if (iso === "eng") return "EN";
if (iso === "zho") return "CN";
return "JP";
}
/**
* Subtitle source picker. Trigger always renders with a stable min-width
* to avoid layout shift when sources resolve. Menu groups Embedded /
* Sidecar with "Off" and "Browse..." as fixed entries.
*/
export function SubtitlePicker({
sources,
selectedId,
loading,
onChange,
onBrowse,
runningJob,
onGenerate,
onCancel,
generateEnabled,
hasGenerated,
offsetSec,
onOffsetChange,
}: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocDown = (e: MouseEvent) => {
if (!ref.current) return;
if (!ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const embedded = sources?.embedded ?? [];
const sidecar = sources?.sidecar ?? [];
const hasAny = embedded.length > 0 || sidecar.length > 0;
const trigger = shortLabel(sources, selectedId, loading);
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="Subtitles"
className={cn(
"inline-flex items-center justify-between gap-1.5 min-w-[140px] text-xs px-3 py-1.5 rounded-lg glass glass-hover",
open && "bg-[var(--color-glass-strong)]",
)}
>
<span className="inline-flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5" />
<span className="font-mono">{trigger}</span>
</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[280px] max-w-[440px] rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-1)] shadow-2xl p-1"
>
<MenuItem
label="Off"
mono
active={selectedId === "off"}
onClick={() => { onChange("off"); setOpen(false); }}
/>
{embedded.length > 0 && <MenuHeader label="Embedded" />}
{embedded.map((e) => (
<MenuItem
key={e.id}
label={e.label}
detail={`#${e.streamIndex}`}
active={selectedId === e.id}
disabled={!e.renderable}
disabledReason="Image Subs Not Supported"
onClick={() => { onChange(e.id); setOpen(false); }}
/>
))}
{sidecar.length > 0 && <MenuHeader label="Subtitles" />}
{sidecar.map((s) => (
<MenuItem
key={s.id}
label={s.label}
detail={s.filename}
active={selectedId === s.id}
onClick={() => { onChange(s.id); setOpen(false); }}
/>
))}
{!hasAny && !loading && (
<div className="px-2 py-2 text-[11px] text-[var(--color-fg-muted)]">
No subtitles found for this video.
</div>
)}
{loading && !hasAny && (
<div className="px-2 py-2 text-[11px] text-[var(--color-fg-muted)]">
Looking for subtitles
</div>
)}
{selectedId !== "off" && onOffsetChange && (
<>
<div className="my-1 border-t border-[var(--color-glass-border)]" />
<SyncControls
offsetSec={offsetSec ?? 0}
onChange={onOffsetChange}
/>
</>
)}
<div className="my-1 border-t border-[var(--color-glass-border)]" />
<MenuItem
label="Browse..."
mono
onClick={() => { onBrowse(); setOpen(false); }}
/>
{generateEnabled && !runningJob && (
<>
<div className="my-1 border-t border-[var(--color-glass-border)]" />
<button
type="button"
role="menuitem"
onClick={() => { onGenerate?.(!!hasGenerated); setOpen(false); }}
className="w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md hover:bg-[var(--color-glass)] text-[var(--color-fg)] cursor-pointer"
>
<Sparkles className="w-3.5 h-3.5 text-[var(--color-cyan)] shrink-0" />
<span className="font-mono">{hasGenerated ? "Regenerate Subtitles" : "Generate Subtitles"}</span>
</button>
</>
)}
</div>
)}
</div>
);
}
function formatElapsed(sec: number): string {
const s = Math.max(0, Math.floor(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
}
function SyncControls({
offsetSec,
onChange,
}: {
offsetSec: number;
onChange: (next: number) => void;
}) {
const adjust = (delta: number) => onChange(Math.round((offsetSec + delta) * 100) / 100);
// Local string so the user can type freely — including transient
// states like "-" or "1." — without React clobbering it on every
// keystroke. Commit to the parent on blur or Enter.
const [draft, setDraft] = useState<string>(() => formatOffset(offsetSec));
// Re-sync when the parent value changes from outside (e.g. button
// press, code change). Using a ref to avoid clobbering active typing.
const lastSyncedRef = useRef(offsetSec);
useEffect(() => {
if (lastSyncedRef.current !== offsetSec) {
setDraft(formatOffset(offsetSec));
lastSyncedRef.current = offsetSec;
}
}, [offsetSec]);
const commit = () => {
const parsed = parseFloat(draft);
if (Number.isFinite(parsed)) {
const rounded = Math.round(parsed * 100) / 100;
lastSyncedRef.current = rounded;
onChange(rounded);
setDraft(formatOffset(rounded));
} else {
// Reject garbage — revert.
setDraft(formatOffset(offsetSec));
}
};
return (
<div
className="px-2 py-2 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mr-1">
Sync
</span>
<SyncBtn label="1s" onClick={() => adjust(-1)} title="Shift earlier 1s" />
<SyncBtn label=".1" onClick={() => adjust(-0.1)} title="Shift earlier 0.1s" />
<input
type="text"
inputMode="decimal"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") { e.currentTarget.blur(); }
else if (e.key === "Escape") { setDraft(formatOffset(offsetSec)); e.currentTarget.blur(); }
}}
title="Type a value (seconds), press Enter to apply"
className={cn(
"w-[60px] text-center text-xs font-mono px-1.5 py-0.5 rounded outline-none border",
offsetSec !== 0
? "text-[var(--color-cyan)] bg-[var(--color-cyan)]/10 border-[var(--color-cyan)]/40"
: "text-[var(--color-fg-dim)] bg-[var(--color-glass)] border-[var(--color-glass-border)]",
"focus:border-[var(--color-cyan)] focus:bg-[var(--color-cyan)]/10",
)}
/>
<SyncBtn label="+.1" onClick={() => adjust(0.1)} title="Shift later 0.1s" />
<SyncBtn label="+1s" onClick={() => adjust(1)} title="Shift later 1s" />
{offsetSec !== 0 && (
<button
type="button"
onClick={() => onChange(0)}
title="Reset to 0"
className="ml-1 text-[10px] uppercase tracking-wider font-mono px-1.5 py-0.5 rounded text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] cursor-pointer"
>
Reset
</button>
)}
</div>
);
}
function formatOffset(sec: number): string {
if (sec === 0) return "0.0s";
return `${sec > 0 ? "+" : ""}${sec.toFixed(1)}s`;
}
function SyncBtn({ label, onClick, title }: { label: string; onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
title={title}
className="text-xs font-mono px-2 py-0.5 rounded glass glass-hover cursor-pointer"
>
{label}
</button>
);
}
function MenuHeader({ label }: { label: string }) {
return (
<div className="px-2 pt-2 pb-1 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
{label}
</div>
);
}
function MenuItem({
label,
detail,
active,
disabled,
disabledReason,
mono,
onClick,
}: {
label: string;
detail?: string;
active?: boolean;
disabled?: boolean;
disabledReason?: string;
mono?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={disabled ? undefined : onClick}
title={disabled ? disabledReason : detail}
className={cn(
"w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md",
disabled
? "opacity-40 cursor-not-allowed"
: active
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] cursor-pointer"
: "hover:bg-[var(--color-glass)] text-[var(--color-fg)] cursor-pointer",
)}
>
<span className={cn("shrink-0", mono && "font-mono")}>{label}</span>
{detail && (
<span className="font-mono text-[10px] text-[var(--color-fg-dim)] truncate min-w-0">
{detail}
</span>
)}
</button>
);
}