- 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>
222 lines
7.6 KiB
Python
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)
|