5 Commits

Author SHA1 Message Date
pax
609066cf87 VideoPlayer: use absolute+exact for slider seek (fix drag-back race)
The slider's _seek used plain 'absolute' (keyframe seek), which made
mpv land on the nearest keyframe at-or-before the click position.
For sparse-keyframe videos (1-5s GOP) the actual position landed
1-5s behind where the user clicked. The 500ms _seek_pending_until
pin window from c4061b0 papered over this for half a second, but
afterwards the slider visibly dragged back to mpv's keyframe-rounded
position and crawled forward. Observed in BOTH the embedded preview
and the popout slider — the bug lives in the shared VideoPlayer
class, so fixing it once fixes both surfaces.

Empirically verified by the pre-commit-1 mpv probe in
docs/POPOUT_REFACTOR_PLAN.md: seek(7.0, 'absolute') landed at
time_pos=5.000 (2s back); seek(12.0, 'absolute') landed at 10.033
(also 2s back). Both match the user's reported drag-back symptom.

The other seek paths in VideoPlayer already use exact mode:
  - seek_to_ms (line 318): 'absolute+exact'
  - _seek_relative (line 430): 'relative+exact'

The slider's _seek was the only outlier. The original c4061b0 commit
chose plain 'absolute' for "responsiveness" and added the pin window
to hide the keyframe rounding. This commit removes the underlying
cause: the seek now decodes from the previous keyframe forward to
the EXACT target position before mpv emits playback-restart, costing
~30-100ms more per seek depending on GOP density (well under the
500ms pin window) but landing time_pos at the click position
exactly. The slider doesn't need any pin window to mask a
discrepancy that no longer exists.

The _seek_pending_until pin remains in place as defense in depth —
it's now redundant for keyframe rounding but still smooths over the
sub-100ms decode latency between the click and the first _poll
tick that reads the new time_pos. Commit 14b will remove the legacy
pin code as part of the imperative-path cleanup.

This also unblocks the popout state machine's design (commits 6, 11,
14a). The state machine's SeekingVideo state lasts until mpv's
playback-restart event arrives — empirically 14-34ms in the user's
verification log of commit 14a. Without exact seek, commit 14b
would visibly REGRESS slider behavior because compute_slider_display_ms
returns mpv.time_pos after 30ms instead of the legacy 500ms — the
drag-back would surface immediately on every seek. With exact seek,
mpv.time_pos == seek_target_ms after the seek completes, so the
state machine's slider pin is correct without needing any extra
window.

Found during commit 14a verification gate. Pre-existing bug — the
state machine refactor revealed it but didn't introduce it.

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green. Phase B regression target met (the dispatch
trace from the verification run shows correct SeekRequested →
SeekCompleted round-trips with no spurious state transitions).

Verification:
- Click slider mid-playback in embedded preview → no drag-back
- Click slider mid-playback in popout → no drag-back
- Drag the slider continuously → still works (isSliderDown path
  unchanged)
- Period/Comma keys (relative seek) → still work (already use
  'relative+exact')
2026-04-08 20:09:49 -05:00
pax
9cba7d5583 VideoPlayer: add playback_restart Signal for state machine adapter
Adds a Qt Signal that mirrors mpv's `playback-restart` event. The
upcoming popout state machine refactor (Prompt 3) needs a clean,
event-driven "seek/load completed" edge to drive its SeekingVideo →
PlayingVideo and LoadingVideo → PlayingVideo transitions, replacing
the current 500ms timestamp suppression window in `_seek_pending_until`.

mpv's `playback-restart` fires once after each loadfile (when playback
actually starts producing frames) and once after each completed seek.
Empirically verified by the pre-commit-1 probe in
docs/POPOUT_REFACTOR_PLAN.md: a load + 2 seeks produces exactly 3
events, with `seeking=False` at every event (the event represents the
completion edge, not the in-progress state).

The state machine adapter will distinguish "load done" from "seek done"
by checking the state machine's current state at dispatch time:
- `playback-restart` while in LoadingVideo → VideoStarted event
- `playback-restart` while in SeekingVideo → SeekCompleted event

Implementation:

- One Signal added near the existing play_next / media_ready /
  video_size definitions, with a doc comment explaining what fires
  it and which state machine consumes it.
