**Commit 14b of the pre-emptive 14a/14b split.**
Adds the effect application path. The state machine becomes the
single source of truth for the popout's media transitions, navigation,
fullscreen toggle, and close lifecycle. The legacy imperative paths
that 14a left in place are removed where the dispatch+apply chain
now produces the same side effects.
Architectural shape:
Qt event → _fsm_dispatch(Event) → list[Effect] → _apply_effects()
↓
pattern-match by type
↓
calls existing private helpers
(_fit_to_content, _enter_fullscreen,
_video.play_file, etc.)
The state machine doesn't try to reach into Qt or mpv directly; it
returns descriptors and the adapter dispatches them to the existing
implementation methods. The private helpers stay in place as the
implementation; the state machine becomes their official caller.
What's fully authoritative via dispatch+apply:
- Navigate keys + wheel tilt → NavigateRequested → EmitNavigate
- F11 → FullscreenToggled → EnterFullscreen / ExitFullscreen
- Space → TogglePlayRequested → TogglePlay
- closeEvent → CloseRequested → StopMedia + EmitClosed
- set_media → ContentArrived → LoadImage|LoadVideo + FitWindowToContent
- mpv playback-restart → VideoStarted | SeekCompleted (state-aware)
- mpv eof-reached + Loop=Next → VideoEofReached → EmitPlayNextRequested
- mpv video-params → VideoSizeKnown → FitWindowToContent
What's deliberately no-op apply in 14b (state machine TRACKS but
doesn't drive):
- ApplyMute / ApplyVolume / ApplyLoopMode: legacy slot connections
on the popout's VideoPlayer still handle the user-facing toggles.
Pushing state.mute/volume/loop_mode would create a sync hazard
with the embedded preview's mute state, which main_window pushes
via direct attribute writes at popout open. The state machine
fields are still updated for the upcoming SyncFromEmbedded path
in a future commit; the apply handlers are intentionally empty.
- SeekVideoTo: the legacy `_ClickSeekSlider.clicked_position →
VideoPlayer._seek` connection still handles both the mpv.seek
call (now exact, per the 609066c drag-back fix) and the legacy
500ms `_seek_pending_until` pin window. Replacing this requires
modifying VideoPlayer._poll which is forbidden by the state
machine refactor's no-touch rule on media/video_player.py.
Removed duplicate legacy emits (would have caused real bugs):
- self.navigate.emit(±N) in eventFilter arrow keys + wheel tilt
→ EmitNavigate effect
- self.closed.emit() and self._video.stop() in closeEvent
→ StopMedia + EmitClosed effects
- self._video.play_next.connect(self.play_next_requested)
signal-to-signal forwarding → EmitPlayNextRequested effect
- self._enter_fullscreen() / self._exit_fullscreen() direct calls
→ EnterFullscreen / ExitFullscreen effects
- self._video._toggle_play() direct call → TogglePlay effect
- set_media body's load logic → LoadImage / LoadVideo effects
The Esc/Q handler now only calls self.close() and lets closeEvent
do the dispatch + apply. Two reasons:
1. Geometry persistence (FullscreenPreview._saved_geometry /
_saved_fullscreen) is adapter-side concern and must run BEFORE
self.closed is emitted, because main_window's
_on_fullscreen_closed handler reads those class fields. Saving
geometry inside closeEvent before dispatching CloseRequested
gets the order right.
2. The state machine sees the close exactly once. Two-paths
(Esc/Q → dispatch + close() → closeEvent → re-dispatch) would
require the dispatch entry's CLOSING-state guard to silently
absorb the second event — works but more confusing than just
having one dispatch site.
The closeEvent flow now is:
1. Save FullscreenPreview._saved_fullscreen and _saved_geometry
(adapter-side, before dispatch)
2. Remove the QApplication event filter
3. Dispatch CloseRequested → effects = [StopMedia, EmitClosed]
4. Apply effects → stop media, emit self.closed
5. super().closeEvent(event) → Qt window close
Verification:
- Phase A test suite (16 tests in tests/core/) still passes
- State machine tests (65 in tests/gui/popout/test_state.py) still pass
- Total: 81 / 81 automated tests green
- Imports clean
**The 11 manual scenarios are NOT verified by automated tests.**
The user must run the popout interactively and walk through each
scenario before this commit can be considered fully verified:
1. P↔L navigation cycles drift toward corner
2. Super+drag externally then nav
3. Corner-resize externally then nav
4. F11 same-aspect round-trip
5. F11 across-aspect round-trip
6. First-open from saved geometry
7. Restart persistence across app sessions
8. Rapid Right-arrow spam
9. Uncached video click
10. Mute toggle before mpv exists
11. Seek mid-playback (already verified by the 14a + drag-back-fix
sweep)
**If ANY scenario fails after this commit:** immediate `git revert
HEAD`, do not fix in place. The 14b apply layer is bounded enough
that any regression can be diagnosed by inspecting the apply handler
for the relevant effect type, but the in-place-fix temptation should
be resisted — bisect-safety requires a clean revert.
Test cases for commit 15:
- main_window.popout calls become method calls instead of direct
underscore access (open_post / sync_video_state / get_video_state /
set_toolbar_visibility)
- Method-cluster sweep from REFACTOR_INVENTORY.md still passes