Files
musicmouse/python-backend/musicmouse/reactions/playback.py
Martin Bauer 459599d2ba Number keys: the keyboard's digits work like the remote's; a key can hold one song
The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.

A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:41:34 +02:00

197 lines
6.4 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 or a future web request 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.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:
playlist = app.playlist(event.figure)
if playlist is None:
return
if not event.restart and app.player.playlist is playlist:
_log.info("Resuming %r", event.figure)
app.player.play()
return
_log.info("Starting %r from the beginning", event.figure)
app.player.set_playlist(playlist)
app.player.play_from_start()
def _start_album(app: App, album: Album, track_index: int) -> None:
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
# Playlist object either way, because both come from the library index.
playlist = app.playlists.get(album.figure) if album.figure else album.to_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.
@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)