VideoPlayer: pin seek slider to user target during seek race window

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).
This commit is contained in:
pax 2026-04-08 16:14:45 -05:00
parent 9455ff0f03
commit c4061b0d20

View File

@ -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,6 +449,18 @@ class VideoPlayer(QWidget):
if pos is not None:
pos_ms = int(pos * 1000)
if not self._seek_slider.isSliderDown():
# 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))