|
|
@@ -100,26 +100,41 @@ export function VideoPlayer({
|
|
|
visibleAnnotationsRef.current = visibleAnnotations;
|
|
|
drawModeRef.current = drawMode;
|
|
|
|
|
|
+ // ── Frame-step debounce ──────────────────────────────────────────────────
|
|
|
+ // Prevent rapid keypresses from piling up seeks before they complete.
|
|
|
+ // A step is considered "in flight" for 150ms; during this window the seeked
|
|
|
+ // handler is gated so we don't fire redundant setState on every rVFC tick.
|
|
|
+ const stepInFlightRef = useRef(false);
|
|
|
+ const stepTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
+
|
|
|
// ── rVFC loop ───────────────────────────────────────────────────────────────
|
|
|
useEffect(() => {
|
|
|
const video = videoRef.current;
|
|
|
if (!video) return;
|
|
|
+
|
|
|
function onFrame(_now: number, metadata: { mediaTime: number }) {
|
|
|
+ // Skip state updates when a frame-step is in flight to avoid re-render pile-up
|
|
|
+ if (stepInFlightRef.current) {
|
|
|
+ videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
+ return;
|
|
|
+ }
|
|
|
const t = metadata.mediaTime;
|
|
|
setCurrentTime(t);
|
|
|
onTimeUpdate(t);
|
|
|
redrawAnnotationsRef.current(t);
|
|
|
videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
}
|
|
|
- videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
|
|
|
- // Immediate time update after any seek completes — keeps UI in sync with video
|
|
|
function onSeeked() {
|
|
|
+ // Only update UI for non-frame-step seeks (e.g. click-to-seek on timeline)
|
|
|
+ if (stepInFlightRef.current) return;
|
|
|
const t = videoRef.current?.currentTime ?? 0;
|
|
|
setCurrentTime(t);
|
|
|
onTimeUpdate(t);
|
|
|
redrawAnnotationsRef.current(t);
|
|
|
}
|
|
|
+
|
|
|
+ videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
|
|
|
video.addEventListener('seeked', onSeeked);
|
|
|
|
|
|
return () => {
|
|
|
@@ -209,9 +224,27 @@ export function VideoPlayer({
|
|
|
function stepFrame(dir: 1 | -1) {
|
|
|
const video = videoRef.current;
|
|
|
if (!video) return;
|
|
|
- const frameTime = 1 / (fps || 30);
|
|
|
+ const frameTime = 1 / (fpsRef.current || 30);
|
|
|
+
|
|
|
+ // Gate: block rVFC state updates for 150ms so the video has time to seek
|
|
|
+ // before we start hammering React with setState from every rVFC tick
|
|
|
+ stepInFlightRef.current = true;
|
|
|
+ if (stepTimeoutRef.current !== null) clearTimeout(stepTimeoutRef.current);
|
|
|
+
|
|
|
video.pause();
|
|
|
- video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
|
|
|
+ const targetTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
|
|
|
+ video.currentTime = targetTime;
|
|
|
+
|
|
|
+ // Update UI immediately — don't wait for rVFC or seeked
|
|
|
+ setCurrentTime(targetTime);
|
|
|
+ onTimeUpdate(targetTime);
|
|
|
+ redrawAnnotationsRef.current(targetTime);
|
|
|
+
|
|
|
+ // Release the gate after one frame decode cycle (~16ms) + decode overhead
|
|
|
+ stepTimeoutRef.current = setTimeout(() => {
|
|
|
+ stepInFlightRef.current = false;
|
|
|
+ stepTimeoutRef.current = null;
|
|
|
+ }, 150);
|
|
|
}
|
|
|
|
|
|
function toggleFullscreen() {
|