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
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
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).
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.