From c4061b0d20328ae049fb95753fa64e966a0a26e3 Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 16:14:45 -0500 Subject: [PATCH] VideoPlayer: pin seek slider to user target during seek race window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seek slider snapped visually backward after a click for the first ~tens to hundreds of ms — long enough to be obvious. Race trace: user clicks slider at target T → _ClickSeekSlider.mousePressEvent fires → setValue(T) lands the visual at the click position → clicked_position emits → _seek dispatches mpv.seek(T) async → mpv processes the seek on its own thread meanwhile the 100ms _poll timer keeps firing → reads mpv.time_pos (still the OLD position, mpv hasn't caught up) → calls self._seek_slider.setValue(pos_ms) → slider visually snaps backward to the pre-seek position → mpv finishes seeking, next poll tick writes the new position → slider jumps forward to settle near T The isSliderDown() guard at the existing setValue site (around line 425) only suppresses writebacks during a *drag* — fast clicks never trigger isSliderDown, so the guard didn't help here. Fix: pin the slider to the user's target throughout a 500ms post-seek window. Mirror the existing _eof_ignore_until pattern (stale-eof suppression in play_file → _on_eof_reached) — it's the same shape: "after this dispatch, ignore poll-driven writebacks for N ms because mpv hasn't caught up yet." - _seek now records _seek_target_ms and arms _seek_pending_until - _poll forces _seek_slider.setValue(_seek_target_ms) on every tick inside the window, instead of mpv's lagging time_pos - After the window expires, normal mpv-driven writes resume Pin window is 500ms (vs the eof window's 250ms) because network and streaming seeks take noticeably longer than local-cache seeks. Inside the window the slider is forced to the target every tick, so mpv lag is invisible no matter how long it takes within the window. First attempt used a smaller 250ms window with a "close enough" early-release condition (release suppression once mpv reports a position within 250ms of the target). That still showed minor track-back because the "close enough" threshold permitted writing back a position slightly less than the target, producing a small visible jump. The pin-to-target approach is robust against any mpv interim position. The time_label keeps updating to mpv's actual position throughout — only the slider value is pinned, so the user can still see the seek progressing in the time text. Verified manually: clicks at start / middle / end of a video slider all hold position cleanly. Drag still works (the isSliderDown path is untouched). Normal playback advances smoothly (the pin window only affects the post-seek window, not steady-state playback). --- booru_viewer/gui/media/video_player.py | 40 +++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/booru_viewer/gui/media/video_player.py b/booru_viewer/gui/media/video_player.py index f04cad3..419896f 100644 --- a/booru_viewer/gui/media/video_player.py +++ b/booru_viewer/gui/media/video_player.py @@ -190,6 +190,29 @@ class VideoPlayer(QWidget): self._eof_ignore_until: float = 0.0 self._eof_ignore_window_secs: float = 0.25 + # Seek-pending pin window — covers the user-clicked-the-seek-slider + # race. The flow: user clicks slider → _ClickSeekSlider.setValue + # (visual jumps to click position) → clicked_position emits → + # _seek dispatches mpv.seek (async on mpv's thread). Meanwhile + # the 100ms _poll tick reads mpv.time_pos (still the OLD + # position) and writes it back to the slider via setValue, + # snapping the slider visually backward until mpv catches up. + # The isSliderDown guard at line ~425 only suppresses the + # writeback during a drag — fast clicks don't trigger + # isSliderDown. Defence: instead of just *suppressing* the + # writeback during the window, we *pin* the slider to the user's + # target position on every poll tick that lands inside the + # window. That way mpv's lag — however long it is up to the + # window length — is invisible. After the window expires, + # normal mpv-driven slider updates resume. + # + # Window is wider than the eof one (500ms vs 250ms) because + # network/streaming seeks can take longer than the local-cache + # seeks the eof window was sized for. + self._seek_pending_until: float = 0.0 + self._seek_target_ms: int = 0 + self._seek_pin_window_secs: float = 0.5 + # Polling timer for position/duration/pause/eof state self._poll_timer = QTimer(self) self._poll_timer.setInterval(100) @@ -366,6 +389,9 @@ class VideoPlayer(QWidget): def _seek(self, pos: int) -> None: """Seek to position in milliseconds (from slider).""" if self._mpv: + import time as _time + self._seek_target_ms = pos + self._seek_pending_until = _time.monotonic() + self._seek_pin_window_secs self._mpv.seek(pos / 1000.0, 'absolute') def _seek_relative(self, ms: int) -> None: @@ -423,7 +449,19 @@ class VideoPlayer(QWidget): if pos is not None: pos_ms = int(pos * 1000) if not self._seek_slider.isSliderDown(): - self._seek_slider.setValue(pos_ms) + # Pin the slider to the user's target while a seek is in + # flight — mpv processes seek async, and any poll tick + # that fires before mpv reports the new position would + # otherwise snap the slider backward. Force the value to + # the target every tick inside the pin window; after the + # window expires, mpv has caught up and normal writes + # resume. See __init__'s `_seek_pin_window_secs` block + # for the race trace. + import time as _time + if _time.monotonic() < self._seek_pending_until: + self._seek_slider.setValue(self._seek_target_ms) + else: + self._seek_slider.setValue(pos_ms) self._time_label.setText(self._fmt(pos_ms)) # Duration (from observer)