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
829 lines
27 KiB
Python
829 lines
27 KiB
Python
"""Pure-Python state machine for the popout viewer.
|
|
|
|
This module is the source of truth for the popout's lifecycle. All
|
|
state transitions, all decisions about which effects to fire on which
|
|
events, and all of the persistent fields (`viewport`, `mute`, `volume`,
|
|
`seek_target_ms`, etc.) live here. The Qt-side adapter in
|
|
`popout/window.py` is responsible only for translating Qt events into
|
|
state machine events and applying the returned effects to widgets.
|
|
|
|
**Hard constraint**: this module MUST NOT import anything from PySide6,
|
|
mpv, httpx, subprocess, or any module that does. The state machine's
|
|
test suite imports it directly without standing up a `QApplication` —
|
|
if those imports fail, the tests fail to collect, and the test suite
|
|
becomes the forcing function that keeps this module pure.
|
|
|
|
The architecture, state diagram, invariant→transition mapping, and
|
|
event/effect lists are documented in `docs/POPOUT_ARCHITECTURE.md`.
|
|
This module's job is to be the executable form of that document.
|
|
|
|
This is the **commit 2 skeleton**: every state, every event type, every
|
|
effect type, and the `StateMachine` class with all fields initialized.
|
|
The `dispatch` method routes events to per-event handlers that all
|
|
currently return empty effect lists. Real transitions land in
|
|
commits 4-11 of `docs/POPOUT_REFACTOR_PLAN.md`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Optional, Union
|
|
|
|
from .viewport import Viewport
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# States
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class State(Enum):
|
|
"""The popout's discrete media-lifecycle states.
|
|
|
|
Six states, each with a clearly-defined set of valid input events
|
|
(see `_VALID_EVENTS_BY_STATE` below and the architecture doc's
|
|
transition table). Fullscreen, Privacy, Mute, Volume, LoopMode,
|
|
and Viewport are state FIELDS, not states — they're orthogonal to
|
|
the media lifecycle.
|
|
"""
|
|
|
|
AWAITING_CONTENT = "AwaitingContent"
|
|
DISPLAYING_IMAGE = "DisplayingImage"
|
|
LOADING_VIDEO = "LoadingVideo"
|
|
PLAYING_VIDEO = "PlayingVideo"
|
|
SEEKING_VIDEO = "SeekingVideo"
|
|
CLOSING = "Closing"
|
|
|
|
|
|
class MediaKind(Enum):
|
|
"""What kind of content the `ContentArrived` event is delivering."""
|
|
|
|
IMAGE = "image" # static image (jpg, png, webp)
|
|
GIF = "gif" # animated gif (or animated png/webp)
|
|
VIDEO = "video" # mp4, webm, mkv (mpv-backed)
|
|
|
|
|
|
class LoopMode(Enum):
|
|
"""The user's choice for end-of-video behavior.
|
|
|
|
Mirrors `VideoPlayer._loop_state` integer values verbatim so the
|
|
adapter can pass them through to mpv without translation:
|
|
- LOOP: mpv `loop-file=inf`, video repeats forever
|
|
- ONCE: mpv `loop-file=no`, video stops at end
|
|
- NEXT: mpv `loop-file=no`, popout advances to next post on EOF
|
|
"""
|
|
|
|
LOOP = 0
|
|
ONCE = 1
|
|
NEXT = 2
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Events
|
|
# ----------------------------------------------------------------------
|
|
#
|
|
# Events are frozen dataclasses so they're hashable, comparable, and
|
|
# immutable once dispatched. The dispatcher uses Python 3.10+ structural
|
|
# pattern matching (`match event:`) to route by event type.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Open:
|
|
"""Initial event dispatched once at popout construction.
|
|
|
|
The adapter reads `FullscreenPreview._saved_geometry` and
|
|
`_saved_fullscreen` (the class-level fields that survive across
|
|
popout open/close cycles within one process) and passes them in
|
|
here. The state machine stashes them as `state.saved_geo` and
|
|
`state.saved_fullscreen` and consults them on the first
|
|
`ContentArrived` to seed the viewport.
|
|
"""
|
|
|
|
saved_geo: Optional[tuple[int, int, int, int]] # (x, y, w, h) or None
|
|
saved_fullscreen: bool
|
|
monitor: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ContentArrived:
|
|
"""The adapter (called by main_window via `popout.open_post(...)`)
|
|
is delivering new media to the popout. Replaces the current
|
|
`set_media` direct method call.
|
|
"""
|
|
|
|
path: str
|
|
info: str
|
|
kind: MediaKind
|
|
width: int = 0 # API-reported dimensions, 0 if unknown
|
|
height: int = 0
|
|
referer: Optional[str] = None # for streaming http(s) URLs
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NavigateRequested:
|
|
"""User pressed an arrow key, tilted the wheel, or otherwise
|
|
requested navigation. Direction is +1 / -1 for left/right or
|
|
±grid_cols for up/down (matches the current `_navigate_preview`
|
|
convention).
|
|
"""
|
|
|
|
direction: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VideoStarted:
|
|
"""Adapter has observed mpv's `playback-restart` event AND the
|
|
state machine is currently in LoadingVideo. Translates to
|
|
LoadingVideo → PlayingVideo. Note: the adapter is responsible for
|
|
deciding "this playback-restart is a load completion, not a seek
|
|
completion" by checking the current state — only the LoadingVideo
|
|
case becomes VideoStarted; the SeekingVideo case becomes
|
|
SeekCompleted.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VideoEofReached:
|
|
"""mpv's `eof-reached` property flipped to True. Only valid in
|
|
PlayingVideo — every other state drops it. This is the structural
|
|
fix for the EOF race that fda3b10b's 250ms timestamp window
|
|
papered over.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VideoSizeKnown:
|
|
"""mpv's `video-params` observer fired with new (w, h) dimensions.
|
|
Triggers a viewport-based fit.
|
|
"""
|
|
|
|
width: int
|
|
height: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SeekRequested:
|
|
"""User clicked the slider, pressed +/- keys, or otherwise asked
|
|
to seek. Transitions PlayingVideo → SeekingVideo and stashes
|
|
`target_ms` so the slider can pin to it.
|
|
"""
|
|
|
|
target_ms: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SeekCompleted:
|
|
"""Adapter has observed mpv's `playback-restart` event AND the
|
|
state machine is currently in SeekingVideo. Translates to
|
|
SeekingVideo → PlayingVideo. Replaces the 500ms `_seek_pending_until`
|
|
timestamp window from 96a0a9d.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MuteToggleRequested:
|
|
"""User clicked the mute button. Updates `state.mute` regardless
|
|
of which state the machine is in (mute is persistent across loads).
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VolumeSet:
|
|
"""User adjusted the volume slider or scroll-wheeled over the
|
|
video area. Updates `state.volume`.
|
|
"""
|
|
|
|
value: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoopModeSet:
|
|
"""User clicked the Loop / Once / Next button cycle."""
|
|
|
|
mode: LoopMode
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TogglePlayRequested:
|
|
"""User pressed Space (or clicked the play button). Only valid in
|
|
PlayingVideo.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FullscreenToggled:
|
|
"""User pressed F11. Snapshots `viewport` into
|
|
`pre_fullscreen_viewport` on enter, restores from it on exit.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WindowMoved:
|
|
"""Qt `moveEvent` fired (non-Hyprland only — Hyprland gates this
|
|
in the adapter because Wayland doesn't expose absolute window
|
|
position to clients). Updates `state.viewport`.
|
|
"""
|
|
|
|
rect: tuple[int, int, int, int] # (x, y, w, h)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WindowResized:
|
|
"""Qt `resizeEvent` fired (non-Hyprland only). Updates
|
|
`state.viewport`.
|
|
"""
|
|
|
|
rect: tuple[int, int, int, int] # (x, y, w, h)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HyprlandDriftDetected:
|
|
"""The fit-time hyprctl read showed the current window rect drifted
|
|
from the last dispatched rect by more than `_DRIFT_TOLERANCE`. The
|
|
user moved or resized the window externally (Super+drag, corner
|
|
resize, window manager intervention). Updates `state.viewport`
|
|
from the current rect.
|
|
"""
|
|
|
|
rect: tuple[int, int, int, int] # (x, y, w, h)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CloseRequested:
|
|
"""User pressed Esc, Q, X, or otherwise requested close. Transitions
|
|
to Closing from any non-Closing state.
|
|
"""
|
|
|
|
|
|
# Type alias for the union of all events. Used as the type annotation
|
|
# on `dispatch(event: Event)`.
|
|
Event = Union[
|
|
Open,
|
|
ContentArrived,
|
|
NavigateRequested,
|
|
VideoStarted,
|
|
VideoEofReached,
|
|
VideoSizeKnown,
|
|
SeekRequested,
|
|
SeekCompleted,
|
|
MuteToggleRequested,
|
|
VolumeSet,
|
|
LoopModeSet,
|
|
TogglePlayRequested,
|
|
FullscreenToggled,
|
|
WindowMoved,
|
|
WindowResized,
|
|
HyprlandDriftDetected,
|
|
CloseRequested,
|
|
]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Effects
|
|
# ----------------------------------------------------------------------
|
|
#
|
|
# Effects are descriptors of what the adapter should do. The dispatcher
|
|
# returns a list of these from each `dispatch()` call. The adapter
|
|
# pattern-matches by type and applies them in order.
|
|
|
|
|
|
# -- Media-control effects --
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoadImage:
|
|
"""Display a static image or animated GIF. The adapter routes by
|
|
`is_gif`: True → ImageViewer.set_gif, False → set_image.
|
|
"""
|
|
|
|
path: str
|
|
is_gif: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoadVideo:
|
|
"""Hand a path or URL to mpv via `VideoPlayer.play_file`. If
|
|
`referer` is set, the adapter passes it to play_file's per-file
|
|
referrer option (current behavior at media/video_player.py:343-347).
|
|
"""
|
|
|
|
path: str
|
|
info: str
|
|
referer: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StopMedia:
|
|
"""Clear both surfaces (image viewer and video player). Used on
|
|
navigation away from current media and on close.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApplyMute:
|
|
"""Push `state.mute` to mpv. Adapter calls
|
|
`self._video.is_muted = value` which goes through VideoPlayer's
|
|
setter (which already handles the lazy-mpv case via _pending_mute
|
|
as defense in depth).
|
|
"""
|
|
|
|
value: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApplyVolume:
|
|
"""Push `state.volume` to mpv via the existing
|
|
`VideoPlayer.volume = value` setter (which writes through the
|
|
slider widget, which is the persistent storage).
|
|
"""
|
|
|
|
value: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApplyLoopMode:
|
|
"""Push `state.loop_mode` to mpv via the existing
|
|
`VideoPlayer.loop_state = value` setter.
|
|
"""
|
|
|
|
value: int # LoopMode.value, kept as int for cross-process portability
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SeekVideoTo:
|
|
"""Adapter calls `mpv.seek(target_ms / 1000.0, 'absolute')`. Note
|
|
the use of plain 'absolute' (keyframe seek), not 'absolute+exact' —
|
|
matches the current slider behavior at video_player.py:405. The
|
|
seek pin behavior is independent: the slider shows
|
|
`state.seek_target_ms` while in SeekingVideo, regardless of mpv's
|
|
keyframe-rounded actual position.
|
|
"""
|
|
|
|
target_ms: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TogglePlay:
|
|
"""Toggle mpv's `pause` property. Adapter calls
|
|
`VideoPlayer._toggle_play()`.
|
|
"""
|
|
|
|
|
|
# -- Window/geometry effects --
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FitWindowToContent:
|
|
"""Compute the new window rect for the given content aspect using
|
|
`state.viewport` and dispatch it to Hyprland (or `setGeometry()`
|
|
on non-Hyprland). The adapter delegates the rect math + dispatch
|
|
to `popout/hyprland.py`'s helper, which lands in commit 13.
|
|
"""
|
|
|
|
content_w: int
|
|
content_h: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnterFullscreen:
|
|
"""Adapter calls `self.showFullScreen()`."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExitFullscreen:
|
|
"""Adapter calls `self.showNormal()` then defers a
|
|
FitWindowToContent on the next event-loop tick (matching the
|
|
current `QTimer.singleShot(0, ...)` pattern at
|
|
popout/window.py:1023).
|
|
"""
|
|
|
|
|
|
# -- Outbound signal effects --
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmitNavigate:
|
|
"""Tell main_window to navigate to the next/previous post.
|
|
Adapter emits `self.navigate.emit(direction)`.
|
|
"""
|
|
|
|
direction: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmitPlayNextRequested:
|
|
"""Tell main_window the video ended in Loop=Next mode. Adapter
|
|
emits `self.play_next_requested.emit()`.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmitClosed:
|
|
"""Tell main_window the popout is closing. Fired on entry to
|
|
Closing state. Adapter emits `self.closed.emit()`.
|
|
"""
|
|
|
|
|
|
# Type alias for the union of all effects.
|
|
Effect = Union[
|
|
LoadImage,
|
|
LoadVideo,
|
|
StopMedia,
|
|
ApplyMute,
|
|
ApplyVolume,
|
|
ApplyLoopMode,
|
|
SeekVideoTo,
|
|
TogglePlay,
|
|
FitWindowToContent,
|
|
EnterFullscreen,
|
|
ExitFullscreen,
|
|
EmitNavigate,
|
|
EmitPlayNextRequested,
|
|
EmitClosed,
|
|
]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# StateMachine
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class StateMachine:
|
|
"""Pure-Python state machine for the popout viewer.
|
|
|
|
All decisions about media lifecycle, navigation, fullscreen, mute,
|
|
volume, viewport, and seeking live here. The Qt adapter in
|
|
`popout/window.py` is responsible only for:
|
|
1. Translating Qt events into state machine event objects
|
|
2. Calling `dispatch(event)`
|
|
3. Applying the returned effects to actual widgets / mpv / etc.
|
|
|
|
The state machine never imports Qt or mpv. It never calls into the
|
|
adapter. The communication is one-directional: events in, effects
|
|
out.
|
|
|
|
**This is the commit 2 skeleton**: all state fields are initialized,
|
|
`dispatch` is wired but every transition handler is a stub that
|
|
returns an empty effect list. Real transitions land in commits 4-11.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
# -- Core lifecycle state --
|
|
self.state: State = State.AWAITING_CONTENT
|
|
|
|
# -- First-content one-shot --
|
|
# See docs/POPOUT_ARCHITECTURE.md "is_first_content_load
|
|
# lifecycle" section for the full explanation. True at
|
|
# construction, flips to False inside the first ContentArrived
|
|
# handler. Selects between "seed viewport from saved_geo" and
|
|
# "use persistent viewport".
|
|
self.is_first_content_load: bool = True
|
|
|
|
# -- Persistent fields (orthogonal to state) --
|
|
self.fullscreen: bool = False
|
|
self.mute: bool = False
|
|
self.volume: int = 50
|
|
self.loop_mode: LoopMode = LoopMode.LOOP
|
|
|
|
# -- Viewport / geometry --
|
|
self.viewport: Optional[Viewport] = None
|
|
self.pre_fullscreen_viewport: Optional[Viewport] = None
|
|
self.last_dispatched_rect: Optional[tuple[int, int, int, int]] = None
|
|
|
|
# -- Seek state (valid only in SeekingVideo) --
|
|
self.seek_target_ms: int = 0
|
|
|
|
# -- Current content snapshot --
|
|
self.current_path: Optional[str] = None
|
|
self.current_info: str = ""
|
|
self.current_kind: Optional[MediaKind] = None
|
|
# API-reported dimensions for the current content. Used by
|
|
# FitWindowToContent on first fit before VideoSizeKnown
|
|
# arrives from mpv.
|
|
self.current_width: int = 0
|
|
self.current_height: int = 0
|
|
|
|
# -- Open-event payload (consumed on first ContentArrived) --
|
|
self.saved_geo: Optional[tuple[int, int, int, int]] = None
|
|
self.saved_fullscreen: bool = False
|
|
self.monitor: str = ""
|
|
|
|
# -- Grid columns for keyboard nav (Up/Down map to ±cols) --
|
|
self.grid_cols: int = 3
|
|
|
|
# ------------------------------------------------------------------
|
|
# Read-path queries
|
|
# ------------------------------------------------------------------
|
|
#
|
|
# Properties of the current state, computed without dispatching.
|
|
# Pure functions of `self`. Called by the adapter to render the UI
|
|
# without going through the dispatch machinery.
|
|
|
|
def compute_slider_display_ms(self, mpv_pos_ms: int) -> int:
|
|
"""Return what the seek slider should display.
|
|
|
|
While in SeekingVideo, the slider must show the user's seek
|
|
target — not mpv's lagging or keyframe-rounded `time_pos` —
|
|
because mpv may take tens to hundreds of ms to land at the
|
|
target, and during that window the user-perceived slider must
|
|
not snap backward. After the seek completes (SeekingVideo →
|
|
PlayingVideo via SeekCompleted), the slider resumes tracking
|
|
mpv's actual position.
|
|
|
|
This is the structural replacement for the 500ms
|
|
`_seek_pending_until` timestamp window. There's no timestamp
|
|
— there's just the SeekingVideo state, which lasts exactly
|
|
until mpv reports the seek is done.
|
|
"""
|
|
if self.state == State.SEEKING_VIDEO:
|
|
return self.seek_target_ms
|
|
return mpv_pos_ms
|
|
|
|
# ------------------------------------------------------------------
|
|
# Dispatch
|
|
# ------------------------------------------------------------------
|
|
#
|
|
# The single mutation point. All state changes happen inside
|
|
# dispatch() and only inside dispatch(). The adapter is forbidden
|
|
# from writing to state fields directly — it only calls dispatch
|
|
# and reads back the returned effects + the post-dispatch state.
|
|
|
|
def dispatch(self, event: Event) -> list[Effect]:
|
|
"""Process one event and return the effect list.
|
|
|
|
**Skeleton (commit 2):** every event handler currently returns
|
|
an empty effect list. Real transitions land in commits 4-11.
|
|
Tests written in commit 3 will document what each transition
|
|
is supposed to do; they fail at this point and progressively
|
|
pass as the transitions land.
|
|
"""
|
|
# Closing is terminal — drop everything once we're done.
|
|
if self.state == State.CLOSING:
|
|
return []
|
|
|
|
# Skeleton routing. Real handlers land in later commits.
|
|
match event:
|
|
case Open():
|
|
return self._on_open(event)
|
|
case ContentArrived():
|
|
return self._on_content_arrived(event)
|
|
case NavigateRequested():
|
|
return self._on_navigate_requested(event)
|
|
case VideoStarted():
|
|
return self._on_video_started(event)
|
|
case VideoEofReached():
|
|
return self._on_video_eof_reached(event)
|
|
case VideoSizeKnown():
|
|
return self._on_video_size_known(event)
|
|
case SeekRequested():
|
|
return self._on_seek_requested(event)
|
|
case SeekCompleted():
|
|
return self._on_seek_completed(event)
|
|
case MuteToggleRequested():
|
|
return self._on_mute_toggle_requested(event)
|
|
case VolumeSet():
|
|
return self._on_volume_set(event)
|
|
case LoopModeSet():
|
|
return self._on_loop_mode_set(event)
|
|
case TogglePlayRequested():
|
|
return self._on_toggle_play_requested(event)
|
|
case FullscreenToggled():
|
|
return self._on_fullscreen_toggled(event)
|
|
case WindowMoved():
|
|
return self._on_window_moved(event)
|
|
case WindowResized():
|
|
return self._on_window_resized(event)
|
|
case HyprlandDriftDetected():
|
|
return self._on_hyprland_drift_detected(event)
|
|
case CloseRequested():
|
|
return self._on_close_requested(event)
|
|
case _:
|
|
# Unknown event type. Returning [] keeps the skeleton
|
|
# safe; the illegal-transition handler in commit 11
|
|
# will replace this with the env-gated raise.
|
|
return []
|
|
|
|
# ------------------------------------------------------------------
|
|
# Per-event stub handlers (commit 2 — all return [])
|
|
# ------------------------------------------------------------------
|
|
|
|
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.
|
|
return []
|
|
|
|
def _on_content_arrived(self, event: ContentArrived) -> list[Effect]:
|
|
"""Route the new content by media kind.
|
|
|
|
Snapshot the content into `current_*` fields regardless of
|
|
kind so the rest of the state machine can read them. Then
|
|
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 []
|
|
|
|
def _on_navigate_requested(self, event: NavigateRequested) -> list[Effect]:
|
|
# Real implementation: emits StopMedia + EmitNavigate,
|
|
# transitions to AwaitingContent. Lands in commit 5.
|
|
return []
|
|
|
|
def _on_video_started(self, event: VideoStarted) -> list[Effect]:
|
|
"""LoadingVideo → PlayingVideo. Persistence effects fire here.
|
|
|
|
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]:
|
|
"""**EOF race fix (replaces fda3b10b's 250ms timestamp window).**
|
|
|
|
Only valid input in PlayingVideo. In every other state — most
|
|
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 []
|
|
|
|
def _on_video_size_known(self, event: VideoSizeKnown) -> list[Effect]:
|
|
"""mpv reported new dimensions — refit the popout window.
|
|
|
|
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 []
|
|
|
|
def _on_seek_requested(self, event: SeekRequested) -> list[Effect]:
|
|
# Real implementation: PlayingVideo → SeekingVideo, sets
|
|
# seek_target_ms, emits SeekVideoTo. Lands in commit 6.
|
|
return []
|
|
|
|
def _on_seek_completed(self, event: SeekCompleted) -> list[Effect]:
|
|
# Real implementation: SeekingVideo → PlayingVideo. Lands in
|
|
# commit 6.
|
|
return []
|
|
|
|
def _on_mute_toggle_requested(
|
|
self, event: MuteToggleRequested
|
|
) -> list[Effect]:
|
|
# Real implementation: flips state.mute, emits ApplyMute.
|
|
# Lands in commit 9.
|
|
return []
|
|
|
|
def _on_volume_set(self, event: VolumeSet) -> list[Effect]:
|
|
# Real implementation: sets state.volume, emits ApplyVolume.
|
|
# Lands in commit 9.
|
|
return []
|
|
|
|
def _on_loop_mode_set(self, event: LoopModeSet) -> list[Effect]:
|
|
# Real implementation: sets state.loop_mode, emits
|
|
# ApplyLoopMode. Lands in commit 9.
|
|
return []
|
|
|
|
def _on_toggle_play_requested(
|
|
self, event: TogglePlayRequested
|
|
) -> list[Effect]:
|
|
"""Space key / play button. Only valid in PlayingVideo —
|
|
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 []
|
|
|
|
def _on_fullscreen_toggled(self, event: FullscreenToggled) -> list[Effect]:
|
|
# Real implementation: enter snapshots viewport into
|
|
# pre_fullscreen_viewport. Exit restores. Lands in commit 7.
|
|
return []
|
|
|
|
def _on_window_moved(self, event: WindowMoved) -> list[Effect]:
|
|
# Real implementation: updates state.viewport from rect (move
|
|
# only — preserves long_side). Lands in commit 8.
|
|
return []
|
|
|
|
def _on_window_resized(self, event: WindowResized) -> list[Effect]:
|
|
# Real implementation: updates state.viewport from rect
|
|
# (resize — long_side becomes max(w, h)). Lands in commit 8.
|
|
return []
|
|
|
|
def _on_hyprland_drift_detected(
|
|
self, event: HyprlandDriftDetected
|
|
) -> list[Effect]:
|
|
# Real implementation: rebuilds state.viewport from rect.
|
|
# Lands in commit 8.
|
|
return []
|
|
|
|
def _on_close_requested(self, event: CloseRequested) -> list[Effect]:
|
|
# Real implementation: transitions to Closing, emits StopMedia
|
|
# + EmitClosed. Lands in commit 10.
|
|
return []
|
|
|
|
|
|
__all__ = [
|
|
# Enums
|
|
"State",
|
|
"MediaKind",
|
|
"LoopMode",
|
|
# Events
|
|
"Open",
|
|
"ContentArrived",
|
|
"NavigateRequested",
|
|
"VideoStarted",
|
|
"VideoEofReached",
|
|
"VideoSizeKnown",
|
|
"SeekRequested",
|
|
"SeekCompleted",
|
|
"MuteToggleRequested",
|
|
"VolumeSet",
|
|
"LoopModeSet",
|
|
"TogglePlayRequested",
|
|
"FullscreenToggled",
|
|
"WindowMoved",
|
|
"WindowResized",
|
|
"HyprlandDriftDetected",
|
|
"CloseRequested",
|
|
"Event",
|
|
# Effects
|
|
"LoadImage",
|
|
"LoadVideo",
|
|
"StopMedia",
|
|
"ApplyMute",
|
|
"ApplyVolume",
|
|
"ApplyLoopMode",
|
|
"SeekVideoTo",
|
|
"TogglePlay",
|
|
"FitWindowToContent",
|
|
"EnterFullscreen",
|
|
"ExitFullscreen",
|
|
"EmitNavigate",
|
|
"EmitPlayNextRequested",
|
|
"EmitClosed",
|
|
"Effect",
|
|
# Machine
|
|
"StateMachine",
|
|
]
|