399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|