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,
  };
}