Files
musicmouse/python-backend/musicmouse/reactions/playback.py
Martin Bauer d465a74cde Figures: several albums per figure, picked on the LED ring
A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

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

219 lines
7.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.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)
if not event.restart and app.player.playlist is 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 keeps the figure's own resume bookkeeping honest: it is the same
# Playlist object either way, because both come from the library index.
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)