75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
"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;
|
|
}
|