- One event_callback registration in `_ensure_mpv` (alongside the
  existing observe_property calls). The callback runs on mpv's
  event thread; emitting a Qt Signal is thread-safe and the
  receiving slot runs on the GUI thread via Qt's default
  AutoConnection (sender and receiver in the same thread by the
  time the popout adapter wires the connection).
- The decorator-based `@self._mpv.event_callback(...)` form is used
  to match the rest of the python-mpv idioms in the file. The inner
  function name `_emit_playback_restart` is local-scoped — mpv keeps
  its own reference, so there's no leak from re-creation across
  popout open/close cycles (each popout gets a fresh VideoPlayer
  with its own _ensure_mpv call).

This is the only commit in the popout state machine refactor that
touches `media/video_player.py`. All subsequent commits land in
`popout/` (state.py, effects.py, hyprland.py, window.py) or
`gui/main_window.py` interface updates. 21 lines added, 0 removed.

Verification:
- Phase A test suite (16 tests) still passes
- Module imports cleanly with the new Signal in place
- App launches without errors (smoke test)

Test cases for state machine adapter (Prompt 3 popout/state.py):
- VideoPlayer.playback_restart fires once on play_file completion
- VideoPlayer.playback_restart fires once per _seek call
2026-04-08 19:17:03 -05:00
pax
0a6818260e VideoPlayer: preserve mute state across lazy mpv creation
The popout's VideoPlayer is constructed with no mpv attached — mpv
gets wired up in _ensure_mpv on the first set_media call. main_window's
_open_fullscreen_preview syncs preview→popout state right after the
popout is constructed, so it writes is_muted *before* mpv exists. The
old setter only forwarded to mpv if mpv was set:

  @is_muted.setter
  def is_muted(self, val: bool) -> None:
      if self._mpv:
          self._mpv.mute = val
      self._mute_btn.setText("Unmute" if val else "Mute")

For the popout's pre-mpv VideoPlayer this updated the button text but
silently dropped the value. _ensure_mpv then created the mpv instance
later with default mute=False, so the popout always opened unmuted
even when the embedded preview was muted (or when a previous popout
session had muted and then closed).

Fix: introduce a Python-side _pending_mute field that survives the
lazy mpv creation. The setter writes to _pending_mute unconditionally
and forwards to mpv if it exists. The getter returns _mpv.mute when
mpv is set, otherwise _pending_mute. _ensure_mpv replays _pending_mute
into the freshly-created mpv instance after applying the volume from
the slider, mirroring the existing volume-from-slider replay pattern
that already worked because the slider widget exists from construction
and acts as the volume's persistent storage.

Also threaded _pending_mute through _toggle_mute so the click-driven
toggle path stays consistent with the setter path — without it, a
mute toggle inside the popout would update mpv but not _pending_mute,
and the next sync round-trip via the setter would clobber it.

Verified manually:
  - popout video, click mute, close popout, reopen on same video →
    mute persisted (button shows "Unmute", audio silent)
  - toggle to unmute, close, reopen → unmuted persisted
  - embedded preview video mute → close popout → state propagates
    correctly via _on_fullscreen_closed's reverse sync
2026-04-08 16:55:16 -05:00
pax
c4061b0d20 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).
2026-04-08 16:14:45 -05:00
pax
fa2d31243c Move _ClickSeekSlider + VideoPlayer from preview.py to media/video_player.py (no behavior change)
Step 5 of the gui/app.py + gui/preview.py structural refactor. Moves
the click-to-seek QSlider variant and the mpv-backed transport-control
widget into their own module under media/. The new module imports
_MpvGLWidget from .mpv_gl (sibling) instead of relying on the bare
name in the old preview.py namespace.

Address-only adjustment: the lazy `from ..core.cache import _referer_for`
inside `play_file` becomes `from ...core.cache import _referer_for`
because the new module sits one package level deeper. Same target
module, different relative-import depth — no behavior change.

preview.py grows another re-export shim line so ImagePreview (still
in preview.py) and FullscreenPreview can keep constructing
VideoPlayer unchanged. Shim removed in commit 14.
2026-04-08 14:07:17 -05:00