popout/window: fix dispatch lambdas dropping effects (video auto-fit + Loop=Next)

The signal-connection lambdas in __init__ added by commit 14a only
called _fsm_dispatch — they never followed up with _apply_effects.
Commit 14b added the apply layer and updated the keyboard event
handlers in eventFilter to dispatch+apply, but missed the lambdas.
Result: every effect produced by an mpv-driven signal was silently
dropped.

Two user-visible regressions:

  1. Video auto-fit (and aspect ratio lock) broken in popout. The
     mpv `video-params` observer fires when mpv reports video
     dimensions, and the chain is:
       _on_video_params (mpv thread) → _pending_video_size set
       → _poll → video_size.emit(w, h)
       → connected lambda → dispatch VideoSizeKnown(w, h)
       → state machine emits FitWindowToContent(w, h)
       → adapter SHOULD apply by calling _fit_to_content
     The lambda dropped the effects, so _fit_to_content never ran
     for video loads. Image loads were unaffected because they go
     through set_media's ContentArrived dispatch (which DOES apply
     via _dispatch_and_apply in this commit) with API-known
     dimensions.

  2. Loop=Next play_next broken. The mpv eof → VideoPlayer.play_next
     → connected lambda → dispatch VideoEofReached chain produces an
     EmitPlayNextRequested effect in PlayingVideo + Loop=Next, but
     the lambda dropped the effect, so self.play_next_requested was
     never emitted, and main_window's _on_video_end_next never fired.
     The user reported the auto-fit breakage; the play_next breakage
     was the silent twin that no one noticed because Loop=Next isn't
     the default.

Both bugs landed in commit 14b. The seek pin removal in d48435d
didn't cause them but exposed the auto-fit one because the user
was paying attention to popout sizing during the slider verification.

Fix:

- Add `_dispatch_and_apply(event)` helper. The single line of
  documentation in its docstring tells future-pax: "if you're
  going to dispatch an event, go through this helper, not bare
  _fsm_dispatch." This makes the apply step impossible to forget
  for any new wire-point.

- Update all 6 signal-connection lambdas to call _dispatch_and_apply:
    play_next → VideoEofReached
    video_size → VideoSizeKnown
    clicked_position → SeekRequested
    _mute_btn.clicked → MuteToggleRequested
    _vol_slider.valueChanged → VolumeSet
    _loop_btn.clicked → LoopModeSet

- Update the rest of the dispatch sites (keyboard event handlers in
  eventFilter, the wheel-tilt navigation, the wheel-vertical volume
  scroll, _on_video_playback_restart, set_media, closeEvent, the
  Open dispatch in __init__, and the WindowResized/WindowMoved
  dispatches in resizeEvent/moveEvent) to use _dispatch_and_apply
  for consistency. The keyboard handlers were already calling
  dispatch+apply via the two-line `effects = ...; self._apply_effects(effects)`
  pattern; switching to the helper is just deduplication. The
  Open / Window* dispatches were bare _fsm_dispatch but their
  handlers return [] anyway so the apply was a no-op.

After this commit, every dispatch site in the popout adapter goes
through one helper. The only remaining `self._fsm_dispatch(...)` call
is inside the helper itself (line 437) and one reference in the
helper's docstring.

Verification:
- Phase A test suite (16 tests) still passes
- State machine tests (65 tests) still pass — none of them touch
  the adapter wiring
- 81 / 81 tests green at HEAD

Manual verification needed:
- Click an uncached video in browse → popout opens, video loads,
  popout auto-fits to video aspect, Hyprland aspect lock applies
- Click cached video → same
- Loop=Next mode + video reaches EOF → popout advances to next post
  (was silently broken since 14b)
