Files
pinkudex/components/video/SubtitlePicker.tsx
T
2026-05-26 22:46:00 +02:00

399 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}