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>
197 lines
6.4 KiB
Python
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)
|