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
This commit is contained in:
parent
9cba7d5583
commit
39816144fe
751
booru_viewer/gui/popout/state.py
Normal file
751
booru_viewer/gui/popout/state.py
Normal file
@ -0,0 +1,751 @@
|
||||
"""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]:
|
||||
# Real implementation: routes to LoadImage or LoadVideo,
|
||||
# transitions to DisplayingImage / LoadingVideo, emits
|
||||
# FitWindowToContent. First-time path consumes saved_geo;
|
||||
# subsequent paths use persistent viewport. Lands in commits
|
||||
# 4 (video) + 10 (image).
|
||||
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]:
|
||||
# Real implementation: LoadingVideo → PlayingVideo, emits
|
||||
# ApplyMute / ApplyVolume / ApplyLoopMode. Lands in commit 4.
|
||||
return []
|
||||
|
||||
def _on_video_eof_reached(self, event: VideoEofReached) -> list[Effect]:
|
||||
# Real implementation: only valid in PlayingVideo. Loop=Next
|
||||
# emits EmitPlayNextRequested. Loop=Once emits TogglePlay (to
|
||||
# pause). Loop=Loop is a no-op (mpv handles it). Other states
|
||||
# drop. Lands in commit 4 — this is the EOF race fix.
|
||||
return []
|
||||
|
||||
def _on_video_size_known(self, event: VideoSizeKnown) -> list[Effect]:
|
||||
# Real implementation: emits FitWindowToContent. Lands in
|
||||
# commits 4 + 8.
|
||||
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]:
|
||||
# Real implementation: only valid in PlayingVideo. Emits
|
||||
# TogglePlay. Lands in commit 4.
|
||||
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",
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user