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
This commit is contained in:
parent
f2f7d64759
commit
7fdc67c613
@ -615,11 +615,40 @@ class StateMachine:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _on_content_arrived(self, event: ContentArrived) -> list[Effect]:
|
def _on_content_arrived(self, event: ContentArrived) -> list[Effect]:
|
||||||
# Real implementation: routes to LoadImage or LoadVideo,
|
"""Route the new content by media kind.
|
||||||
# transitions to DisplayingImage / LoadingVideo, emits
|
|
||||||
# FitWindowToContent. First-time path consumes saved_geo;
|
Snapshot the content into `current_*` fields regardless of
|
||||||
# subsequent paths use persistent viewport. Lands in commits
|
kind so the rest of the state machine can read them. Then
|
||||||
# 4 (video) + 10 (image).
|
transition to LoadingVideo (video) or DisplayingImage (image,
|
||||||
|
commit 10) and emit the appropriate load + fit effects.
|
||||||
|
|
||||||
|
The first-content-load one-shot consumes `saved_geo` to seed
|
||||||
|
the viewport before the first fit (commit 8 wires the actual
|
||||||
|
seeding). After this commit, every ContentArrived flips
|
||||||
|
`is_first_content_load` to False — the saved_geo path runs at
|
||||||
|
most once per popout open.
|
||||||
|
"""
|
||||||
|
self.current_path = event.path
|
||||||
|
self.current_info = event.info
|
||||||
|
self.current_kind = event.kind
|
||||||
|
self.current_width = event.width
|
||||||
|
self.current_height = event.height
|
||||||
|
|
||||||
|
if event.kind == MediaKind.VIDEO:
|
||||||
|
self.is_first_content_load = False
|
||||||
|
self.state = State.LOADING_VIDEO
|
||||||
|
return [
|
||||||
|
LoadVideo(
|
||||||
|
path=event.path,
|
||||||
|
info=event.info,
|
||||||
|
referer=event.referer,
|
||||||
|
),
|
||||||
|
FitWindowToContent(
|
||||||
|
content_w=event.width,
|
||||||
|
content_h=event.height,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
# Image / GIF lands in commit 10 (DisplayingImage transitions).
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _on_navigate_requested(self, event: NavigateRequested) -> list[Effect]:
|
def _on_navigate_requested(self, event: NavigateRequested) -> list[Effect]:
|
||||||
@ -628,20 +657,65 @@ class StateMachine:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _on_video_started(self, event: VideoStarted) -> list[Effect]:
|
def _on_video_started(self, event: VideoStarted) -> list[Effect]:
|
||||||
# Real implementation: LoadingVideo → PlayingVideo, emits
|
"""LoadingVideo → PlayingVideo. Persistence effects fire here.
|
||||||
# ApplyMute / ApplyVolume / ApplyLoopMode. Lands in commit 4.
|
|
||||||
return []
|
The state machine pushes its persistent values (mute, volume,
|
||||||
|
loop_mode) into mpv on the entry edge. The mute value is the
|
||||||
|
critical one — it survives lazy mpv creation by being held on
|
||||||
|
the state machine instead of mpv (replaces the
|
||||||
|
VideoPlayer._pending_mute pattern at the popout layer).
|
||||||
|
|
||||||
|
Only valid in LoadingVideo. PlayingVideo→PlayingVideo would
|
||||||
|
be illegal (no entry edge to fire on); SeekingVideo→PlayingVideo
|
||||||
|
is the SeekCompleted path, not VideoStarted.
|
||||||
|
"""
|
||||||
|
if self.state != State.LOADING_VIDEO:
|
||||||
|
return []
|
||||||
|
self.state = State.PLAYING_VIDEO
|
||||||
|
return [
|
||||||
|
ApplyMute(value=self.mute),
|
||||||
|
ApplyVolume(value=self.volume),
|
||||||
|
ApplyLoopMode(value=self.loop_mode.value),
|
||||||
|
]
|
||||||
|
|
||||||
def _on_video_eof_reached(self, event: VideoEofReached) -> list[Effect]:
|
def _on_video_eof_reached(self, event: VideoEofReached) -> list[Effect]:
|
||||||
# Real implementation: only valid in PlayingVideo. Loop=Next
|
"""**EOF race fix (replaces fda3b10b's 250ms timestamp window).**
|
||||||
# emits EmitPlayNextRequested. Loop=Once emits TogglePlay (to
|
|
||||||
# pause). Loop=Loop is a no-op (mpv handles it). Other states
|
Only valid input in PlayingVideo. In every other state — most
|
||||||
# drop. Lands in commit 4 — this is the EOF race fix.
|
importantly LoadingVideo, where the stale-eof race lived —
|
||||||
|
the event is dropped without changing state or emitting
|
||||||
|
effects. This is the structural fix: the previous fix used
|
||||||
|
a wall-clock window to suppress eof events arriving within
|
||||||
|
250ms of `play_file`; the state machine subsumes that by
|
||||||
|
only accepting eof when we're actually in PlayingVideo.
|
||||||
|
|
||||||
|
In PlayingVideo:
|
||||||
|
- Loop=Next: emit EmitPlayNextRequested so main_window
|
||||||
|
advances to the next post.
|
||||||
|
- Loop=Once: emit nothing — mpv with keep_open=yes naturally
|
||||||
|
pauses at the end of the file. No state transition; the
|
||||||
|
user can manually click Play to restart.
|
||||||
|
- Loop=Loop: emit nothing — mpv's loop-file=inf handles
|
||||||
|
the restart internally.
|
||||||
|
"""
|
||||||
|
if self.state != State.PLAYING_VIDEO:
|
||||||
|
return []
|
||||||
|
if self.loop_mode == LoopMode.NEXT:
|
||||||
|
return [EmitPlayNextRequested()]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _on_video_size_known(self, event: VideoSizeKnown) -> list[Effect]:
|
def _on_video_size_known(self, event: VideoSizeKnown) -> list[Effect]:
|
||||||
# Real implementation: emits FitWindowToContent. Lands in
|
"""mpv reported new dimensions — refit the popout window.
|
||||||
# commits 4 + 8.
|
|
||||||
|
Valid in LoadingVideo (first frame) and PlayingVideo
|
||||||
|
(mid-playback aspect change, rare but possible with
|
||||||
|
anamorphic sources). Other states drop.
|
||||||
|
"""
|
||||||
|
if self.state in (State.LOADING_VIDEO, State.PLAYING_VIDEO):
|
||||||
|
return [FitWindowToContent(
|
||||||
|
content_w=event.width,
|
||||||
|
content_h=event.height,
|
||||||
|
)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _on_seek_requested(self, event: SeekRequested) -> list[Effect]:
|
def _on_seek_requested(self, event: SeekRequested) -> list[Effect]:
|
||||||
@ -674,8 +748,11 @@ class StateMachine:
|
|||||||
def _on_toggle_play_requested(
|
def _on_toggle_play_requested(
|
||||||
self, event: TogglePlayRequested
|
self, event: TogglePlayRequested
|
||||||
) -> list[Effect]:
|
) -> list[Effect]:
|
||||||
# Real implementation: only valid in PlayingVideo. Emits
|
"""Space key / play button. Only valid in PlayingVideo —
|
||||||
# TogglePlay. Lands in commit 4.
|
toggling play during a load or seek would race with mpv's
|
||||||
|
own state machine and produce undefined behavior."""
|
||||||
|
if self.state == State.PLAYING_VIDEO:
|
||||||
|
return [TogglePlay()]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _on_fullscreen_toggled(self, event: FullscreenToggled) -> list[Effect]:
|
def _on_fullscreen_toggled(self, event: FullscreenToggled) -> list[Effect]:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user