import { useEffect, useRef, useState } from "react"; import type { ScadaReplay } from "../api/scada-replay-client"; export const SCADA_PLAYBACK_SPEED = 180; interface PlaybackOptions { active: boolean; enabled: boolean; busy: boolean; start: number; end: number; now: number; intervalSeconds: number | null | undefined; restart: boolean; advance: () => Promise; } /** Browser-owned demo clock. Each boundary waits for one durable Server step. */ export function useScadaPlayback(options: PlaybackOptions) { const latest = useRef(options); latest.current = options; const [playing, setPlaying] = useState(false); const [started, setStarted] = useState(false); const [newRound, setNewRound] = useState(false); const [time, setTime] = useState(options.now); const running = useRef(false); const pending = useRef(false); const position = useRef(options.now); const processed = useRef(options.now); const mounted = useRef(true); const restartPrepared = useRef(false); const pause = () => { running.current = false; setPlaying(false); }; const reset = () => { pause(); setStarted(false); setNewRound(false); restartPrepared.current = false; processed.current = position.current = latest.current.start; setTime(latest.current.start); }; useEffect(() => { mounted.current = true; const hidden = () => { if (document.visibilityState !== "visible") pause(); }; document.addEventListener("visibilitychange", hidden); return () => { mounted.current = false; running.current = false; document.removeEventListener("visibilitychange", hidden); }; }, []); useEffect(() => { pause(); setStarted(false); setNewRound(false); position.current = latest.current.now; processed.current = latest.current.now; setTime(latest.current.now); }, [options.start, options.end]); useEffect(() => { processed.current = options.now; position.current = options.now; setTime(options.now); if (options.now >= latest.current.end) pause(); }, [options.now]); useEffect(() => { if (!options.active || !options.enabled || options.restart) pause(); }, [options.active, options.enabled, options.restart]); useEffect(() => { restartPrepared.current = false; }, [options.restart]); useEffect(() => { if (!playing) return; let previousTick = performance.now(); const timer = window.setInterval(() => { const current = latest.current; const tick = performance.now(); const elapsed = Math.min(tick - previousTick, 250); previousTick = tick; if (!running.current || !current.active || !current.enabled || document.visibilityState !== "visible" || pending.current || current.busy || !current.intervalSeconds) return; const interval = current.intervalSeconds * 1000; const target = Math.min(current.end, current.start + (Math.floor((processed.current - current.start) / interval) + 1) * interval); position.current = Math.min(target, position.current + elapsed * SCADA_PLAYBACK_SPEED); setTime(position.current); if (position.current < target) return; pending.current = true; const requestedFrom = processed.current; void current.advance().then((next) => { if (!mounted.current) return; if (!next || next.status === "failed" || next.step_pending) { pause(); return; } const nextTime = Date.parse(next.virtual_now); if (nextTime <= requestedFrom && !current.restart) { pause(); return; } processed.current = position.current = nextTime; setNewRound(false); setTime(nextTime); if (next.status === "completed" || nextTime >= current.end) pause(); }).catch(() => { if (mounted.current) pause(); }).finally(() => { pending.current = false; previousTick = performance.now(); }); }, 100); return () => window.clearInterval(timer); }, [playing]); const play = () => { if (!options.active || !options.enabled || options.busy || pending.current || !options.intervalSeconds) return; if ((options.restart && !restartPrepared.current) || position.current >= options.end) { processed.current = position.current = options.start; restartPrepared.current = true; setNewRound(true); setTime(options.start); } running.current = true; setStarted(true); setPlaying(true); }; return { playing, started, newRound, time, play, pause, reset }; }