3 Commits

Author SHA1 Message Date
pax
a9ce01e6c1 popout/state: implement Navigating + AwaitingContent + double-load fix
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
2026-04-08 19:33:17 -05:00
pax
7fdc67c613 popout/state: implement PlayingVideo + LoadingVideo + EOF race fix
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
2026-04-08 19:32:04 -05:00
pax
39816144fe popout/state: skeleton (6 states, 17 events, 14 effects, no transitions)
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
2026-04-08 19:22:06 -05:00