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
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { Play } from "lucide-react";
import { useVideoIndex } from "./VideoIndexProvider";
import { VideoPlayerModal } from "./VideoPlayerModal";
/**
* Centered play button overlay for cover images. Visible only when the
* video index has a match for this code. Same UX as the cover-grid play
* button — always visible, rectangular, opens the in-app player modal.
*/
export function CoverPlayButton({
code,
actresses,
}: {
code: string | null;
actresses?: Array<{ id: number; name: string; slug: string }>;
}) {
const idx = useVideoIndex();
const [playing, setPlaying] = useState(false);
if (!code || !idx.hasVideo(code)) return null;
return (
<>
<button
type="button"
onClick={() => setPlaying(true)}
aria-label="Play video"
title="Play video"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 inline-flex items-center justify-center w-20 h-14 rounded-lg backdrop-blur-md text-white/95 cursor-pointer transition-colors hover:text-[var(--color-cyan)] hover:[animation:play-pulse_1.2s_ease-out_infinite] active:scale-95"
style={{
background: "rgba(20,20,28,0.75)",
border: 0,
boxShadow: "0 6px 16px rgba(0,0,0,0.55), 0 1px 2px rgba(0,0,0,0.5)",
}}
>
<Play className="w-6 h-6" style={{ fill: "currentColor" }} />
</button>
{playing && (
<VideoPlayerModal
code={code}
actresses={actresses}
onClose={() => setPlaying(false)}
/>
)}
</>
);
}
+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>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface VariantOption {
/** Stable identifier (typically the absolute partIdx). */
id: number;
/** Short label shown in the chip and menu, e.g. `original`, `fixed`, `1080p`. */
label: string;
/** Full filename, shown muted in the menu for disambiguation. */
filename: string;
}
/**
* Compact dropdown that picks between alternate encodes of one part.
* Renders nothing when there's only one option — caller can still
* mount it unconditionally.
*/
export function VariantPicker({
options,
selectedId,
onChange,
}: {
options: VariantOption[];
selectedId: number;
onChange: (id: number) => void;
}) {
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]);
if (options.length <= 1) return null;
const selected = options.find((o) => o.id === selectedId) ?? options[0]!;
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
title={`Switch encode (${options.length} available)`}
className={cn(
"inline-flex items-center gap-1 text-xs font-mono px-2.5 py-1 rounded-md border cursor-pointer",
"border-[var(--color-glass-border)] bg-[var(--color-glass)] hover:bg-[var(--color-glass-strong)] text-[var(--color-fg)]",
open && "bg-[var(--color-glass-strong)]",
)}
>
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
<span className="truncate max-w-[140px]">{selected.label}</span>
</button>
{open && (
<div
role="menu"
className="absolute right-0 bottom-full mb-1 z-30 min-w-[260px] max-w-[420px] rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-1)] shadow-2xl p-1"
>
{options.map((o) => (
<button
key={o.id}
type="button"
role="menuitem"
onClick={() => { onChange(o.id); setOpen(false); }}
className={cn(
"w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md cursor-pointer",
o.id === selectedId
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "hover:bg-[var(--color-glass)] text-[var(--color-fg)]",
)}
>
<span className="font-mono shrink-0">{o.label}</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)] truncate min-w-0">
{o.filename}
</span>
</button>
))}
</div>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { subscribeVideoStatusRefresh } from "./videoStatusEvents";
interface Status {
codes: Set<string>;
subtitleCodes: Set<string>;
count: number;
lastScannedAt: number;
rootsScanned: string[];
}
interface Ctx extends Status {
hasVideo: (code: string | null | undefined) => boolean;
hasSubtitle: (code: string | null | undefined) => boolean;
refresh: () => Promise<void>;
}
const empty: Status = { codes: new Set(), subtitleCodes: new Set(), count: 0, lastScannedAt: 0, rootsScanned: [] };
const VideoIdxCtx = createContext<Ctx | null>(null);
export function VideoIndexProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<Status>(empty);
const inflightRef = useRef<AbortController | null>(null);
const refresh = useCallback(async () => {
// Abort any prior fetch so a slow first request can't clobber a
// newer second request's result on settle order.
inflightRef.current?.abort();
const ctrl = new AbortController();
inflightRef.current = ctrl;
try {
const r = await fetch("/api/video-status", { cache: "no-store", signal: ctrl.signal });
if (!r.ok) return;
const j = await r.json();
if (ctrl.signal.aborted) return;
setStatus({
codes: new Set<string>(Array.isArray(j.codes) ? j.codes : []),
subtitleCodes: new Set<string>(Array.isArray(j.subtitleCodes) ? j.subtitleCodes : []),
count: j.count ?? 0,
lastScannedAt: j.lastScannedAt ?? 0,
rootsScanned: Array.isArray(j.rootsScanned) ? j.rootsScanned : [],
});
} catch {
// Silent — if the endpoint fails or aborts, no badges. No user-facing error.
} finally {
if (inflightRef.current === ctrl) inflightRef.current = null;
}
}, []);
useEffect(() => { refresh(); return () => { inflightRef.current?.abort(); }; }, [refresh]);
useEffect(() => subscribeVideoStatusRefresh(() => { refresh(); }), [refresh]);
const value = useMemo<Ctx>(() => ({
...status,
hasVideo: (code) => {
if (!code) return false;
return status.codes.has(code.toUpperCase());
},
hasSubtitle: (code) => {
if (!code) return false;
return status.subtitleCodes.has(code.toUpperCase());
},
refresh,
}), [status, refresh]);
return <VideoIdxCtx.Provider value={value}>{children}</VideoIdxCtx.Provider>;
}
export function useVideoIndex(): Ctx {
const ctx = useContext(VideoIdxCtx);
if (!ctx) throw new Error("useVideoIndex must be used within VideoIndexProvider");
return ctx;
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
// Pure event-bus module separate from VideoIndexProvider.tsx so the
// provider file only exports a Component + a `use*` hook — that's the
// shape Next.js Fast Refresh requires to swap a Provider in place
// instead of forcing a full page reload.
const REFRESH_EVENT = "pinkudex:video-status-refresh";
export function dispatchVideoStatusRefresh() {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
}
export function subscribeVideoStatusRefresh(cb: () => void): () => void {
window.addEventListener(REFRESH_EVENT, cb);
return () => window.removeEventListener(REFRESH_EVENT, cb);
}