- Image load still auto-fits (regression check — image path was
  already working via ContentArrived's immediate FitWindowToContent)
This commit is contained in:
pax 2026-04-08 21:00:27 -05:00
parent d48435db1c
commit 0ef3643b32

View File

@ -371,7 +371,10 @@ class FullscreenPreview(QMainWindow):
if FullscreenPreview._saved_geometry: if FullscreenPreview._saved_geometry:
sg = FullscreenPreview._saved_geometry sg = FullscreenPreview._saved_geometry
saved_geo_tuple = (sg.x(), sg.y(), sg.width(), sg.height()) saved_geo_tuple = (sg.x(), sg.y(), sg.width(), sg.height())
self._fsm_dispatch(Open( # Open's handler returns [] (just stashes saved_geo on the
# state machine) but using _dispatch_and_apply for consistency
# — every dispatch site goes through one helper.
self._dispatch_and_apply(Open(
saved_geo=saved_geo_tuple, saved_geo=saved_geo_tuple,
saved_fullscreen=bool(FullscreenPreview._saved_fullscreen), saved_fullscreen=bool(FullscreenPreview._saved_fullscreen),
monitor=monitor, monitor=monitor,
@ -383,37 +386,57 @@ class FullscreenPreview(QMainWindow):
# the adapter distinguishes by checking the state machine's # the adapter distinguishes by checking the state machine's
# current state at dispatch time. # current state at dispatch time.
self._video.playback_restart.connect(self._on_video_playback_restart) self._video.playback_restart.connect(self._on_video_playback_restart)
# Wire video EOF (already connected to play_next_requested # Wire VideoPlayer signals to dispatch+apply via the
# signal above) — additionally dispatch VideoEofReached. # _dispatch_and_apply helper. NOTE: every lambda below MUST
# call _dispatch_and_apply, not _fsm_dispatch directly. Calling
# _fsm_dispatch alone produces effects that never reach
# widgets — the bug that landed in commit 14b and broke
# video auto-fit (FitWindowToContent never applied) and
# Loop=Next play_next (EmitPlayNextRequested never applied)
# until the lambdas were fixed in this commit.
self._video.play_next.connect( self._video.play_next.connect(
lambda: self._fsm_dispatch(VideoEofReached()) lambda: self._dispatch_and_apply(VideoEofReached())
) )
# Wire video size known.
self._video.video_size.connect( self._video.video_size.connect(
lambda w, h: self._fsm_dispatch(VideoSizeKnown(width=w, height=h)) lambda w, h: self._dispatch_and_apply(VideoSizeKnown(width=w, height=h))
) )
# Wire seek slider clicks → SeekRequested.
self._video._seek_slider.clicked_position.connect( self._video._seek_slider.clicked_position.connect(
lambda v: self._fsm_dispatch(SeekRequested(target_ms=v)) lambda v: self._dispatch_and_apply(SeekRequested(target_ms=v))
) )
# Wire mute button → MuteToggleRequested. Dispatch BEFORE the
# legacy _toggle_mute runs (which mutates VideoPlayer state)
# so the dispatch reflects the user-intent edge.
self._video._mute_btn.clicked.connect( self._video._mute_btn.clicked.connect(
lambda: self._fsm_dispatch(MuteToggleRequested()) lambda: self._dispatch_and_apply(MuteToggleRequested())
) )
# Wire volume slider → VolumeSet.
self._video._vol_slider.valueChanged.connect( self._video._vol_slider.valueChanged.connect(
lambda v: self._fsm_dispatch(VolumeSet(value=v)) lambda v: self._dispatch_and_apply(VolumeSet(value=v))
) )
# Wire loop button → LoopModeSet. Dispatched AFTER the legacy
# cycle so the new value is what we send.
self._video._loop_btn.clicked.connect( self._video._loop_btn.clicked.connect(
lambda: self._fsm_dispatch( lambda: self._dispatch_and_apply(
LoopModeSet(mode=LoopMode(self._video.loop_state)) LoopModeSet(mode=LoopMode(self._video.loop_state))
) )
) )
def _dispatch_and_apply(self, event) -> None:
"""Dispatch an event and apply the returned effects.
Centralizes the dispatch+apply pattern that every event
wire-point in the adapter follows. Replaces the older
`effects = self._fsm_dispatch(...); self._apply_effects(effects)`
two-line pattern in eventFilter and the bare `_fsm_dispatch`
calls in the signal connection lambdas.
Note the historical bug this method exists to prevent: if a
signal lambda only calls `_fsm_dispatch` (which dispatches
and logs but does NOT apply effects), the state machine's
returned effects never reach widgets. That's how commit 14b
broke video auto-fit and Loop=Next play_next without anyone
noticing the lambdas in __init__ were dispatching cleanly
but the FitWindowToContent and EmitPlayNextRequested effects
they produced were silently dropped. Going through this
helper makes the apply step impossible to forget.
"""
effects = self._fsm_dispatch(event)
self._apply_effects(effects)
def _fsm_dispatch(self, event) -> list: def _fsm_dispatch(self, event) -> list:
"""Dispatch an event to the state machine and log the result. """Dispatch an event to the state machine and log the result.
@ -454,11 +477,9 @@ class FullscreenPreview(QMainWindow):
if not hasattr(self, "_state_machine"): if not hasattr(self, "_state_machine"):
return return
if self._state_machine.state == State.LOADING_VIDEO: if self._state_machine.state == State.LOADING_VIDEO:
effects = self._fsm_dispatch(VideoStarted()) self._dispatch_and_apply(VideoStarted())
self._apply_effects(effects)
elif self._state_machine.state == State.SEEKING_VIDEO: elif self._state_machine.state == State.SEEKING_VIDEO:
effects = self._fsm_dispatch(SeekCompleted()) self._dispatch_and_apply(SeekCompleted())
self._apply_effects(effects)
# Other states: drop. The state machine's release-mode # Other states: drop. The state machine's release-mode
# legality check would also drop it; this saves the dispatch # legality check would also drop it; this saves the dispatch
# round trip. # round trip.
@ -905,7 +926,7 @@ class FullscreenPreview(QMainWindow):
# private helpers. The state machine becomes the single entry # private helpers. The state machine becomes the single entry
# point for media load; set_media's body shrinks to dispatch # point for media load; set_media's body shrinks to dispatch
# + apply. # + apply.
effects = self._fsm_dispatch(ContentArrived( self._dispatch_and_apply(ContentArrived(
path=path, path=path,
info=info, info=info,
kind=kind, kind=kind,
@ -913,7 +934,6 @@ class FullscreenPreview(QMainWindow):
height=height, height=height,
referer=referer, referer=referer,
)) ))
self._apply_effects(effects)
# Note: pre-fit to API dimensions was tried (option A from # Note: pre-fit to API dimensions was tried (option A from
# the perf round) but caused a perceptible slowdown in popout # the perf round) but caused a perceptible slowdown in popout
# video clicks — the redundant second hyprctl dispatch when # video clicks — the redundant second hyprctl dispatch when
@ -1240,28 +1260,22 @@ class FullscreenPreview(QMainWindow):
self.close() self.close()
return True return True
elif key in (Qt.Key.Key_Left, Qt.Key.Key_H): elif key in (Qt.Key.Key_Left, Qt.Key.Key_H):
effects = self._fsm_dispatch(NavigateRequested(direction=-1)) self._dispatch_and_apply(NavigateRequested(direction=-1))
self._apply_effects(effects)
return True return True
elif key in (Qt.Key.Key_Right, Qt.Key.Key_L): elif key in (Qt.Key.Key_Right, Qt.Key.Key_L):
effects = self._fsm_dispatch(NavigateRequested(direction=1)) self._dispatch_and_apply(NavigateRequested(direction=1))
self._apply_effects(effects)
return True return True
elif key in (Qt.Key.Key_Up, Qt.Key.Key_K): elif key in (Qt.Key.Key_Up, Qt.Key.Key_K):
effects = self._fsm_dispatch(NavigateRequested(direction=-self._grid_cols)) self._dispatch_and_apply(NavigateRequested(direction=-self._grid_cols))
self._apply_effects(effects)
return True return True
elif key in (Qt.Key.Key_Down, Qt.Key.Key_J): elif key in (Qt.Key.Key_Down, Qt.Key.Key_J):
effects = self._fsm_dispatch(NavigateRequested(direction=self._grid_cols)) self._dispatch_and_apply(NavigateRequested(direction=self._grid_cols))
self._apply_effects(effects)
return True return True
elif key == Qt.Key.Key_F11: elif key == Qt.Key.Key_F11:
effects = self._fsm_dispatch(FullscreenToggled()) self._dispatch_and_apply(FullscreenToggled())
self._apply_effects(effects)
return True return True
elif key == Qt.Key.Key_Space and self._stack.currentIndex() == 1: elif key == Qt.Key.Key_Space and self._stack.currentIndex() == 1:
effects = self._fsm_dispatch(TogglePlayRequested()) self._dispatch_and_apply(TogglePlayRequested())
self._apply_effects(effects)
return True return True
elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1: elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1:
# +/- keys are seek-relative, NOT slider-pin seeks. The # +/- keys are seek-relative, NOT slider-pin seeks. The
@ -1279,12 +1293,10 @@ class FullscreenPreview(QMainWindow):
# Horizontal tilt navigates between posts on either stack # Horizontal tilt navigates between posts on either stack
tilt = event.angleDelta().x() tilt = event.angleDelta().x()
if tilt > 30: if tilt > 30:
effects = self._fsm_dispatch(NavigateRequested(direction=-1)) self._dispatch_and_apply(NavigateRequested(direction=-1))
self._apply_effects(effects)
return True return True
if tilt < -30: if tilt < -30:
effects = self._fsm_dispatch(NavigateRequested(direction=1)) self._dispatch_and_apply(NavigateRequested(direction=1))
self._apply_effects(effects)
return True return True
# Vertical wheel adjusts volume on the video stack only # Vertical wheel adjusts volume on the video stack only
if self._stack.currentIndex() == 1: if self._stack.currentIndex() == 1:
@ -1295,7 +1307,7 @@ class FullscreenPreview(QMainWindow):
# actual mpv.volume write still happens via the # actual mpv.volume write still happens via the
# legacy assignment below — ApplyVolume is a no-op # legacy assignment below — ApplyVolume is a no-op
# in 14b (see _apply_effects docstring). # in 14b (see _apply_effects docstring).
self._fsm_dispatch(VolumeSet(value=vol)) self._dispatch_and_apply(VolumeSet(value=vol))
self._video.volume = vol self._video.volume = vol
self._show_overlay() self._show_overlay()
return True return True
@ -1473,7 +1485,7 @@ class FullscreenPreview(QMainWindow):
long_side=float(max(rect.width(), rect.height())), long_side=float(max(rect.width(), rect.height())),
) )
# Parallel state machine dispatch for the same event. # Parallel state machine dispatch for the same event.
self._fsm_dispatch(WindowResized(rect=( self._dispatch_and_apply(WindowResized(rect=(
rect.x(), rect.y(), rect.width(), rect.height(), rect.x(), rect.y(), rect.width(), rect.height(),
))) )))
@ -1504,7 +1516,7 @@ class FullscreenPreview(QMainWindow):
long_side=self._viewport.long_side, long_side=self._viewport.long_side,
) )
# Parallel state machine dispatch for the same event. # Parallel state machine dispatch for the same event.
self._fsm_dispatch(WindowMoved(rect=( self._dispatch_and_apply(WindowMoved(rect=(
rect.x(), rect.y(), rect.width(), rect.height(), rect.x(), rect.y(), rect.width(), rect.height(),
))) )))
@ -1550,6 +1562,5 @@ class FullscreenPreview(QMainWindow):
# [StopMedia, EmitClosed]. StopMedia clears the media stack; # [StopMedia, EmitClosed]. StopMedia clears the media stack;
# EmitClosed emits self.closed which triggers main_window's # EmitClosed emits self.closed which triggers main_window's
# _on_fullscreen_closed handler. # _on_fullscreen_closed handler.
effects = self._fsm_dispatch(CloseRequested()) self._dispatch_and_apply(CloseRequested())
self._apply_effects(effects)
super().closeEvent(event) super().closeEvent(event)