Spaces:
Runtime error
Runtime error
File size: 6,760 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | "use client";
import { useReducer, useEffect, useRef, useCallback } from "react";
import { buildReplayFrames, type CompressionRunModel } from "./compressionFlowModel";
// ββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type ReplaySpeed = 0.3 | 1 | 3;
export interface UseCompressionReplayReturn {
/** The current in-progress frame (null when idle or complete). */
currentFrame: CompressionRunModel | null;
/** Index of the currently displayed frame (0-based). */
frameIndex: number;
/** Total number of frames available. */
totalFrames: number;
isPlaying: boolean;
isComplete: boolean;
speed: ReplaySpeed;
setSpeed: (s: ReplaySpeed) => void;
play: () => void;
pause: () => void;
reset: () => void;
}
// ββ Timing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const BASE_STEP_MS = 400;
/** Interval (ms) between frames for a given speed. Exported as a unit-test seam. */
export function stepMs(speed: ReplaySpeed): number {
return Math.round(BASE_STEP_MS / speed);
}
// ββ Reducer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// `ReplayState`, `ReplayAction`, `INITIAL_STATE` and `replayReducer` are exported
// purely as unit-test seams (the state machine is otherwise driven only via the hook).
export interface ReplayState {
frameIndex: number; // -1 = not started
isPlaying: boolean;
speed: ReplaySpeed;
}
export type ReplayAction =
| { type: "RESET" }
| { type: "PLAY"; frameIndex?: number }
| { type: "PAUSE" }
| { type: "TICK"; totalFrames: number }
| { type: "SET_SPEED"; speed: ReplaySpeed };
export const INITIAL_STATE: ReplayState = { frameIndex: -1, isPlaying: false, speed: 1 };
export function replayReducer(state: ReplayState, action: ReplayAction): ReplayState {
switch (action.type) {
case "RESET":
return { ...state, frameIndex: -1, isPlaying: false };
case "PLAY":
return {
...state,
isPlaying: true,
frameIndex: action.frameIndex !== undefined ? action.frameIndex : state.frameIndex,
};
case "PAUSE":
return { ...state, isPlaying: false };
case "TICK": {
const next = state.frameIndex + 1;
if (next >= action.totalFrames - 1) {
return { ...state, frameIndex: action.totalFrames - 1, isPlaying: false };
}
return { ...state, frameIndex: next };
}
case "SET_SPEED":
return { ...state, speed: action.speed };
default:
return state;
}
}
// ββ Hook ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Drives frame-by-frame replay of a `CompressionRunModel`.
*
* Since compression is sub-ms synchronous, this hook artificially paces
* the reveal using a `setInterval` (cleared on unmount or model change).
* All state lives in a single `useReducer` to avoid cascading setState calls
* inside effects (satisfies react-hooks/set-state-in-effect).
*/
export function useCompressionReplay(
model: CompressionRunModel | null
): UseCompressionReplayReturn {
const frames = model ? buildReplayFrames(model) : [];
const totalFrames = frames.length;
const [state, dispatch] = useReducer(replayReducer, INITIAL_STATE);
const { frameIndex, isPlaying, speed } = state;
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Stable ref to totalFrames so the interval callback never closes over a stale value
const totalFramesRef = useRef(totalFrames);
const speedRef = useRef<ReplaySpeed>(speed);
// Sync refs in event-handler-safe effects
useEffect(() => {
totalFramesRef.current = totalFrames;
}, [totalFrames]);
useEffect(() => {
speedRef.current = speed;
}, [speed]);
// Clear interval helper (stable β no deps)
const clearTick = useCallback(() => {
if (intervalRef.current != null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// Start the interval ticker
const startTick = useCallback(() => {
clearTick();
intervalRef.current = setInterval(() => {
// dispatch is stable β safe to call inside an interval callback
dispatch({ type: "TICK", totalFrames: totalFramesRef.current });
}, stepMs(speedRef.current));
}, [clearTick]);
// Stop ticker when isPlaying turns false (either from PAUSE, RESET, or TICKβdone)
useEffect(() => {
if (!isPlaying) clearTick();
}, [isPlaying, clearTick]);
// Reset when model identity changes
const modelRef = useRef(model);
useEffect(() => {
if (modelRef.current !== model) {
modelRef.current = model;
clearTick();
dispatch({ type: "RESET" });
}
}, [model, clearTick]);
// Cleanup on unmount
useEffect(() => {
return () => clearTick();
}, [clearTick]);
// Public controls β called from event handlers, so setState inside is fine
const play = useCallback(() => {
if (totalFramesRef.current === 0) return;
const restartIndex = frameIndex >= totalFramesRef.current - 1 ? 0 : undefined;
dispatch({ type: "PLAY", ...(restartIndex !== undefined ? { frameIndex: restartIndex } : {}) });
startTick();
}, [frameIndex, startTick]);
const pause = useCallback(() => {
dispatch({ type: "PAUSE" });
clearTick();
}, [clearTick]);
const reset = useCallback(() => {
dispatch({ type: "RESET" });
clearTick();
}, [clearTick]);
const handleSetSpeed = useCallback(
(s: ReplaySpeed) => {
// Update the cadence ref synchronously: startTick() below reads speedRef.current
// immediately, but the syncing effect only runs after render β too late for this
// in-flight restart. Without this, changing speed mid-play kept the old cadence.
speedRef.current = s;
dispatch({ type: "SET_SPEED", speed: s });
if (isPlaying) startTick();
},
[isPlaying, startTick]
);
const currentFrame = frameIndex >= 0 && frameIndex < frames.length ? frames[frameIndex] : null;
const isComplete = totalFrames > 0 && frameIndex >= totalFrames - 1 && !isPlaying;
return {
currentFrame,
frameIndex,
totalFrames,
isPlaying,
isComplete,
speed,
setSpeed: handleSetSpeed,
play,
pause,
reset,
};
}
|