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>
219 lines
7.4 KiB
Python
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)
|