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:
parent
a03d0e9dc8
commit
527cb3489b
@ -807,19 +807,58 @@ class StateMachine:
|
|||||||
def _on_mute_toggle_requested(
|
def _on_mute_toggle_requested(
|
||||||
self, event: MuteToggleRequested
|
self, event: MuteToggleRequested
|
||||||
) -> list[Effect]:
|
) -> list[Effect]:
|
||||||
# Real implementation: flips state.mute, emits ApplyMute.
|
"""**Pending mute fix structural (replaces 0a68182's
|
||||||
# Lands in commit 9.
|
_pending_mute lazy-replay pattern at the popout layer).**
|
||||||
return []
|
|
||||||
|
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]:
|
def _on_volume_set(self, event: VolumeSet) -> list[Effect]:
|
||||||
# Real implementation: sets state.volume, emits ApplyVolume.
|
"""User adjusted the volume slider or scroll-wheeled over the
|
||||||
# Lands in commit 9.
|
video area. Update `state.volume` (clamped to 0-100), emit
|
||||||
return []
|
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]:
|
def _on_loop_mode_set(self, event: LoopModeSet) -> list[Effect]:
|
||||||
# Real implementation: sets state.loop_mode, emits
|
"""User clicked the Loop / Once / Next button cycle. Update
|
||||||
# ApplyLoopMode. Lands in commit 9.
|
`state.loop_mode`, emit ApplyLoopMode.
|
||||||
return []
|
|
||||||
|
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(
|
def _on_toggle_play_requested(
|
||||||
self, event: TogglePlayRequested
|
self, event: TogglePlayRequested
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user