Files
musicmouse/python-backend/musicmouse/reactions/playback.py
Martin Bauer a737ccf108 Bring the docs in line with the code
- python-backend/README: track analysis is implemented (2e0e6ad); the cache layout,
  the module table (lirc, screen, timers, podcasts, tippen) and the route table
  (track analysis, HA proxy, typing game) were missing pieces.
- docs/REPO_OVERVIEW: Mein Zimmer and the clock are implemented; Home Assistant is
  also reached over HTTP through the room page's proxy, not MQTT only; the IR remote,
  scripts/, the optional config sections and figure kind were not mentioned.
- web/README: the key table was the mockup's (Ctrl transport keys, TAB over 'all');
  ?pi=1 is now a preset with per-browser overrides, not a read-once flag; the room
  page and the typing game exist; eslint is part of the checks.
- Docstrings that described earlier designs: HaConfig (written as browser-direct in
  the same commit that built the proxy, a8ed350), services/__init__, events,
  playback, media, colors, tippen progress and AppHeader, lowPower.ts, and the
  'Hoerbuecher' spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:27 +02:00

222 lines
7.6 KiB
Python

"""What the mouse plays, and when.
Physical inputs are turned into *intents*, and the intents are what actually drive the
player. That indirection is the point: an MQTT command, a web request or an IR remote key
emits the same intent and lands in the same handler, so there is one place per behaviour.
"""
from __future__ import annotations
import logging
from musicmouse.app import App
from musicmouse.events import (
ActiveFigureChanged,
ButtonEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayFigureRequested,
PlaylistFinished,
PlayRemoteSlotRequested,
PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested,
RotaryTurned,
SeekRequested,
SetVolumeRequested,
VolumeChangeRequested,
)
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
from musicmouse.library.models import Album
from musicmouse.media import Playlist
from musicmouse.reactions.registry import on
_log = logging.getLogger(__name__)
# ------------------------------------------------------------------ figure on/off
@on(ActiveFigureChanged)
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
if event.figure is None:
_figure_removed(event.previous, app)
else:
app.bus.emit(
PlayFigureRequested(
figure=event.figure,
restart=app.state.last_partially_played_figure != event.figure,
source="device",
)
)
def _figure_removed(previous: str | None, app: App) -> None:
if app.player.is_playing:
app.player.pause()
# Remember where we were, so putting the same figure back resumes.
app.state.last_partially_played_figure = previous
_log.info("Figure %r removed mid-playlist", previous)
else:
app.state.last_partially_played_figure = None
@on(PlayFigureRequested)
def play_figure(event: PlayFigureRequested, app: App) -> None:
"""Play the figure's album - the one it played last, if it has several.
Another figure placed in between forgets only *where* in that album it was
(``restart``), not which album it was.
"""
choices = app.choices(event.figure)
if not choices:
return
playlist = choices[app.default_choice(event.figure)]
remember_figure_album(app, event.figure, playlist)
# Equality, not identity: a library rescan (the refresh button, or the podcast
# poller landing an episode) rebuilds every Playlist object, and an `is` check made
# the figure start over after that. Equal means the same album with the same files;
# if the files changed, starting over is the honest thing to do.
if not event.restart and app.player.playlist == playlist:
_log.info("Resuming %r", event.figure)
app.player.play()
return
_log.info("Starting %r (%s) from the beginning", event.figure, playlist.album_id)
app.player.set_playlist(playlist)
app.player.play_from_start()
def remember_figure_album(app: App, figure: str, playlist: Playlist) -> None:
"""Make ``playlist`` the album ``figure`` picks next time it is put down."""
if playlist.album_id is not None:
app.state.figure_albums[figure] = playlist.album_id
def _start_album(app: App, album: Album, track_index: int) -> None:
# A figure album started from anywhere becomes the album that figure picks next.
playlist = None
if album.figure is not None:
playlist = next(
(p for p in app.playlists.get(album.figure, ()) if p.album_id == album.id), None
)
if playlist is not None:
remember_figure_album(app, album.figure, playlist)
app.player.set_playlist(playlist or album.to_playlist())
app.player.play_track(track_index)
@on(PlayAlbumRequested)
def play_album(event: PlayAlbumRequested, app: App) -> None:
"""Play any album in the library. This is the web front-end's way in."""
album = app.library.get(event.album_id)
if album is None:
_log.warning("No album %r in the library", event.album_id)
return
_start_album(app, album, event.track_index)
@on(PlaySeriesLatestRequested)
def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
"""Play the newest episode of a podcast show - the IR remote's number-key way in."""
album = app.library.latest_episode(event.series)
if album is None:
_log.warning("No episodes for series %r", event.series)
return
_start_album(app, album, 0)
@on(PlayRemoteSlotRequested)
def play_remote_slot(event: PlayRemoteSlotRequested, app: App) -> None:
"""Play what a number key is assigned - pressed on the IR remote or a keyboard.
A slot naming one song starts the album there; the rest of the album follows, as
for any other start mid-album.
"""
slot = app.config.remote.get(event.digit)
if slot is None:
_log.debug("Number key %s is not assigned", event.digit)
return
if slot.target_kind == "series":
app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source=event.source))
return
album = app.library.get(slot.target)
if album is None:
_log.warning("Number key %s: no album %r in the library", event.digit, slot.target)
return
_start_album(app, album, slot.track_index)
@on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
# Nothing was left half-played, so the next placement starts from the top.
app.state.last_partially_played_figure = None
# ---------------------------------------------------------------- physical inputs
@on(ButtonEvent)
def button_pressed(event: ButtonEvent, app: App) -> None:
if event.action is not ButtonAction.PRESSED:
return
if event.button is Button.LEFT and app.player.is_playing:
app.bus.emit(PreviousTrackRequested(source="device"))
elif event.button is Button.RIGHT and app.player.is_playing:
app.bus.emit(NextTrackRequested(source="device"))
# The rotary press is published to Home Assistant by the MQTT service; what it
# controls is an automation over there, not something this backend decides. While
# the ring's album picker is open the press is the picker's instead, and does not
# arrive here at all (see ``MusicMouseDevice.capture_knob``).
@on(RotaryTurned)
def rotary_turned(event: RotaryTurned, app: App) -> None:
step = app.config.general.volume_increment * abs(event.increment)
if event.direction is RotaryDirection.UP:
app.bus.emit(VolumeChangeRequested(delta=step, source="device"))
elif event.direction is RotaryDirection.DOWN:
app.bus.emit(VolumeChangeRequested(delta=-step, source="device"))
# ----------------------------------------------------------------------- intents
@on(NextTrackRequested)
def next_track(event: NextTrackRequested, app: App) -> None:
_log.debug("Next track (%s)", event.source)
app.player.next_track()
@on(PreviousTrackRequested)
def previous_track(event: PreviousTrackRequested, app: App) -> None:
_log.debug("Previous track (%s)", event.source)
app.player.previous_track()
@on(PlayRequested)
def play(_event: PlayRequested, app: App) -> None:
app.player.play()
@on(PauseRequested)
def pause(_event: PauseRequested, app: App) -> None:
app.player.pause()
@on(SeekRequested)
def seek(event: SeekRequested, app: App) -> None:
app.player.seek(event.position)
@on(VolumeChangeRequested)
def change_volume(event: VolumeChangeRequested, app: App) -> None:
app.player.change_volume(event.delta, source=event.source)
@on(SetVolumeRequested)
def set_volume(event: SetVolumeRequested, app: App) -> None:
app.player.set_volume(event.volume, source=event.source)