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
This commit is contained in:
pax 2026-04-08 19:33:17 -05:00
parent 7fdc67c613
commit a9ce01e6c1

View File

@ -609,9 +609,22 @@ class StateMachine:
# ------------------------------------------------------------------
def _on_open(self, event: Open) -> list[Effect]:
# Real implementation: stash saved_geo / saved_fullscreen /
# monitor on self for the first ContentArrived to consume.
# Lands in commit 5.
"""Initial popout-open event from the adapter.
Stashes the cross-popout-session class-level state
(`_saved_geometry`, `_saved_fullscreen`, the chosen monitor)
on the state machine instance for the first ContentArrived
handler to consume. After Open the machine is still in
AwaitingContent the actual viewport seeding from saved_geo
happens inside the first ContentArrived (commit 8 wires the
actual viewport math; this commit just stashes the inputs).
No effects: the popout window is already constructed and
showing. The first content load triggers the first fit.
"""
self.saved_geo = event.saved_geo
self.saved_fullscreen = event.saved_fullscreen
self.monitor = event.monitor
return []
def _on_content_arrived(self, event: ContentArrived) -> list[Effect]:
@ -652,9 +665,34 @@ class StateMachine:
return []
def _on_navigate_requested(self, event: NavigateRequested) -> list[Effect]:
# Real implementation: emits StopMedia + EmitNavigate,
# transitions to AwaitingContent. Lands in commit 5.
return []
"""**Double-load race fix (replaces 31d02d3c's upstream signal-
chain trust fix at the popout layer).**
From a media-bearing state (DisplayingImage / LoadingVideo /
PlayingVideo / SeekingVideo): transition to AwaitingContent
and emit `[StopMedia, EmitNavigate]`. The StopMedia clears the
current surface so mpv doesn't keep playing the previous video
during the async download wait. The EmitNavigate tells
main_window to advance selection and eventually deliver the
new content via ContentArrived.
From AwaitingContent itself (rapid Right-arrow spam, second
nav before main_window has delivered): emit EmitNavigate
ALONE no StopMedia, because there's nothing to stop. The
state stays AwaitingContent. **The state machine never
produces two LoadVideo / LoadImage effects for the same
navigation cycle, no matter how many NavigateRequested events
the user fires off.** That structural property is what makes
the eof race impossible at the popout layer.
"""
if self.state == State.AWAITING_CONTENT:
return [EmitNavigate(direction=event.direction)]
# Media-bearing state: clear current media + emit nav
self.state = State.AWAITING_CONTENT
return [
StopMedia(),
EmitNavigate(direction=event.direction),
]
def _on_video_started(self, event: VideoStarted) -> list[Effect]:
"""LoadingVideo → PlayingVideo. Persistence effects fire here.