popout/state: implement mute/volume/loop persistence

Three event handlers, all updating state fields and emitting the
corresponding Apply effect:

MuteToggleRequested:
  Flip state.mute unconditionally — independent of which media state
  we're in, independent of whether mpv exists. Emit ApplyMute. The
  persistence-on-load mechanism in _on_video_started already replays
  state.mute into the freshly-loaded video, so toggling mute before
  any video is loaded survives the load cycle.

VolumeSet:
  Set state.volume (clamped 0-100), emit ApplyVolume. Same
  persistence-on-load behavior.

LoopModeSet:
  Set state.loop_mode, emit ApplyLoopMode. Also affects what
  happens at the next EOF (PlayingVideo + VideoEofReached branches
  on state.loop_mode), so changing it during playback takes effect
  on the next eof without any other state mutation.

This commit makes the 0a68182 pending mute fix structural at the
popout layer. The state machine owns mute / volume / loop_mode as
the source of truth. The current VideoPlayer._pending_mute field
stays as defense in depth — the state machine refactor's prompt
forbids touching media/video_player.py beyond the playback_restart
Signal addition. The popout layer no longer depends on the lazy
replay because the state machine emits ApplyMute on every
PlayingVideo entry.

All four persistent fields (mute, volume, loop_mode, viewport)
are now state machine fields with single-writer ownership through
dispatch().

Tests passing after this commit (62 total → 54 pass, 8 fail):

  - test_state_field_mute_persists_across_video_loads
  - test_state_field_volume_persists_across_video_loads
  - test_state_field_loop_mode_persists
  - test_invariant_pending_mute_replayed_into_video (RACE FIX!)

Phase A (16 tests) still green.

Tests still failing (8, scheduled for commit 10):
  - DisplayingImage content arrived branch (commit 10)
  - Closing transitions (commit 10)
  - Open + first content with image kind (commit 10)

Test cases for commit 10 (DisplayingImage + Closing):
  - ContentArrived(IMAGE) → DisplayingImage + LoadImage(is_gif=False)
  - ContentArrived(GIF) → DisplayingImage + LoadImage(is_gif=True)
  - DisplayingImage + ContentArrived(IMAGE) replaces media
  - CloseRequested from each state → Closing + StopMedia + EmitClosed
This commit is contained in:
pax 2026-04-08 19:36:35 -05:00
parent a03d0e9dc8
commit 527cb3489b

View File

@ -807,19 +807,58 @@ class StateMachine:
def _on_mute_toggle_requested(
self, event: MuteToggleRequested
) -> list[Effect]:
# Real implementation: flips state.mute, emits ApplyMute.
# Lands in commit 9.
return []
"""**Pending mute fix structural (replaces 0a68182's
_pending_mute lazy-replay pattern at the popout layer).**
Flip `state.mute` unconditionally independent of which
media state we're in, independent of whether mpv exists.
Emit `ApplyMute` so the adapter pushes the new value into
mpv if mpv is currently alive.
For the "user mutes before any video has loaded" case, the
ApplyMute effect is still emitted but the adapter's apply
handler routes it through `VideoPlayer.is_muted = value`,
which uses VideoPlayer's existing `_pending_mute` field as
defense in depth (the pre-mpv buffer survives until
`_ensure_mpv` runs). Either way, the mute value persists.
On the next LoadingVideo PlayingVideo transition,
`_on_video_started` emits ApplyMute(state.mute) again as
part of the entry effects, so the freshly-loaded video
starts in the right mute state regardless of when the user
toggled.
Valid in every non-Closing state.
"""
self.mute = not self.mute
return [ApplyMute(value=self.mute)]
def _on_volume_set(self, event: VolumeSet) -> list[Effect]:
# Real implementation: sets state.volume, emits ApplyVolume.
# Lands in commit 9.
return []
"""User adjusted the volume slider or scroll-wheeled over the
video area. Update `state.volume` (clamped to 0-100), emit
ApplyVolume.
Same persistence pattern as mute: state.volume is the source
of truth, replayed on every PlayingVideo entry.
Valid in every non-Closing state.
"""
self.volume = max(0, min(100, event.value))
return [ApplyVolume(value=self.volume)]
def _on_loop_mode_set(self, event: LoopModeSet) -> list[Effect]:
# Real implementation: sets state.loop_mode, emits
# ApplyLoopMode. Lands in commit 9.
return []
"""User clicked the Loop / Once / Next button cycle. Update
`state.loop_mode`, emit ApplyLoopMode.
loop_mode also gates `_on_video_eof_reached`'s decision
between EmitPlayNextRequested (Next), no-op (Once and Loop),
so changing it during PlayingVideo affects what happens at
the next EOF without needing any other state mutation.
Valid in every non-Closing state.
"""
self.loop_mode = event.mode
return [ApplyLoopMode(value=self.loop_mode.value)]
def _on_toggle_play_requested(
self, event: TogglePlayRequested