feat: prev/next frame buttons flanking play

Transport row is now |◀ ▶/⏸ ▶|. step-frame! pauses, rounds the video
clock to the nearest frame and seeks ±1; the rAF clock reports the new
position back into :playhead, so the red line follows (same single
seek-only path as jump/scrub).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-27 17:19:02 -04:00
parent ea78a9ee08
commit ec236e93cb
2 changed files with 21 additions and 5 deletions

View file

@ -199,11 +199,14 @@ body { overflow: hidden; }
flex-shrink: 0; } flex-shrink: 0; }
.slider { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #9ab; } .slider { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #9ab; }
.slider input { width: 150px; touch-action: none; } .slider input { width: 150px; touch-action: none; }
.play-btn { .transport { display: flex; align-items: center; gap: 4px; }
.play-btn, .step-btn {
background: #1c2a38; color: #cfe3f5; border: 1px solid #3a6ea5; background: #1c2a38; color: #cfe3f5; border: 1px solid #3a6ea5;
border-radius: 4px; padding: 2px 12px; cursor: pointer; font-size: 14px; line-height: 1.4; border-radius: 4px; cursor: pointer; line-height: 1.4;
} }
.play-btn:hover { background: #24384e; } .play-btn { padding: 2px 12px; font-size: 14px; }
.step-btn { padding: 2px 7px; font-size: 11px; letter-spacing: -1px; }
.play-btn:hover, .step-btn:hover { background: #24384e; }
.timeline-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; } .timeline-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.timeline { display: flex; align-items: flex-start; background: #111; } .timeline { display: flex; align-items: flex-start; background: #111; }

View file

@ -116,6 +116,15 @@
(when-let [v @video-el] (when-let [v @video-el]
(if (.-paused v) (.play v) (.pause v)))) ; play/pause events drive :playing? + rAF (if (.-paused v) (.play v) (.pause v)))) ; play/pause events drive :playing? + rAF
(defn- step-frame!
"Pause and nudge the playhead one frame in `dir` (-1 / +1). Seeking is the
only move — the rAF clock reports the new position back into :playhead."
[fps dir]
(when-let [v @video-el]
(when-not (.-paused v) (.pause v))
(let [frame (js/Math.round (* (.-currentTime v) fps))]
(set! (.-currentTime v) (secs (max 0 (+ frame dir)) fps)))))
(defn- editable-target? (defn- editable-target?
"True for fields where Space should type a space, not toggle play." "True for fields where Space should type a space, not toggle play."
[el] [el]
@ -486,10 +495,14 @@
(defn zoom-controls [] (defn zoom-controls []
(let [zoom @(rf/subscribe [::subs/zoom]) (let [zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h]) row-h @(rf/subscribe [::subs/row-h])
fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001))
playing? @(rf/subscribe [::subs/playing?])] playing? @(rf/subscribe [::subs/playing?])]
[:div.toolbar [:div.toolbar
[:div.transport
[:button.step-btn {:on-click #(step-frame! fps -1) :title "Previous frame"} "|◀"]
[:button.play-btn {:on-click toggle-play! :title "Play / pause (Space)"} [:button.play-btn {:on-click toggle-play! :title "Play / pause (Space)"}
(if playing? "⏸" "▶")] (if playing? "⏸" "▶")]
[:button.step-btn {:on-click #(step-frame! fps 1) :title "Next frame"} "▶|"]]
[slider "Zoom X" zoom 2 400 zoom-x!] [slider "Zoom X" zoom 2 400 zoom-x!]
[slider "Zoom Y" row-h 8 120 #(rf/dispatch [::events/set-row-h %])]])) [slider "Zoom Y" row-h 8 120 #(rf/dispatch [::events/set-row-h %])]]))