"use client"; import { useEffect, useState } from "react"; import type { PulsePayload } from "yt-dlp-transcript-common/views/pulse"; // One poller for the whole app, shared through a module-level store. // // AutoRefresh and every sidebar badge want the same ~2 ms answer to "has // anything changed?", and N components each running their own interval would // reintroduce the polling cost this endpoint exists to remove. So the poller is // a singleton: the first subscriber starts it, the last one to leave stops it. // // Deliberately not React Context — the badges live inside the SERVER-rendered // sidebar in the root layout, which cannot be wrapped in a client provider // without making the whole layout a client component. export type PulseState = { rev: string | null; activeJobs: number; runningJobs: number; cleanableBytes: number; busy: boolean; }; type Listener = (state: PulseState) => void; let state: PulseState = { rev: null, activeJobs: 0, runningJobs: 0, cleanableBytes: 0, busy: false, }; const listeners = new Set(); let timer: ReturnType | null = null; let inFlight = false; let intervalMs = 5000; // Bumped whenever `rev` changes, so components can react to "something changed" // without diffing the token themselves. let revCounter = 0; export function getPulseState(): PulseState { return state; } export function getRevCounter(): number { return revCounter; } function emit() { for (const l of listeners) l(state); } async function poll(): Promise { if (inFlight) return; if (typeof document !== "undefined" && document.hidden) return; inFlight = true; try { const url = state.rev ? `/api/view/pulse?rev=${encodeURIComponent(state.rev)}` : "/api/view/pulse"; const res = await fetch(url, { cache: "no-store" }); if (!res.ok) return; const data = (await res.json()) as PulsePayload; const revChanged = data.rev !== state.rev; state = { rev: data.rev, activeJobs: data.activeJobs, runningJobs: data.runningJobs, // -1 is the idle path's "not recomputed" marker; keep the last known // value rather than flashing the badge to zero. cleanableBytes: data.cleanableBytes >= 0 ? data.cleanableBytes : state.cleanableBytes, busy: data.busy, }; if (revChanged) revCounter++; emit(); } catch { // A failed poll is not worth surfacing; the next tick tries again. } finally { inFlight = false; } } function schedule() { if (timer) clearTimeout(timer); if (intervalMs <= 0) return; timer = setTimeout(async () => { await poll(); schedule(); }, intervalMs); } export function configurePulse(ms: number): void { intervalMs = Number.isFinite(ms) ? ms : 0; if (listeners.size > 0) schedule(); } function start() { if (timer || intervalMs <= 0) return; void poll(); schedule(); } function stop() { if (timer) clearTimeout(timer); timer = null; } export function subscribePulse(listener: Listener): () => void { listeners.add(listener); if (listeners.size === 1) { start(); // Catch up the moment the tab comes back, the same guard the old refresher // had — a tab left in the background for an hour should not show hour-old // numbers for a further five seconds. document.addEventListener("visibilitychange", onVisibility); } return () => { listeners.delete(listener); if (listeners.size === 0) { stop(); document.removeEventListener("visibilitychange", onVisibility); } }; } function onVisibility() { if (!document.hidden) void poll(); } // Subscribe a component to the shared poller. // // Returns null until the first poll AFTER this component mounts, and callers // fall back to their server-rendered seed until then. That is not caution, it // is a hydration requirement: the badges live inside a boundary, so // they hydrate LATER than the rest of the tree — by which time the shared // poller (started by a component that hydrated earlier) may already have new // numbers in this module. Initialising state from the module would then make // the client's first render disagree with the server's HTML, which is exactly // the mismatch this used to produce: the server rendered no badge (0 bytes) and // the client rendered one. export function usePulse(): PulseState | null { const [local, setLocal] = useState(null); useEffect(() => subscribePulse(setLocal), []); return local; }