Summary of Changes
The playback system has been completely rewritten to fix critical issues causing:
- ❌ Playhead moving backward during playback
- ❌ Severe lag and stuttering
- ❌ High CPU usage
- ❌ Unpredictable animation behavior
What Was Wrong
The Core Problem: Dependency Cycle
The original usePlayback.ts had a fatal design flaw:
// OLD CODE - BROKEN
useEffect(() => {
const animate = (timestamp: number) => {
const newTime = currentTime + deltaSeconds; // ⚠️ Uses state variable
setCurrentTime(newTime); // ⚠️ Triggers re-render
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [isPlaying, currentTime, duration, loop, setCurrentTime, setPlaying]);
// ^^^^^^^^^^^ THIS CAUSED THE BUG
The Cascade of Failures:
- Animation frame runs →
setCurrentTime(0.016)→ State updates - React re-renders → useEffect sees
currentTimechanged - Effect UNMOUNTS old animation loop
- Effect REMOUNTS new animation loop
-
lastTimeRef.current = performance.now()resets - Next frame calculates delta from wrong baseline
- Result: Time jumps backward, severe lag
This happened 60 times per second, causing:
- Constant effect remounting
- Garbage collection pressure
- Timing drift and errors
- Backward playhead movement
Why It Couldn't Be Fixed With Simple Changes
The problem wasn't just the dependency array. The fundamental architecture was wrong:
- Animation loop tied to React's render cycle
- State updates triggering effect re-runs
- No separation between internal timing and UI updates
The New Architecture
Core Principle: Decouple Animation from Rendering
The new system uses refs for internal state and state for UI updates:
// Refs - Track internal state without triggering re-renders
const internalTimeRef = useRef<number>(currentTime);
const isPlayingRef = useRef<boolean>(isPlaying);
const durationRef = useRef<number>(duration);
const loopRef = useRef<boolean>(loop);
const fpsRef = useRef<number>(fps);
// Animation loop uses ONLY refs
const animate = (timestamp: number) => {
const newTime = internalTimeRef.current + deltaSeconds; // ✅ Uses ref
internalTimeRef.current = newTime; // ✅ Updates ref
setCurrentTime(newTime); // ✅ Updates UI (doesn't restart loop)
};
Key Improvements
1. Refs Prevent Stale Closures
Sync effects keep refs updated:
useEffect(() => {
internalTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
This ensures:
- External seeking updates internal time
- Sequence changes update duration/fps
- No stale values in animation loop
2. Animation Loop Runs Once
useEffect(() => {
if (!isPlaying) return;
// Start animation loop ONCE
const animate = (timestamp: number) => {
// Uses refs - no stale closures
// Updates state - doesn't restart loop
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [isPlaying, setCurrentTime, setPlaying]);
// ✅ currentTime, duration, loop, fps NOT in dependencies
The loop:
- Starts when
isPlayingbecomes true - Runs continuously until stopped
- Never restarts mid-playback
- Uses refs for all internal state
3. Proper State Management
const togglePlay = useCallback(() => {
if (isPlayingRef.current) {
pause();
} else {
// Restart from beginning if at end
if (internalTimeRef.current >= durationRef.current) {
internalTimeRef.current = 0;
setCurrentTime(0);
}
play();
}
}, [play, pause, setCurrentTime]);
All callbacks use refs to check current values, preventing stale closures.
How It Works Now
Playback Flow
-
User presses play →
setPlaying(true) - useEffect triggers (only for isPlaying change)
- Animation loop starts:
Frame 0: timestamp = 1000, delta = 0, time = 0.000
Frame 1: timestamp = 1016, delta = 16ms, time = 0.016
Frame 2: timestamp = 1033, delta = 17ms, time = 0.033
Frame 3: timestamp = 1049, delta = 16ms, time = 0.049
... smooth forward progression
-
Each frame:
- Calculates delta from last frame
- Updates
internalTimeRef.current - Calls
setCurrentTime()for UI - Schedules next frame
- Effect does NOT re-run because currentTime not in deps
- Result: Smooth, accurate, forward-only playback
Seeking Flow
-
User drags playhead →
seekTo(2.5)called - seekTo updates both:
internalTimeRef.current = 2.5; // Internal state
setCurrentTime(2.5); // UI state
-
Sync effect runs:
internalTimeRef.current = currentTime - Next animation frame uses correct time
- No discontinuity or time jump
Sequence Changes
When a sequence changes (fps, duration):
- AnimationContext updates timeline state
- Sync effects run:
durationRef.current = newDuration;
fpsRef.current = newFps;
- Animation loop uses new values immediately
- No restart needed
Performance Improvements
Before (Broken System)
- ⚠️ Effect remounts: 60 times/second
- ⚠️ State updates: 60 times/second
- ⚠️ Cleanup functions: 60 times/second
- ⚠️ Setup functions: 60 times/second
- ⚠️ CPU usage: Very high
- ⚠️ Frame rate: Unstable, dropping
- ⚠️ Timing: Inaccurate, backward movement
After (New System)
- ✅ Effect remounts: 1 time on play/pause
- ✅ State updates: 60 times/second (normal)
- ✅ Cleanup functions: 1 time on stop
- ✅ Setup functions: 1 time on start
- ✅ CPU usage: Normal
- ✅ Frame rate: Stable 60 FPS
- ✅ Timing: Accurate, smooth forward
Edge Cases Handled
1. Seeking While Playing
seekTo(5.0) → internalTimeRef updates → sync effect runs → continues smoothly
✅ No time jump or discontinuity
2. Changing Duration Mid-Playback
setDuration(20) → durationRef updates → animation continues with new limit
✅ No restart, smooth transition
3. Reaching End
if (newTime >= durationRef.current) {
if (loopRef.current) {
// Loop back
} else {
// Stop cleanly
}
}
✅ Proper loop or stop behavior
4. Pause/Resume
pause() → cancels RAF → isPlayingRef = false
play() → starts new RAF loop → lastTimestampRef resets
✅ No time jump from pause duration
5. Stop/Restart
stop() → time = 0, cancels RAF
play() → starts from 0
✅ Clean restart behavior
Testing Checklist
Test these scenarios to verify the fix:
Basic Playback
- [x] Press play → playhead moves forward smoothly
- [x] No backward movement at any point
- [x] Consistent speed (30 FPS = 0.033s per frame)
- [x] Time display matches actual progression
Seeking
- [x] Seek while paused → updates immediately
- [x] Seek while playing → continues from new position
- [x] Seek to start → resets to 0
- [x] Seek to end → stops at duration
Controls
- [x] Play/pause → toggles correctly
- [x] Stop → returns to start
- [x] Step forward → advances one frame
- [x] Step backward → goes back one frame
Looping
- [x] Enable loop → reaches end → restarts at 0
- [x] Disable loop → reaches end → stops
Performance
- [x] Open DevTools Performance tab
- [x] Record during playback
- [x] Verify smooth 60 FPS
- [x] No excessive re-renders
- [x] Normal CPU usage
Sequences
- [x] Create sequence with 24 FPS → playback respects frame rate
- [x] Change duration → playback adapts
- [x] Switch sequences → timeline updates correctly
Technical Details
Refs vs State
When to use Refs:
- Internal timing values
- Values that change every frame
- Values needed in animation loop
- Values that shouldn't trigger re-renders
When to use State:
- UI display values
- User-controllable values
- Values that need to trigger re-renders
- Values that affect component output
Why This Works
- Refs are mutable without triggering re-renders
- Sync effects keep refs updated from external changes
- Animation loop runs independently of React's render cycle
- State updates only affect UI, not loop timing
- No circular dependencies between state and effects
Performance Characteristics
- Memory: Minimal (few extra refs)
- CPU: Optimal (one RAF loop, no restart overhead)
- Timing accuracy: High (no drift from restarts)
- Frame rate: Stable (60 FPS on most displays)
Migration Notes
No Breaking Changes
The external API remains identical:
const {
play, pause, stop, togglePlay,
seekTo, seekToFrame,
isPlaying, currentTime, currentFrame,
// ... all the same
} = usePlayback();
Internal Changes Only
All changes are internal to usePlayback.ts. No other files need updates.
Backward Compatible
Existing code using usePlayback() works without modification.
Conclusion
The playback system has been fundamentally redesigned to:
- Separate concerns: Animation timing vs UI rendering
- Use refs for internal state: Avoid re-render loops
- Use state for UI updates: Keep display in sync
- Run animation loop once: No restarts mid-playback
- Handle all edge cases: Seeking, looping, sequences
Result: Smooth, accurate, performant playback that respects sequences and provides a professional animation experience.
Code Comparison
Before (Broken)
useEffect(() => {
lastTimeRef.current = performance.now();
const animate = (timestamp: number) => {
const deltaMs = timestamp - lastTimeRef.current;
const newTime = currentTime + deltaSeconds; // ⚠️ Stale closure
setCurrentTime(newTime); // ⚠️ Triggers remount
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [isPlaying, currentTime, ...]);
// ⚠️ Remounts 60 times/second
After (Fixed)
const internalTimeRef = useRef<number>(currentTime);
useEffect(() => {
internalTimeRef.current = currentTime;
}, [currentTime]); // ✅ Sync only
useEffect(() => {
const animate = (timestamp: number) => {
const newTime = internalTimeRef.current + deltaSeconds; // ✅ Always current
internalTimeRef.current = newTime; // ✅ Update ref
setCurrentTime(newTime); // ✅ Update UI (doesn't remount)
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [isPlaying]); // ✅ Runs once per play/pause
The difference: Refs break the dependency cycle and allow the animation loop to run independently of React's render cycle.
Top comments (0)