Moves the typing app's lesson plan and progress from client-side YAML/localStorage into the backend, and adds a reward system that ties passing a lesson to unlocking part of the music library. Lock state is always recomputed live from curriculum x progress x the live library, never persisted separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
"""What the reactions get handed: the three objects, the bus, and a little shared state."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
|
|
from musicmouse.bus import EventBus
|
|
from musicmouse.clock import Clock, RealClock
|
|
from musicmouse.config import Config, FigureColors
|
|
from musicmouse.devices.mouse import MusicMouseDevice
|
|
from musicmouse.devices.player import Player
|
|
from musicmouse.library import MusicLibrary
|
|
from musicmouse.library.models import Album
|
|
from musicmouse.media import Playlist
|
|
from musicmouse.tippen.runtime import TippenRuntime
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
__all__ = ["App", "AppState"]
|
|
|
|
|
|
@dataclass
|
|
class AppState:
|
|
"""State that belongs to no single device but is shared between reactions."""
|
|
|
|
#: Figure that was taken off the reader mid-playlist, so putting it back resumes
|
|
#: instead of starting over. Cleared once its playlist runs out.
|
|
last_partially_played_figure: str | None = None
|
|
|
|
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
|
#: readable off the transport; a broker's is not, so it is remembered here.
|
|
mqtt_connected: bool = False
|
|
|
|
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
|
lirc_connected: bool = False
|
|
|
|
|
|
@dataclass
|
|
class App:
|
|
config: Config
|
|
bus: EventBus
|
|
mouse: MusicMouseDevice
|
|
player: Player
|
|
library: MusicLibrary
|
|
playlists: dict[str, Playlist]
|
|
clock: Clock = field(default_factory=RealClock)
|
|
state: AppState = field(default_factory=AppState)
|
|
#: `None` when `general.tippen` is absent - the typing game is off.
|
|
tippen: TippenRuntime | None = None
|
|
|
|
def colors(self, figure: str) -> FigureColors:
|
|
return self.config.figures[figure].colors
|
|
|
|
def playlist(self, figure: str) -> Playlist | None:
|
|
playlist = self.playlists.get(figure)
|
|
if playlist is None:
|
|
_log.warning("No playlist for figure %r", figure)
|
|
return playlist
|
|
|
|
def album_for(self, playlist: Playlist | None) -> Album | None:
|
|
"""The library album a playlist came from, if any."""
|
|
return self.library.get(playlist.album_id if playlist else None)
|
|
|
|
async def rescan_library(
|
|
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
|
|
) -> None:
|
|
"""Rescan from disk, rebuild figure playlists, and tell whoever's listening.
|
|
|
|
Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that
|
|
can change what's on disk on its own, such as the podcast feed poller.
|
|
"""
|
|
await self.library.refresh()
|
|
self.playlists.clear()
|
|
self.playlists.update(self.library.figure_playlists())
|
|
if broadcast is not None:
|
|
await broadcast()
|