"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(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 (
{open && (
{ onChange("off"); setOpen(false); }} /> {embedded.length > 0 && } {embedded.map((e) => ( { onChange(e.id); setOpen(false); }} /> ))} {sidecar.length > 0 && } {sidecar.map((s) => ( { onChange(s.id); setOpen(false); }} /> ))} {!hasAny && !loading && (
No subtitles found for this video.
)} {loading && !hasAny && (
Looking for subtitles…
)} {selectedId !== "off" && onOffsetChange && ( <>
)}
{ onBrowse(); setOpen(false); }} /> {generateEnabled && !runningJob && ( <>
)}
)}
); } 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(() => 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 (
e.stopPropagation()} > Sync adjust(-1)} title="Shift earlier 1s" /> adjust(-0.1)} title="Shift earlier 0.1s" /> 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", )} /> adjust(0.1)} title="Shift later 0.1s" /> adjust(1)} title="Shift later 1s" /> {offsetSec !== 0 && ( )}
); } 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 ( ); } function MenuHeader({ label }: { label: string }) { return (
{label}
); } function MenuItem({ label, detail, active, disabled, disabledReason, mono, onClick, }: { label: string; detail?: string; active?: boolean; disabled?: boolean; disabledReason?: string; mono?: boolean; onClick: () => void; }) { return ( ); }