popout/state: implement SeekingVideo + slider pin

PlayingVideo + SeekRequested → SeekingVideo, stash target_ms, emit
SeekVideoTo. SeekingVideo + SeekRequested replaces the target (user
clicked again, latest seek wins). SeekingVideo + SeekCompleted →
PlayingVideo.

The slider pin behavior is the read-path query
`compute_slider_display_ms(mpv_pos_ms)` already implemented at the
skeleton stage: while in SeekingVideo, returns `seek_target_ms`;
otherwise returns `mpv_pos_ms`. The Qt-side adapter's poll timer
asks the state machine for the slider display value on every tick
and writes whatever it gets back to the slider widget.

**This replaces 96a0a9d's 500ms _seek_pending_until timestamp window
at the popout layer.** The state machine has no concept of wall-clock
time. The SeekingVideo state lasts exactly until mpv signals the seek
is done, via the playback_restart Signal added in commit 1. The
adapter distinguishes load-restart from seek-restart by checking
the state machine's current state (LoadingVideo → VideoStarted;
SeekingVideo → SeekCompleted).

The pre-commit-1 probe verified that mpv emits playback-restart
exactly once per load and exactly once per seek (3 events for 1
load + 2 seeks), so the dispatch routing is unambiguous.

VideoPlayer's internal _seek_pending_until field stays in place as
defense in depth — the state machine refactor's prompt explicitly
forbids touching media/video_player.py beyond the playback_restart
Signal addition. The popout layer no longer depends on it.

Tests passing after this commit (62 total → 46 pass, 16 fail):

  - test_playing_video_seek_requested_transitions_and_pins
  - test_seeking_video_completed_returns_to_playing
  - test_seeking_video_seek_requested_replaces_target
  - test_invariant_seek_pin_uses_compute_slider_display_ms (RACE FIX!)

Phase A (16 tests) still green.

Tests still failing (16, scheduled for commits 7-11):
  - F11 round-trip (commit 7)
  - Persistent viewport / drift events (commit 8)
  - mute/volume/loop persistence events (commit 9)
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)

Test cases for commit 7 (Fullscreen flag + F11 round-trip):
  - dispatch FullscreenToggled in any media state, assert flag flipped
  - F11 enter snapshots viewport into pre_fullscreen_viewport
  - F11 exit restores viewport from pre_fullscreen_viewport
This commit is contained in:
pax 2026-04-08 19:34:08 -05:00
parent a9ce01e6c1
commit 664d4e9cda

View File

@ -757,13 +757,51 @@ class StateMachine:
return [] return []
def _on_seek_requested(self, event: SeekRequested) -> list[Effect]: def _on_seek_requested(self, event: SeekRequested) -> list[Effect]:
# Real implementation: PlayingVideo → SeekingVideo, sets """**Slider pin replaces 96a0a9d's 500ms _seek_pending_until.**
# seek_target_ms, emits SeekVideoTo. Lands in commit 6.
Two valid source states:
- PlayingVideo: enter SeekingVideo, stash target_ms, emit
SeekVideoTo. The slider pin behavior is read-path:
`compute_slider_display_ms` returns `seek_target_ms`
while in SeekingVideo regardless of mpv's lagging or
keyframe-rounded `time_pos`.
- SeekingVideo: a second seek before the first one completed.
Replace the target the user clicked again, so the new
target is what they want pinned. Emit a fresh SeekVideoTo.
Stay in SeekingVideo. mpv handles back-to-back seeks fine;
its own playback-restart event for the latest seek is what
will eventually fire SeekCompleted.
SeekRequested in any other state (AwaitingContent /
DisplayingImage / LoadingVideo / Closing): drop. There's no
video to seek into.
No timestamp window. The state machine subsumes the 500ms
suppression by holding SeekingVideo until SeekCompleted
arrives (which is mpv's `playback-restart` after the seek,
wired in the adapter).
"""
if self.state in (State.PLAYING_VIDEO, State.SEEKING_VIDEO):
self.state = State.SEEKING_VIDEO
self.seek_target_ms = event.target_ms
return [SeekVideoTo(target_ms=event.target_ms)]
return [] return []
def _on_seek_completed(self, event: SeekCompleted) -> list[Effect]: def _on_seek_completed(self, event: SeekCompleted) -> list[Effect]:
# Real implementation: SeekingVideo → PlayingVideo. Lands in """SeekingVideo → PlayingVideo.
# commit 6.
Triggered by the adapter receiving mpv's `playback-restart`
event AND finding the state machine in SeekingVideo (the
adapter distinguishes load-restart from seek-restart by
checking current state see VideoStarted handler).
After this transition, `compute_slider_display_ms` returns
the actual mpv `time_pos` again instead of the pinned target.
"""
if self.state == State.SEEKING_VIDEO:
self.state = State.PLAYING_VIDEO
return [] return []
def _on_mute_toggle_requested( def _on_mute_toggle_requested(