fix: video won't seek — playhead clock had no element handle

seek-to-frame! and the rAF clock both reach the <video> through the
shared video-el atom, which was populated by an inline :ref closure —
recreated every render and callable with nil by React, so video-el ended
up nil. That made set! currentTime (jumps + scrubs) and the clock's
currentTime read both silent no-ops: the red line moved (separate
:playhead dispatch) but the picture never seeked or followed.

Capture the element with a stable ref that ignores nil, and collapse the
clock to a single source of truth: one always-on rAF copies currentTime
into :playhead, and jump/scrub only seek (no optimistic :playhead write),
so the line and the picture can't disagree. Replaces the old
push!/tick/stop + on-play/on-pause/on-seeked bookkeeping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-27 17:05:14 -04:00
parent 24558b1cae
commit ea78a9ee08

View file

@ -39,13 +39,14 @@
target (-> (- px (/ (.-clientWidth el) 2)) (max 0) (min max-sl))]
(set! (.-scrollLeft el) target))))
;; Moving the playhead = seeking the video. The video is the single clock
;; (video-monitor's rAF reads currentTime back into :playhead), so we never
;; write :playhead directly — we just move the video and let it report back.
(defn- seek-to-frame! [fps frames]
(when-let [v @video-el]
(set! (.-currentTime v) (secs frames fps))))
(defn- jump! [fps frame]
(rf/dispatch [::events/set-playhead frame])
(seek-to-frame! fps frame))
(defn- jump! [fps frame] (seek-to-frame! fps frame))
(defn- jump-and-scroll!
"Jump (seek video + move playhead) AND scroll the timeline to center the frame."
@ -142,28 +143,42 @@
(.addEventListener js/document "keydown" on-keydown)
(defn video-monitor
"Native-controls <video> that acts as the playback clock: while it's playing,
a requestAnimationFrame loop pushes currentTime (as frames) into :playhead.
Also syncs on seek/pause so scrubbing (native controls or the timeline) moves
the playhead. Sized by CSS (.video-pane video) to fit its pane."
"Native-controls <video>, which is the single playback clock AND the single
seek target. One always-on requestAnimationFrame copies its currentTime (as
frames) into :playhead whenever it changes — covering playback, native-control
scrubbing, jumps and timeline scrubs alike, with no per-event bookkeeping.
The element is captured into `video-el` (shared with seek-to-frame!) by a
stable ref that ignores nil — an *inline* ref closure is re-created every
render and React can call it with nil, which is what left video-el nil and
made both the clock read and the seek write silently no-op."
[fps]
(let [raf (atom nil)
push! (fn [] (when-let [v @video-el]
(rf/dispatch [::events/set-playhead (* (.-currentTime v) fps)])))
tick (fn tick [] (push!) (reset! raf (js/requestAnimationFrame tick)))
stop (fn [] (when @raf (js/cancelAnimationFrame @raf) (reset! raf nil)))]
(fn [_fps]
[:video {:src media-src
:controls true
:preload "auto"
;; Keep playback inline on iOS instead of jumping to fullscreen.
:plays-inline true
:webkit-playsinline "true"
:ref (fn [node] (reset! video-el node))
:on-play (fn [_] (reset! following? true)
(rf/dispatch [::events/set-playing true]) (stop) (tick))
:on-pause (fn [_] (rf/dispatch [::events/set-playing false]) (stop) (push!))
:on-seeked (fn [_] (push!))}])))
(let [raf (atom nil)
seen (atom nil) ; last currentTime we pushed
capture! (fn [n] (when n (reset! video-el n))) ; stable id; ignore React's nils
tick (fn tick []
(when-let [v @video-el]
(let [t (.-currentTime v)]
(when (not= t @seen) ; skip churn while paused/idle
(reset! seen t)
(rf/dispatch [::events/set-playhead (* t fps)]))))
(reset! raf (js/requestAnimationFrame tick)))]
(r/create-class
{:display-name "video-monitor"
:component-did-mount (fn [_] (tick))
:component-will-unmount (fn [_] (some-> @raf js/cancelAnimationFrame) (reset! video-el nil))
:reagent-render
(fn [_fps]
[:video {:src media-src
:controls true
:preload "auto"
:ref capture!
;; Keep playback inline on iOS instead of jumping to fullscreen.
:plays-inline true
:webkit-playsinline "true"
:on-play (fn [_] (reset! following? true)
(rf/dispatch [::events/set-playing true]))
:on-pause (fn [_] (rf/dispatch [::events/set-playing false]))}])})))
;; --- commentary sidebar ---------------------------------------------------
@ -353,9 +368,9 @@
[content zoom fps off end ev]
(.preventDefault ev)
(let [to (fn [clientX]
(let [f (ev->frames content clientX zoom fps off end)]
(rf/dispatch [::events/set-playhead f]) ; move the red line now
(seek-to-frame! fps f))) ; and seek the video
;; just seek; the video clock reports back into :playhead (rAF),
;; so the red line and the picture can never disagree
(seek-to-frame! fps (ev->frames content clientX zoom fps off end)))
move (fn [e] (to (.-clientX e)))
up (fn up [_]
(.removeEventListener js/document "mousemove" move)