NavigateRequested in any media state (DisplayingImage / LoadingVideo /
PlayingVideo / SeekingVideo) transitions to AwaitingContent and emits
[StopMedia, EmitNavigate]. NavigateRequested in AwaitingContent itself
(rapid Right-arrow spam, second nav before main_window has delivered
the next post) emits EmitNavigate alone — no StopMedia, because
there's nothing to stop.
This is the structural fix for the double-load race that 31d02d3c
fixed upstream by removing the explicit _on_post_activated call after
_grid._select. The popout-layer fix is independent and stronger: even
if upstream signal chains misfire, the state machine never produces
two Load effects for the same navigation cycle. The state machine's
LoadVideo / LoadImage effects only fire from ContentArrived, and
ContentArrived is delivered exactly once per main_window-side post
activation.
The Open event handler also lands here. Stashes saved_geo,
saved_fullscreen, monitor on the state machine instance for the
first ContentArrived to consume. The actual viewport seeding from
saved_geo lives in commit 8 — this commit just stores the inputs.
Tests passing after this commit (62 total → 42 pass, 20 fail):
- test_awaiting_open_stashes_saved_geo
- test_awaiting_navigate_emits_navigate_only
- test_displaying_image_navigate_stops_and_emits
- test_loading_video_navigate_stops_and_emits
- test_playing_video_navigate_stops_and_emits
- test_seeking_video_navigate_stops_and_emits
- test_invariant_double_navigate_no_double_load (RACE FIX!)
Plus several illegal-transition cases for nav-from-now-valid-states.
Phase A (16 tests in tests/core/) still green.
Tests still failing (20, scheduled for commits 6-11):
- SeekingVideo entry/exit (commit 6)
- 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 6 (SeekingVideo + slider pin):
- PlayingVideo + SeekRequested → SeekingVideo + SeekVideoTo effect
- SeekingVideo + SeekRequested replaces seek_target_ms
- SeekingVideo + SeekCompleted → PlayingVideo
- test_invariant_seek_pin_uses_compute_slider_display_ms
First batch of real transitions. The EOF race fix is the headline —
this commit replaces fda3b10b's 250ms _eof_ignore_until timestamp
window with a structural transition that drops VideoEofReached in
every state except PlayingVideo.
Transitions implemented:
ContentArrived(VIDEO) in any state →
LoadingVideo, [LoadVideo, FitWindowToContent]
Snapshots current_path/info/kind/width/height. Flips
is_first_content_load to False (the saved_geo seeding lands in
commit 8). Image and GIF kinds are still stubbed — they get
DisplayingImage in commit 10.
LoadingVideo + VideoStarted →
PlayingVideo, [ApplyMute, ApplyVolume, ApplyLoopMode]
The persistence effects fire on PlayingVideo entry, pushing the
state machine's persistent values into mpv. This is the
structural replacement for VideoPlayer._pending_mute's lazy-mpv
replay (the popout layer now owns mute as truth; VideoPlayer's
internal _pending_mute stays as defense in depth, untouched).
PlayingVideo + VideoEofReached →
Loop=NEXT: [EmitPlayNextRequested]
Loop=ONCE: [] (mpv keep_open=yes pauses naturally)
Loop=LOOP: [] (mpv loop-file=inf handles internally)
*Anything* + VideoEofReached (not in PlayingVideo) →
[], state unchanged
**THIS IS THE EOF RACE FIX.** The fda3b10b commit added a 250ms
timestamp window inside VideoPlayer to suppress eof events
arriving from a previous file's stop. The state machine subsumes
that by only accepting eof in PlayingVideo. In LoadingVideo
(where the race lives), VideoEofReached is structurally invalid
and gets dropped at the dispatch boundary. No window. No
timestamp. No race.
LoadingVideo / PlayingVideo + VideoSizeKnown →
[FitWindowToContent(w, h)]
mpv reports new dimensions; refit. Same effect for both states
because the only difference is "did the user see a frame yet"
(which doesn't matter for window sizing).
PlayingVideo + TogglePlayRequested →
[TogglePlay]
Space key / play button. Only valid in PlayingVideo — toggling
play during a load or seek would race with mpv's own state
machine.
Tests passing after this commit (62 total → 35 pass, 27 fail):
- test_loading_video_started_transitions_to_playing
- test_loading_video_eof_dropped (RACE FIX!)
- test_loading_video_size_known_emits_fit
- test_playing_video_eof_loop_next_emits_play_next
- test_playing_video_eof_loop_once_pauses
- test_playing_video_eof_loop_loop_no_op
- test_playing_video_size_known_refits
- test_playing_video_toggle_play_emits_toggle
- test_invariant_eof_race_loading_video_drops_stale_eof (RACE FIX!)
- test_awaiting_content_arrived_video_transitions_to_loading
- test_awaiting_content_arrived_video_emits_persistence_effects
Plus several illegal-transition cases for the (LoadingVideo, *)
events that this commit makes meaningfully invalid.
Phase A (16 tests in tests/core/) still green.
Tests still failing (27, scheduled for commits 5-11):
- Open / NavigateRequested handlers (commit 5)
- DisplayingImage transitions (commit 10)
- SeekingVideo transitions (commit 6)
- Closing transitions (commit 10)
- Persistent viewport / drift events (commit 8)
- mute/volume/loop persistence events (commit 9)
- F11 round-trip (commit 7)
Test cases for commit 5 (Navigating + AwaitingContent + double-load):
- dispatch NavigateRequested in PlayingVideo → AwaitingContent
- second NavigateRequested in AwaitingContent doesn't re-stop
- test_invariant_double_navigate_no_double_load
Lays down the data shapes for the popout state machine ahead of any
transition logic. Pure Python — does not import PySide6, mpv, httpx,
subprocess, or any module that does. The Phase B test suite (commit 3)
will exercise this purity by importing it directly without standing
up a QApplication; the test suite is the forcing function that keeps
the file pure as transitions land in commits 4-11.
Module structure follows docs/POPOUT_ARCHITECTURE.md exactly.
States (6, target ≤10):
AwaitingContent — popout exists, no current media (initial OR mid-nav)
DisplayingImage — static image or GIF on screen
LoadingVideo — set_media called for video, awaiting first frame
PlayingVideo — video active (paused or playing)
SeekingVideo — user-initiated seek pending
Closing — closeEvent received, terminal
Events (17, target ≤20):
Open / ContentArrived / NavigateRequested
VideoStarted / VideoEofReached / VideoSizeKnown
SeekRequested / SeekCompleted
MuteToggleRequested / VolumeSet / LoopModeSet / TogglePlayRequested
FullscreenToggled
WindowMoved / WindowResized / HyprlandDriftDetected
CloseRequested
Effects (14, target ≤15):
LoadImage / LoadVideo / StopMedia
ApplyMute / ApplyVolume / ApplyLoopMode
SeekVideoTo / TogglePlay
FitWindowToContent / EnterFullscreen / ExitFullscreen
EmitNavigate / EmitPlayNextRequested / EmitClosed
Frozen dataclasses for events and effects, Enum for State / MediaKind /
LoopMode. Dispatch uses Python 3.10+ structural pattern matching to
route by event type.
StateMachine fields cover the full inventory:
- Lifecycle: state, is_first_content_load
- Persistent (orthogonal): fullscreen, mute, volume, loop_mode
- Geometry: viewport, pre_fullscreen_viewport, last_dispatched_rect
- Seek: seek_target_ms
- Content snapshot: current_path, current_info, current_kind,
current_width, current_height
- Open-event payload: saved_geo, saved_fullscreen, monitor
- Nav: grid_cols
Read-path query implemented even at the skeleton stage:
compute_slider_display_ms(mpv_pos_ms) returns seek_target_ms while
in SeekingVideo, mpv_pos_ms otherwise. This is the structural
replacement for the 500ms _seek_pending_until timestamp window —
no timestamp, just the SeekingVideo state.
Every per-event handler is a stub that returns []. Real transitions
land in commits 4-11 (priority order: PlayingVideo + LoadingVideo +
EOF race fix → Navigating + AwaitingContent + double-load fix →
SeekingVideo + slider pin → Fullscreen + F11 → viewport + drift →
mute/volume/loop persistence → DisplayingImage + Closing → illegal
transition handler).
Closing is treated as terminal at the dispatch entry — once we're
there, every event returns [] regardless of type. Same property the
current closeEvent has implicitly.
Verification:
- Phase A test suite (16 tests) still passes
- state.py imports cleanly with PySide6/mpv/httpx blocked at the
meta_path level (purity gate)
- StateMachine() constructs with all fields initialized to sensible
defaults
- Stub dispatch returns [] for every event type
- 6 states / 17 events / 14 effects all under budget (≤10/≤20/≤15)
Test cases for state machine tests (Prompt 3 commit 3):
- Construct StateMachine, assert initial state == AwaitingContent
- Assert is_first_content_load is True at construction
- Assert all stub dispatches return []
- Assert compute_slider_display_ms returns mpv_pos when not seeking