Initial commit
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user