Web frontend
@@ -17,9 +17,10 @@ Assistant integration.
|
|||||||
| Path | What it is |
|
| Path | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
||||||
|
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
|
||||||
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
||||||
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
||||||
| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats"). Not integrated with anything — no build system ties it in. Don't assume it reflects current product direction. |
|
| `claude-design/` | The interaction and visual spec the web front-end was built from ("Dolphin Beats"), as a standalone HTML mockup with hardcoded data. `web/` is the real implementation; the mockup is kept as the reference for the keyboard model and the styling. It also contains a second page, "Mein Zimmer" (room lights), which is **not** implemented. |
|
||||||
|
|
||||||
## How the pieces talk to each other
|
## How the pieces talk to each other
|
||||||
|
|
||||||
@@ -34,6 +35,12 @@ Assistant integration.
|
|||||||
exact bytes of every effect payload — so a firmware change that breaks the host now
|
exact bytes of every effect payload — so a firmware change that breaks the host now
|
||||||
breaks a test instead of just the LEDs.
|
breaks a test instead of just the LEDs.
|
||||||
|
|
||||||
|
- **`python-backend` ↔ `web/`**: JSON over HTTP for the library and for commands, plus a
|
||||||
|
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
|
||||||
|
the physical buttons emit, so the web UI has no privileged path — and no way to get
|
||||||
|
out of step with a figure someone puts on the reader. See
|
||||||
|
`python-backend/README.md` for the endpoint list.
|
||||||
|
|
||||||
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
|
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
|
||||||
discoverable lights, a player sensor, a volume number, transport buttons, device
|
discoverable lights, a player sensor, a volume number, transport buttons, device
|
||||||
triggers for every button and touch area, and a tag scanner for the RFID reader. It
|
triggers for every button and touch area, and a tag scanner for the RFID reader. It
|
||||||
@@ -47,7 +54,17 @@ Assistant integration.
|
|||||||
python -m musicmouse --config /media/musicmouse/config.yml
|
python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Or, with no hardware and no audio:
|
On a machine with no mouse attached — real audio and a real web UI, no serial port:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware
|
||||||
|
```
|
||||||
|
|
||||||
|
`general.serial_port: simulate` does the same thing from the config, and
|
||||||
|
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
|
||||||
|
both keys are required - omitting one is an error rather than an implicit simulation.
|
||||||
|
|
||||||
|
Or with no hardware *and* no audio:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python -m musicmouse --config ./config.yml --simulate
|
python -m musicmouse --config ./config.yml --simulate
|
||||||
@@ -57,19 +74,38 @@ The simulator runs the whole app against a fake serial link and a fake player, e
|
|||||||
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
||||||
systemd unit for the device.
|
systemd unit for the device.
|
||||||
|
|
||||||
|
For front-end work, run the backend (either way above) and then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
|
||||||
|
production so one process serves both the UI and the API.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
`python-backend/config.yml.example` documents the schema. In short:
|
`python-backend/config.yml.example` documents the schema. In short:
|
||||||
|
|
||||||
- `general.{figure_folder, serial_port, baudrate, reconnect_interval, alsa_device,
|
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||||
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||||
audio_extensions, mqtt.*}`
|
audio_extensions, mqtt.*, web.*}`
|
||||||
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
|
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
|
||||||
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
|
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
|
||||||
`"wNN"`.
|
`"wNN"`.
|
||||||
|
|
||||||
Each figure plays `<figure_folder>/<figure name>`, alphabetically by filename. Config is
|
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||||
validated with pydantic: unknown keys are errors, and every problem is reported at once.
|
names, not settings, because each has quirks the scanner knows about:
|
||||||
|
|
||||||
|
```
|
||||||
|
<root>/Figuren/<figure name>/ one folder per figurine
|
||||||
|
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
|
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
|
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
|
```
|
||||||
|
|
||||||
|
Config is validated with pydantic: unknown keys are errors, and every problem is
|
||||||
|
reported at once.
|
||||||
|
|
||||||
## Notes for agents
|
## Notes for agents
|
||||||
|
|
||||||
@@ -80,3 +116,10 @@ validated with pydantic: unknown keys are errors, and every problem is reported
|
|||||||
- `python-backend/notebooks/` is university course material on chord recognition, not
|
- `python-backend/notebooks/` is university course material on chord recognition, not
|
||||||
part of the app.
|
part of the app.
|
||||||
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
||||||
|
- The front-end has `vitest` over the pure modules (`web/src/lib/`) and `tsc --noEmit`;
|
||||||
|
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are
|
||||||
|
the two checks.
|
||||||
|
- The library index is cached under `general.library.cache`. It is keyed by file mtime
|
||||||
|
and size, so **a change to how the scanner derives a title, artist or series is
|
||||||
|
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||||
|
`musicmouse/library/cache.py` when you touch that logic.
|
||||||
|
|||||||
BIN
python-backend/.musicmouse-cache/covers/0772247eeb24.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
python-backend/.musicmouse-cache/covers/13293e87561e.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
python-backend/.musicmouse-cache/covers/22a6a01286bd.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
python-backend/.musicmouse-cache/covers/9bf448484625.jpg
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
python-backend/.musicmouse-cache/covers/9cc6a9f226cd.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
python-backend/.musicmouse-cache/covers/c7dba5e954c4.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
python-backend/.musicmouse-cache/covers/e95134365b97.jpg
Normal file
|
After Width: | Height: | Size: 567 KiB |
1
python-backend/.musicmouse-cache/index.json
Normal file
@@ -1,21 +1,22 @@
|
|||||||
# MusicMouse backend
|
# MusicMouse backend
|
||||||
|
|
||||||
The host application: it reads RFID tags, buttons and touch areas from the ESP32
|
The host application: it reads RFID tags, buttons and touch areas from the ESP32
|
||||||
firmware over serial, plays music through VLC, drives three LED strips, and exposes
|
firmware over serial, plays music through VLC, drives three LED strips, indexes the
|
||||||
everything to Home Assistant over MQTT.
|
music collection, and exposes everything to Home Assistant over MQTT and to a browser
|
||||||
|
over HTTP.
|
||||||
|
|
||||||
```
|
```
|
||||||
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
|
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
|
||||||
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
|
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
|
||||||
broker ⇄ MqttService ──────┘ ▲ └─► (a web service would go here)
|
broker ⇄ MqttService ──────┤ ▲ └─► WebService (state out, intents in)
|
||||||
│
|
browser ⇄ WebService ───────┘ │
|
||||||
reactions/*.py ── call actions on ──► device / player
|
reactions/*.py ── call actions on ──► device / player
|
||||||
```
|
```
|
||||||
|
|
||||||
Three objects own the outside world, one bus carries everything, and the *reactions*
|
Three objects own the outside world, one bus carries everything, and the *reactions*
|
||||||
are the only place that decides what should happen. Adding a new way to control the
|
are the only place that decides what should happen. Adding a new way to control the
|
||||||
mouse means adding a service that emits the same intents - no device or reaction
|
mouse means adding a service that emits the same intents - no device or reaction
|
||||||
changes.
|
changes. The web front-end was added exactly that way.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -28,6 +29,32 @@ See `config.yml.example` for the schema and `musicmouse.service` for the systemd
|
|||||||
Config problems are reported all at once with the path to each one; unknown keys are
|
Config problems are reported all at once with the path to each one; unknown keys are
|
||||||
errors, not silent no-ops.
|
errors, not silent no-ops.
|
||||||
|
|
||||||
|
### On a host with no mouse attached
|
||||||
|
|
||||||
|
The web front-end is a complete way to drive the player, so the backend is useful on a
|
||||||
|
machine with no serial port and no sound card worth grabbing. Two config keys say so,
|
||||||
|
each by taking the literal value `simulate`:
|
||||||
|
|
||||||
|
| Setting | `simulate` gives you | Warned about as |
|
||||||
|
|---|---|---|
|
||||||
|
| `serial_port` | no serial link; RFID, buttons and LEDs are inert | "running without the mouse" |
|
||||||
|
| `alsa_device` | the simulator's player: everything works, nothing is audible | "nothing will be audible" |
|
||||||
|
|
||||||
|
Both keys are **required**. Leaving one out is a config error, not a shortcut to
|
||||||
|
simulation - running blind or silent has to be asked for, so a config that lost a line
|
||||||
|
fails loudly instead of booting into something that looks like it is working. The
|
||||||
|
warnings are repeated on every boot for the same reason.
|
||||||
|
|
||||||
|
`--no-hardware` forces the serial half regardless of the config, for a one-off run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `alsa_device` has no "just use whatever" value. VLC's own default would seize
|
||||||
|
whatever the desktop is playing through, which is the wrong thing to do silently - name
|
||||||
|
a device (`"default"` is the system one) when the machine is meant to make noise.
|
||||||
|
|
||||||
### Without hardware
|
### Without hardware
|
||||||
|
|
||||||
The simulator runs the entire app - real bus, real device, real reactions, real MQTT
|
The simulator runs the entire app - real bus, real device, real reactions, real MQTT
|
||||||
@@ -65,7 +92,9 @@ the prompt becomes a regression test by pasting it into a `.txt` file.
|
|||||||
| `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). |
|
| `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). |
|
||||||
| `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). |
|
| `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). |
|
||||||
| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. |
|
| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. |
|
||||||
|
| `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. |
|
||||||
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
|
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
|
||||||
|
| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, and parent-mode settings. |
|
||||||
| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. |
|
| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. |
|
||||||
| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. |
|
| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. |
|
||||||
|
|
||||||
@@ -118,3 +147,89 @@ trigger, action `light.toggle` on `light.kinderzimmer_fluter`.
|
|||||||
|
|
||||||
Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with
|
Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with
|
||||||
that data.
|
that data.
|
||||||
|
|
||||||
|
## The library
|
||||||
|
|
||||||
|
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||||
|
names rather than settings (`musicmouse/library/sections.py`), because each has quirks
|
||||||
|
the scanner has to know about:
|
||||||
|
|
||||||
|
| Folder | Shown as | Why it is special |
|
||||||
|
|---|---|---|
|
||||||
|
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config |
|
||||||
|
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
||||||
|
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
||||||
|
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
||||||
|
|
||||||
|
Only files whose suffix is in `audio_extensions` are read, and dotfiles are skipped, so
|
||||||
|
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
|
||||||
|
a playlist.
|
||||||
|
|
||||||
|
Each album carries three colours, pulled out of its cover art with Pillow (or
|
||||||
|
synthesised from a hash of its id when it has none). The frontend paints cards with
|
||||||
|
them and the LED strips run the first of them, so shelf and screen agree.
|
||||||
|
|
||||||
|
### The cache
|
||||||
|
|
||||||
|
`general.library.cache` is a directory, not a file, because its contents cost wildly
|
||||||
|
different amounts to produce:
|
||||||
|
|
||||||
|
```
|
||||||
|
index.json cheap: tags and structure. Rebuilt freely.
|
||||||
|
covers/<album_id>.jpg medium: art extracted from an ID3 APIC frame
|
||||||
|
analysis/<track_key>.json expensive: reserved for offline audio analysis
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
|
||||||
|
of DSP per track, which is why anything expensive is keyed by *file content* rather than
|
||||||
|
by album id - renaming a folder or re-sorting a section then costs nothing.
|
||||||
|
|
||||||
|
An entry is reused whenever its files' sizes and mtimes are unchanged. That means a
|
||||||
|
change to how the scanner derives a title, artist or series is invisible until the cache
|
||||||
|
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
|
||||||
|
that logic.
|
||||||
|
|
||||||
|
**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape
|
||||||
|
of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with
|
||||||
|
the index, and a beat grid lives in its own file and is fetched per track - so an
|
||||||
|
analyzer can be added later without touching the scanner, the API or the frontend. It
|
||||||
|
will go behind an optional dependency group, and because results are content-keyed
|
||||||
|
files, they can equally well be computed on a workstation and the `analysis/` folder
|
||||||
|
copied to the device.
|
||||||
|
|
||||||
|
## The web front-end
|
||||||
|
|
||||||
|
`web/` in the repo root, served by this backend when `general.web.static_dir` is set.
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
|
||||||
|
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
|
||||||
|
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
|
||||||
|
| `GET /api/state` | Snapshot: what is playing, where, how loud. |
|
||||||
|
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. |
|
||||||
|
| `POST /api/play` | `{album_id, track_index?}` |
|
||||||
|
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
||||||
|
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
||||||
|
| `GET` `PUT /api/settings` | parent mode |
|
||||||
|
|
||||||
|
Two decisions worth knowing:
|
||||||
|
|
||||||
|
**Search is not an endpoint.** The whole index goes to the browser and filtering happens
|
||||||
|
there, which is what makes the design's type-to-search feel instant.
|
||||||
|
|
||||||
|
**Volume is a percentage at this boundary.** `max_volume` is a parent's business, not a
|
||||||
|
child's, so it never crosses into the browser: `100 %` means whatever ceiling is
|
||||||
|
configured, and the mapping lives in `services/web/settings.py` so MQTT, the rotary
|
||||||
|
encoder and the firmware carry on in device units.
|
||||||
|
|
||||||
|
### Parent mode
|
||||||
|
|
||||||
|
`?parentMode=1` reveals a settings panel for the volume limits, the rotary step and the
|
||||||
|
button brightness. Saving writes `config.yml` back through ruamel's round-trip loader,
|
||||||
|
so the file keeps its comments, and a new ceiling applies to the running player rather
|
||||||
|
than waiting for a restart.
|
||||||
|
|
||||||
|
This **hides** the settings; it does not protect them. There is no authentication on any
|
||||||
|
endpoint, which matches a device on a home network - put it behind a reverse proxy if
|
||||||
|
that is not good enough.
|
||||||
|
|||||||
94
python-backend/config.yml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Example config for the MusicMouse backend.
|
||||||
|
#
|
||||||
|
# python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
#
|
||||||
|
# Unknown keys are rejected rather than ignored, and every problem in the file is
|
||||||
|
# reported at once, so a typo fails at startup with the path to the offending line.
|
||||||
|
# Keep the real config (with credentials) off the repo - on the device only.
|
||||||
|
|
||||||
|
general:
|
||||||
|
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||||
|
# settings, because each one has its own quirks the code already knows about:
|
||||||
|
#
|
||||||
|
# <root>/Figuren/<figure name>/ one folder per figurine
|
||||||
|
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
|
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
|
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
|
#
|
||||||
|
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
||||||
|
# of the files' tags. Relative paths resolve against this file's directory.
|
||||||
|
library:
|
||||||
|
root: /home/martin/Music
|
||||||
|
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
||||||
|
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||||
|
# is expensive to recompute.
|
||||||
|
cache: .musicmouse-cache
|
||||||
|
|
||||||
|
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||||
|
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||||
|
# setup on its own because the web front-end can drive the player by itself. RFID,
|
||||||
|
# buttons and LEDs then do nothing, and startup says so every boot.
|
||||||
|
serial_port: "simulate"
|
||||||
|
baudrate: 115200
|
||||||
|
reconnect_interval: 5.0
|
||||||
|
|
||||||
|
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
||||||
|
# default output. Required - use "simulate" for a player that makes no sound, which
|
||||||
|
# is handy when working on the web UI on a machine whose audio you would rather not
|
||||||
|
# commandeer. Startup says so every boot.
|
||||||
|
#
|
||||||
|
# Both of these are required rather than optional on purpose: running blind or silent
|
||||||
|
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
||||||
|
# into something that looks like it is working.
|
||||||
|
alsa_device: "default"
|
||||||
|
|
||||||
|
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||||
|
min_volume: 0
|
||||||
|
max_volume: 80
|
||||||
|
initial_volume: 40
|
||||||
|
volume_increment: 5 # per rotary-encoder click
|
||||||
|
|
||||||
|
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||||
|
button_leds_brightness: 0.5
|
||||||
|
|
||||||
|
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||||
|
# a half-finished .tmp - is ignored.
|
||||||
|
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||||
|
|
||||||
|
# The web front-end. Omit the whole section to run without it.
|
||||||
|
#
|
||||||
|
# There is no authentication: this is a device on a home network. The settings panel
|
||||||
|
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
||||||
|
# to this file. Put it behind a reverse proxy if that is not good enough.
|
||||||
|
web:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||||
|
static_dir: ../web/dist
|
||||||
|
|
||||||
|
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||||
|
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||||
|
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||||
|
#mqtt:
|
||||||
|
# server: "homeassistant.local"
|
||||||
|
# port: 1883
|
||||||
|
# user: "musicmouse"
|
||||||
|
# password: "REPLACE_WITH_MQTT_PASSWORD"
|
||||||
|
# base_topic: "musicmouse"
|
||||||
|
# discovery_prefix: "homeassistant"
|
||||||
|
# device_id: "musicmouse"
|
||||||
|
# device_name: "Music Mouse"
|
||||||
|
# reconnect_interval: 10.0
|
||||||
|
|
||||||
|
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||||
|
figures:
|
||||||
|
fuchs:
|
||||||
|
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||||
|
id: "04a1b2c3d4"
|
||||||
|
# Exactly four colours: primary, secondary, background, accent.
|
||||||
|
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||||
|
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||||
|
|
||||||
|
eule:
|
||||||
|
id: "04b2c3d4e5"
|
||||||
|
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||||
@@ -7,17 +7,39 @@
|
|||||||
# Keep the real config (with credentials) off the repo - on the device only.
|
# Keep the real config (with credentials) off the repo - on the device only.
|
||||||
|
|
||||||
general:
|
general:
|
||||||
# Root folder holding one subfolder per figure. Relative paths resolve against
|
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||||
# this file's directory. Each figure plays <figure_folder>/<figure name>,
|
# settings, because each one has its own quirks the code already knows about:
|
||||||
# in alphabetical order by filename.
|
#
|
||||||
figure_folder: music
|
# <root>/Figuren/<figure name>/ one folder per figurine
|
||||||
|
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
|
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
|
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
|
#
|
||||||
|
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
||||||
|
# of the files' tags. Relative paths resolve against this file's directory.
|
||||||
|
library:
|
||||||
|
root: /home/martin/Music
|
||||||
|
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
||||||
|
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||||
|
# is expensive to recompute.
|
||||||
|
cache: .musicmouse-cache
|
||||||
|
|
||||||
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||||
|
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||||
|
# setup on its own because the web front-end can drive the player by itself. RFID,
|
||||||
|
# buttons and LEDs then do nothing, and startup says so every boot.
|
||||||
serial_port: "/dev/ttyUSB0"
|
serial_port: "/dev/ttyUSB0"
|
||||||
baudrate: 115200
|
baudrate: 115200
|
||||||
reconnect_interval: 5.0
|
reconnect_interval: 5.0
|
||||||
|
|
||||||
# ALSA output device passed to VLC, e.g. "hw:0,0". Omit for VLC's default.
|
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
||||||
|
# default output. Required - use "simulate" for a player that makes no sound, which
|
||||||
|
# is handy when working on the web UI on a machine whose audio you would rather not
|
||||||
|
# commandeer. Startup says so every boot.
|
||||||
|
#
|
||||||
|
# Both of these are required rather than optional on purpose: running blind or silent
|
||||||
|
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
||||||
|
# into something that looks like it is working.
|
||||||
alsa_device: "softvol_effects"
|
alsa_device: "softvol_effects"
|
||||||
|
|
||||||
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||||
@@ -29,9 +51,21 @@ general:
|
|||||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||||
button_leds_brightness: 0.5
|
button_leds_brightness: 0.5
|
||||||
|
|
||||||
# Which files count as music. Anything else in a figure folder is ignored.
|
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||||
|
# a half-finished .tmp - is ignored.
|
||||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||||
|
|
||||||
|
# The web front-end. Omit the whole section to run without it.
|
||||||
|
#
|
||||||
|
# There is no authentication: this is a device on a home network. The settings panel
|
||||||
|
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
||||||
|
# to this file. Put it behind a reverse proxy if that is not good enough.
|
||||||
|
web:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||||
|
static_dir: ../web/dist
|
||||||
|
|
||||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||||
@@ -54,7 +88,13 @@ figures:
|
|||||||
# Exactly four colours: primary, secondary, background, accent.
|
# Exactly four colours: primary, secondary, background, accent.
|
||||||
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||||
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||||
|
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
||||||
|
# its type is obvious; a figure folder is named after the figurine, so this is the
|
||||||
|
# one thing that has to be said out loud. The web UI draws albums square and
|
||||||
|
# audiobooks taller than wide, so getting it wrong is visible at a glance.
|
||||||
|
kind: music
|
||||||
|
|
||||||
eule:
|
eule:
|
||||||
id: "04b2c3d4e5"
|
id: "04b2c3d4e5"
|
||||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||||
|
kind: book
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Entry point and composition root.
|
"""Entry point and composition root.
|
||||||
|
|
||||||
python -m musicmouse --config /media/musicmouse/config.yml
|
python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware
|
||||||
python -m musicmouse --config ./config.yml --simulate
|
python -m musicmouse --config ./config.yml --simulate
|
||||||
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
|
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
|
||||||
|
|
||||||
This is the only module that knows which concrete implementations are in play; the
|
This is the only module that knows which concrete implementations are in play; the
|
||||||
difference between "real mouse" and "simulated mouse" is which transport and which
|
difference between "real mouse", "no mouse attached" and "simulated mouse" is which
|
||||||
player get built here.
|
transport and which player get built here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,13 +25,16 @@ from musicmouse import __version__
|
|||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.clock import RealClock
|
from musicmouse.clock import RealClock
|
||||||
from musicmouse.config import Config, ConfigError, build_playlists, load_config
|
from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config
|
||||||
from musicmouse.devices.mouse import MusicMouseDevice
|
from musicmouse.devices.mouse import MusicMouseDevice
|
||||||
|
from musicmouse.devices.null_transport import NullTransport
|
||||||
from musicmouse.devices.player import Player, VlcPlayer
|
from musicmouse.devices.player import Player, VlcPlayer
|
||||||
from musicmouse.devices.serial_link import SerialLink
|
from musicmouse.devices.serial_link import SerialLink
|
||||||
|
from musicmouse.library import MusicLibrary
|
||||||
from musicmouse.reactions import register_all
|
from musicmouse.reactions import register_all
|
||||||
from musicmouse.services.base import Service
|
from musicmouse.services.base import Service
|
||||||
from musicmouse.services.mqtt import MqttService, build_entities
|
from musicmouse.services.mqtt import MqttService, build_entities
|
||||||
|
from musicmouse.services.web import WebService
|
||||||
|
|
||||||
_log = logging.getLogger("musicmouse")
|
_log = logging.getLogger("musicmouse")
|
||||||
|
|
||||||
@@ -48,6 +52,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="run against fake hardware and a fake player (no serial port, no audio)",
|
help="run against fake hardware and a fake player (no serial port, no audio)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-hardware",
|
||||||
|
action="store_true",
|
||||||
|
help="real audio and a real web front-end, but no serial port: for a host with "
|
||||||
|
"no mouse attached",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--script",
|
"--script",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -64,6 +74,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.script and not args.simulate:
|
if args.script and not args.simulate:
|
||||||
parser.error("--script only makes sense together with --simulate")
|
parser.error("--script only makes sense together with --simulate")
|
||||||
|
if args.simulate and args.no_hardware:
|
||||||
|
parser.error("--simulate already runs without hardware; drop --no-hardware")
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +97,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
runner = run_simulated(config, args.script) if args.simulate else run_real(config)
|
runner = (
|
||||||
|
run_simulated(config, args.config, args.script)
|
||||||
|
if args.simulate
|
||||||
|
else run_real(config, args.config, hardware=wants_hardware(config, args.no_hardware))
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
asyncio.run(runner)
|
asyncio.run(runner)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -96,43 +112,53 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
# ------------------------------------------------------------------------- real
|
# ------------------------------------------------------------------------- real
|
||||||
|
|
||||||
|
|
||||||
async def run_real(config: Config) -> None:
|
async def run_real(config: Config, config_path: Path, *, hardware: bool = True) -> None:
|
||||||
bus = EventBus()
|
bus = EventBus()
|
||||||
await bus.start()
|
await bus.start()
|
||||||
clock = RealClock()
|
clock = RealClock()
|
||||||
general = config.general
|
general = config.general
|
||||||
|
|
||||||
link = SerialLink(
|
link: SerialLink | None = None
|
||||||
general.serial_port,
|
if hardware:
|
||||||
general.baudrate,
|
link = SerialLink(
|
||||||
reconnect_interval=general.reconnect_interval,
|
general.serial_port,
|
||||||
clock=clock,
|
general.baudrate,
|
||||||
)
|
reconnect_interval=general.reconnect_interval,
|
||||||
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
|
clock=clock,
|
||||||
link.attach(
|
)
|
||||||
mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected
|
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
|
||||||
)
|
link.attach(
|
||||||
|
mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
|
||||||
|
|
||||||
player: Player = VlcPlayer(
|
player = _build_player(bus, general, clock=clock)
|
||||||
bus,
|
|
||||||
alsa_device=general.alsa_device,
|
|
||||||
clock=clock,
|
|
||||||
**VlcPlayer.volume_kwargs(general),
|
|
||||||
)
|
|
||||||
|
|
||||||
app = _build_app(config, bus, mouse, player, clock=clock)
|
library = await build_library(config)
|
||||||
services = _build_services(app, mouse, player, clock=clock)
|
app = _build_app(config, bus, mouse, player, library, clock=clock)
|
||||||
|
services = _build_services(
|
||||||
|
app, mouse, player, clock=clock, config_path=config_path
|
||||||
|
)
|
||||||
|
|
||||||
_log.info(
|
_log.info(
|
||||||
"MusicMouse %s starting: %d figures, serial %s, mqtt %s",
|
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
|
||||||
__version__,
|
__version__,
|
||||||
len(config.figures),
|
len(config.figures),
|
||||||
general.serial_port,
|
len(library.albums),
|
||||||
|
# Report what the config says, with a marker when --no-hardware overrode it,
|
||||||
|
# so the line never disagrees with the file it was started from.
|
||||||
|
general.serial_port + ("" if link or general.serial_simulated else " (no link)"),
|
||||||
|
general.alsa_device,
|
||||||
general.mqtt.server if general.mqtt else "disabled",
|
general.mqtt.server if general.mqtt else "disabled",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await _run_forever(
|
await _run_forever(
|
||||||
[link.run(), player.run(), *(service.run() for service in services)]
|
[
|
||||||
|
*([link.run()] if link else []),
|
||||||
|
player.run(),
|
||||||
|
*(service.run() for service in services),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
player.close()
|
player.close()
|
||||||
@@ -142,7 +168,7 @@ async def run_real(config: Config) -> None:
|
|||||||
# -------------------------------------------------------------------- simulated
|
# -------------------------------------------------------------------- simulated
|
||||||
|
|
||||||
|
|
||||||
async def run_simulated(config: Config, script: Path | None) -> None:
|
async def run_simulated(config: Config, config_path: Path, script: Path | None) -> None:
|
||||||
# Imported here so the production path never touches the simulator.
|
# Imported here so the production path never touches the simulator.
|
||||||
from musicmouse.simulator.harness import build_simulation
|
from musicmouse.simulator.harness import build_simulation
|
||||||
from musicmouse.simulator.repl import run_repl
|
from musicmouse.simulator.repl import run_repl
|
||||||
@@ -154,7 +180,9 @@ async def run_simulated(config: Config, script: Path | None) -> None:
|
|||||||
config, clock=RealClock() if script is None else None, track_duration=5.0
|
config, clock=RealClock() if script is None else None, track_duration=5.0
|
||||||
)
|
)
|
||||||
|
|
||||||
services = _build_services(sim.app, sim.app.mouse, sim.player, clock=RealClock())
|
services = _build_services(
|
||||||
|
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
|
||||||
|
)
|
||||||
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
|
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
|
||||||
try:
|
try:
|
||||||
if script is not None:
|
if script is not None:
|
||||||
@@ -170,11 +198,68 @@ async def run_simulated(config: Config, script: Path | None) -> None:
|
|||||||
# ---------------------------------------------------------------------- wiring
|
# ---------------------------------------------------------------------- wiring
|
||||||
|
|
||||||
|
|
||||||
|
def wants_hardware(config: Config, no_hardware_flag: bool) -> bool:
|
||||||
|
"""Whether to open a serial port at all.
|
||||||
|
|
||||||
|
``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked
|
||||||
|
for rather than inferred from a missing key, but it is still worth saying out loud
|
||||||
|
every boot - a mouse whose RFID reader does nothing should say why.
|
||||||
|
"""
|
||||||
|
if no_hardware_flag:
|
||||||
|
return False
|
||||||
|
if config.general.serial_simulated:
|
||||||
|
_log.warning(
|
||||||
|
'general.serial_port is "%s": running without the mouse. '
|
||||||
|
"The web front-end still works; RFID, buttons and LEDs do not.",
|
||||||
|
SIMULATE,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player:
|
||||||
|
"""A real player, or a silent one when the config asked for that.
|
||||||
|
|
||||||
|
``alsa_device: simulate`` gets the simulator's player: everything above it behaves
|
||||||
|
identically, it just makes no sound. Useful for working on the web UI without
|
||||||
|
commandeering the machine's audio.
|
||||||
|
"""
|
||||||
|
if not general.audio_simulated:
|
||||||
|
return VlcPlayer(
|
||||||
|
bus,
|
||||||
|
alsa_device=general.alsa_device,
|
||||||
|
clock=clock,
|
||||||
|
**VlcPlayer.volume_kwargs(general),
|
||||||
|
)
|
||||||
|
|
||||||
|
_log.warning(
|
||||||
|
'general.alsa_device is "%s": using a simulated player, so nothing will be '
|
||||||
|
'audible. Set it to "default" for the system default output.',
|
||||||
|
SIMULATE,
|
||||||
|
)
|
||||||
|
# Imported here rather than at module scope so the real audio path never loads the
|
||||||
|
# simulator - and so this still runs on a machine without libVLC at all.
|
||||||
|
from musicmouse.simulator.fake_player import FakePlayer
|
||||||
|
|
||||||
|
return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general))
|
||||||
|
|
||||||
|
|
||||||
|
async def build_library(config: Config) -> MusicLibrary:
|
||||||
|
library_config = config.general.library
|
||||||
|
return await MusicLibrary.build(
|
||||||
|
library_config.root,
|
||||||
|
library_config.cache,
|
||||||
|
frozenset(config.general.audio_extensions),
|
||||||
|
figure_kinds=config.figure_kinds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_app(
|
def _build_app(
|
||||||
config: Config,
|
config: Config,
|
||||||
bus: EventBus,
|
bus: EventBus,
|
||||||
mouse: MusicMouseDevice,
|
mouse: MusicMouseDevice,
|
||||||
player: Player,
|
player: Player,
|
||||||
|
library: MusicLibrary,
|
||||||
*,
|
*,
|
||||||
clock: RealClock,
|
clock: RealClock,
|
||||||
) -> App:
|
) -> App:
|
||||||
@@ -183,7 +268,11 @@ def _build_app(
|
|||||||
bus=bus,
|
bus=bus,
|
||||||
mouse=mouse,
|
mouse=mouse,
|
||||||
player=player,
|
player=player,
|
||||||
playlists=build_playlists(config),
|
library=library,
|
||||||
|
# One source of truth: the figure path and the web path must hand the player the
|
||||||
|
# same Playlist object for the same folder, because `play_figure` resumes on an
|
||||||
|
# identity check.
|
||||||
|
playlists=library.figure_playlists(),
|
||||||
clock=clock,
|
clock=clock,
|
||||||
)
|
)
|
||||||
register_all(bus, app)
|
register_all(bus, app)
|
||||||
@@ -191,15 +280,30 @@ def _build_app(
|
|||||||
|
|
||||||
|
|
||||||
def _build_services(
|
def _build_services(
|
||||||
app: App, mouse: MusicMouseDevice, player: Player, *, clock: RealClock
|
app: App,
|
||||||
|
mouse: MusicMouseDevice,
|
||||||
|
player: Player,
|
||||||
|
*,
|
||||||
|
clock: RealClock,
|
||||||
|
config_path: Path,
|
||||||
) -> list[Service]:
|
) -> list[Service]:
|
||||||
"""Every front-end. A web service would be one more line here."""
|
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
|
||||||
if app.config.general.mqtt is None:
|
services: list[Service] = []
|
||||||
_log.info("No mqtt section in the config: Home Assistant integration is off")
|
|
||||||
return []
|
|
||||||
mqtt_config = app.config.general.mqtt
|
mqtt_config = app.config.general.mqtt
|
||||||
entities = build_entities(app.bus, mqtt_config, mouse, player)
|
if mqtt_config is None:
|
||||||
return [MqttService(app.bus, mqtt_config, entities, clock=clock)]
|
_log.info("No mqtt section in the config: Home Assistant integration is off")
|
||||||
|
else:
|
||||||
|
entities = build_entities(app.bus, mqtt_config, mouse, player)
|
||||||
|
services.append(MqttService(app.bus, mqtt_config, entities, clock=clock))
|
||||||
|
|
||||||
|
web_config = app.config.general.web
|
||||||
|
if web_config is None:
|
||||||
|
_log.info("No web section in the config: the web front-end is off")
|
||||||
|
else:
|
||||||
|
services.append(WebService(app, web_config, config_path))
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
|
||||||
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None:
|
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from musicmouse.clock import Clock, RealClock
|
|||||||
from musicmouse.config import Config, FigureColors
|
from musicmouse.config import Config, FigureColors
|
||||||
from musicmouse.devices.mouse import MusicMouseDevice
|
from musicmouse.devices.mouse import MusicMouseDevice
|
||||||
from musicmouse.devices.player import Player
|
from musicmouse.devices.player import Player
|
||||||
|
from musicmouse.library import MusicLibrary
|
||||||
from musicmouse.media import Playlist
|
from musicmouse.media import Playlist
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -25,6 +26,10 @@ class AppState:
|
|||||||
#: instead of starting over. Cleared once its playlist runs out.
|
#: instead of starting over. Cleared once its playlist runs out.
|
||||||
last_partially_played_figure: str | None = None
|
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
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class App:
|
class App:
|
||||||
@@ -32,6 +37,7 @@ class App:
|
|||||||
bus: EventBus
|
bus: EventBus
|
||||||
mouse: MusicMouseDevice
|
mouse: MusicMouseDevice
|
||||||
player: Player
|
player: Player
|
||||||
|
library: MusicLibrary
|
||||||
playlists: dict[str, Playlist]
|
playlists: dict[str, Playlist]
|
||||||
clock: Clock = field(default_factory=RealClock)
|
clock: Clock = field(default_factory=RealClock)
|
||||||
state: AppState = field(default_factory=AppState)
|
state: AppState = field(default_factory=AppState)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Self
|
from typing import Annotated, Any, Final, Literal, Self
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
@@ -26,24 +26,31 @@ from ruamel.yaml.error import YAMLError
|
|||||||
|
|
||||||
from musicmouse.color import ColorRGBW, parse_color
|
from musicmouse.color import ColorRGBW, parse_color
|
||||||
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
||||||
from musicmouse.media import Playlist, build_playlist
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"SIMULATE",
|
||||||
"Config",
|
"Config",
|
||||||
"ConfigError",
|
"ConfigError",
|
||||||
"FigureColors",
|
"FigureColors",
|
||||||
"FigureConfig",
|
"FigureConfig",
|
||||||
"GeneralConfig",
|
"GeneralConfig",
|
||||||
|
"LibraryConfig",
|
||||||
"MqttConfig",
|
"MqttConfig",
|
||||||
"build_playlists",
|
"WebConfig",
|
||||||
"format_validation_error",
|
"format_validation_error",
|
||||||
"load_config",
|
"load_config",
|
||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||||
|
|
||||||
|
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
|
||||||
|
#: without sound is a supported setup, but it has to be *asked for*: a missing key is an
|
||||||
|
#: error, so a config that lost a line fails loudly instead of booting into a silent
|
||||||
|
#: mouse that looks like it is working.
|
||||||
|
SIMULATE: Final = "simulate"
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
"""Raised with an already human-readable, multi-line message."""
|
"""Raised with an already human-readable, multi-line message."""
|
||||||
@@ -101,6 +108,17 @@ class FigureColors(_Strict):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_folder(folder: Path, info: ValidationInfo, *, must_exist: bool) -> Path:
|
||||||
|
"""Make a configured path absolute against the config file, and optionally check it."""
|
||||||
|
context = info.context or {}
|
||||||
|
base = context.get("config_dir")
|
||||||
|
if base is not None and not folder.is_absolute():
|
||||||
|
folder = (Path(base) / folder).resolve()
|
||||||
|
if must_exist and context.get("check_paths", True) and not folder.is_dir():
|
||||||
|
raise ValueError(f"no such directory: {folder}")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
class MqttConfig(_Strict):
|
class MqttConfig(_Strict):
|
||||||
server: str
|
server: str
|
||||||
port: int = Field(default=1883, ge=1, le=65535)
|
port: int = Field(default=1883, ge=1, le=65535)
|
||||||
@@ -113,18 +131,63 @@ class MqttConfig(_Strict):
|
|||||||
reconnect_interval: float = Field(default=10.0, gt=0)
|
reconnect_interval: float = Field(default=10.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
class GeneralConfig(_Strict):
|
class LibraryConfig(_Strict):
|
||||||
#: Root folder holding one subfolder per figure.
|
"""Where the music lives.
|
||||||
figure_folder: Path
|
|
||||||
|
|
||||||
serial_port: str = "/dev/ttyUSB0"
|
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``,
|
||||||
|
``Kinderpodcasts`` - are fixed names, not settings; see
|
||||||
|
:mod:`musicmouse.library.sections`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
root: Path
|
||||||
|
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
||||||
|
cache: Path = Path(".musicmouse-cache")
|
||||||
|
|
||||||
|
@field_validator("root")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||||
|
return _resolve_folder(folder, info, must_exist=True)
|
||||||
|
|
||||||
|
@field_validator("cache")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||||
|
return _resolve_folder(folder, info, must_exist=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def figure_folder(self) -> Path:
|
||||||
|
return self.root / "Figuren"
|
||||||
|
|
||||||
|
|
||||||
|
class WebConfig(_Strict):
|
||||||
|
"""The web front-end. Omit the whole section to run without it."""
|
||||||
|
|
||||||
|
#: A LAN appliance with no auth; binding to all interfaces is the point.
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = Field(default=8080, ge=1, le=65535)
|
||||||
|
#: Built frontend to serve at ``/``. Omit to expose only the JSON API.
|
||||||
|
static_dir: Path | None = None
|
||||||
|
|
||||||
|
@field_validator("static_dir")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None:
|
||||||
|
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralConfig(_Strict):
|
||||||
|
library: LibraryConfig
|
||||||
|
|
||||||
|
#: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the
|
||||||
|
#: web front-end is a complete way to drive the player on its own. Required.
|
||||||
|
serial_port: str
|
||||||
baudrate: int = Field(default=115200, gt=0)
|
baudrate: int = Field(default=115200, gt=0)
|
||||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||||
|
|
||||||
#: ALSA output device passed to VLC, e.g. "hw:0,0"; null for VLC's default.
|
#: ALSA output device passed to VLC, e.g. ``"hw:0,0"`` or ``"default"``, or
|
||||||
alsa_device: str | None = None
|
#: ``"simulate"`` for a player that makes no sound. Required.
|
||||||
|
alsa_device: str
|
||||||
|
|
||||||
mqtt: MqttConfig | None = None
|
mqtt: MqttConfig | None = None
|
||||||
|
web: WebConfig | None = None
|
||||||
|
|
||||||
min_volume: int = Field(default=0, ge=0, le=200)
|
min_volume: int = Field(default=0, ge=0, le=200)
|
||||||
max_volume: int = Field(default=100, ge=0, le=200)
|
max_volume: int = Field(default=100, ge=0, le=200)
|
||||||
@@ -134,6 +197,14 @@ class GeneralConfig(_Strict):
|
|||||||
|
|
||||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serial_simulated(self) -> bool:
|
||||||
|
return self.serial_port == SIMULATE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_simulated(self) -> bool:
|
||||||
|
return self.alsa_device == SIMULATE
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_volumes(self) -> Self:
|
def _check_volumes(self) -> Self:
|
||||||
if self.min_volume > self.max_volume:
|
if self.min_volume > self.max_volume:
|
||||||
@@ -147,22 +218,15 @@ class GeneralConfig(_Strict):
|
|||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@field_validator("figure_folder")
|
|
||||||
@classmethod
|
|
||||||
def _resolve_figure_folder(cls, folder: Path, info: ValidationInfo) -> Path:
|
|
||||||
context = info.context or {}
|
|
||||||
base = context.get("config_dir")
|
|
||||||
if base is not None and not folder.is_absolute():
|
|
||||||
folder = (Path(base) / folder).resolve()
|
|
||||||
if context.get("check_paths", True) and not folder.is_dir():
|
|
||||||
raise ValueError(f"no such directory: {folder}")
|
|
||||||
return folder
|
|
||||||
|
|
||||||
|
|
||||||
class FigureConfig(_Strict):
|
class FigureConfig(_Strict):
|
||||||
#: RFID tag id as hex, e.g. "04a1b2c3d4".
|
#: RFID tag id as hex, e.g. "04a1b2c3d4".
|
||||||
id: TagId
|
id: TagId
|
||||||
colors: FigureColors
|
colors: FigureColors
|
||||||
|
#: What this figure holds. Unlike the other shelves a figure folder is named after
|
||||||
|
#: the figurine rather than its contents, so nothing on disk says whether it is an
|
||||||
|
#: album or an audiobook - and the browse view draws the two differently.
|
||||||
|
kind: Literal["music", "book"] = "music"
|
||||||
|
|
||||||
|
|
||||||
class Config(_Strict):
|
class Config(_Strict):
|
||||||
@@ -185,16 +249,13 @@ class Config(_Strict):
|
|||||||
"""Tag id -> figure name, as handed to the device."""
|
"""Tag id -> figure name, as handed to the device."""
|
||||||
return {figure.id: name for name, figure in self.figures.items()}
|
return {figure.id: name for name, figure in self.figures.items()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def figure_kinds(self) -> dict[str, Literal["music", "book"]]:
|
||||||
|
"""Figure name -> what it holds, as handed to the library scanner."""
|
||||||
|
return {name: figure.kind for name, figure in self.figures.items()}
|
||||||
|
|
||||||
def folder_for(self, figure: str) -> Path:
|
def folder_for(self, figure: str) -> Path:
|
||||||
return self.general.figure_folder / figure
|
return self.general.library.figure_folder / figure
|
||||||
|
|
||||||
|
|
||||||
def build_playlists(config: Config) -> dict[str, Playlist]:
|
|
||||||
"""One playlist per figure, from ``<figure_folder>/<figure_name>``, alphabetically."""
|
|
||||||
return {
|
|
||||||
name: build_playlist(name, config.folder_for(name), config.general.audio_extensions)
|
|
||||||
for name in config.figures
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_validation_error(error: ValidationError) -> str:
|
def format_validation_error(error: ValidationError) -> str:
|
||||||
@@ -209,6 +270,8 @@ def format_validation_error(error: ValidationError) -> str:
|
|||||||
message = message.removeprefix(prefix)
|
message = message.removeprefix(prefix)
|
||||||
if entry["type"] == "extra_forbidden":
|
if entry["type"] == "extra_forbidden":
|
||||||
message = "unknown option (check the spelling against config.yml.example)"
|
message = "unknown option (check the spelling against config.yml.example)"
|
||||||
|
elif entry["type"] == "missing":
|
||||||
|
message = "required (see config.yml.example)"
|
||||||
lines.append(f" {location or '<root>'}: {message}")
|
lines.append(f" {location or '<root>'}: {message}")
|
||||||
plural = "s" if len(lines) != 1 else ""
|
plural = "s" if len(lines) != 1 else ""
|
||||||
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
|
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
|
||||||
|
|||||||
22
python-backend/musicmouse/devices/null_transport.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""A transport with nothing on the other end.
|
||||||
|
|
||||||
|
The web front-end is a complete way to drive the mouse, so the backend has to be
|
||||||
|
useful on a machine that has no mouse attached - a spare Pi, a laptop, a container.
|
||||||
|
Rather than making :class:`~musicmouse.devices.mouse.MusicMouseDevice` optional and
|
||||||
|
teaching every reaction to cope with its absence, the device is built as usual against
|
||||||
|
a transport that drops what it is handed. Lighting reactions still run; their bytes go
|
||||||
|
nowhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["NullTransport"]
|
||||||
|
|
||||||
|
|
||||||
|
class NullTransport:
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return False
|
||||||
@@ -43,16 +43,33 @@ class Player(Protocol):
|
|||||||
def track_index(self) -> int: ...
|
def track_index(self) -> int: ...
|
||||||
@property
|
@property
|
||||||
def current_track(self) -> Track | None: ...
|
def current_track(self) -> Track | None: ...
|
||||||
|
@property
|
||||||
|
def position(self) -> float:
|
||||||
|
"""Seconds into the current track. ``0.0`` when nothing is loaded.
|
||||||
|
|
||||||
|
Read on demand rather than announced: a progress bar wants this twice a second,
|
||||||
|
and an event at that rate would flood the bus, the MQTT service and the log for
|
||||||
|
the benefit of one front-end.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
"""Length of the current track in seconds, or ``0.0`` when unknown."""
|
||||||
|
...
|
||||||
|
|
||||||
def set_playlist(self, playlist: Playlist) -> None: ...
|
def set_playlist(self, playlist: Playlist) -> None: ...
|
||||||
def play(self) -> None: ...
|
def play(self) -> None: ...
|
||||||
def play_from_start(self) -> None: ...
|
def play_from_start(self) -> None: ...
|
||||||
|
def play_track(self, index: int) -> None: ...
|
||||||
def pause(self) -> None: ...
|
def pause(self) -> None: ...
|
||||||
def stop(self) -> None: ...
|
def stop(self) -> None: ...
|
||||||
def next_track(self) -> None: ...
|
def next_track(self) -> None: ...
|
||||||
def previous_track(self) -> None: ...
|
def previous_track(self) -> None: ...
|
||||||
|
def seek(self, position: float) -> None: ...
|
||||||
def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ...
|
def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ...
|
||||||
def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ...
|
def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ...
|
||||||
|
def set_volume_limits(self, minimum: int, maximum: int) -> None: ...
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Long-running task, if the implementation needs one."""
|
"""Long-running task, if the implementation needs one."""
|
||||||
@@ -113,6 +130,32 @@ class PlayerBase:
|
|||||||
return None
|
return None
|
||||||
return self._playlist[self._index]
|
return self._playlist[self._index]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
|
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
|
||||||
|
clamped = self._clamp(volume)
|
||||||
|
if clamped == self._volume:
|
||||||
|
return
|
||||||
|
self._volume = clamped
|
||||||
|
self._apply_volume(clamped)
|
||||||
|
self._announce_volume(source)
|
||||||
|
|
||||||
|
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
|
||||||
|
self.set_volume(self._volume + delta, source=source)
|
||||||
|
|
||||||
|
def set_volume_limits(self, minimum: int, maximum: int) -> None:
|
||||||
|
"""Re-clamp to a new allowed range, and pull the current volume into it.
|
||||||
|
|
||||||
|
Parent mode edits these while the mouse is playing, so they cannot only be
|
||||||
|
constructor arguments.
|
||||||
|
"""
|
||||||
|
self._min_volume = minimum
|
||||||
|
self._max_volume = maximum
|
||||||
|
self.set_volume(self._volume)
|
||||||
|
|
||||||
|
def _apply_volume(self, volume: int) -> None:
|
||||||
|
"""Push the new volume at whatever actually makes sound. No-op by default."""
|
||||||
|
|
||||||
# ---------------------------------------------------------------- internals
|
# ---------------------------------------------------------------- internals
|
||||||
|
|
||||||
def _clamp(self, volume: int) -> int:
|
def _clamp(self, volume: int) -> int:
|
||||||
@@ -181,6 +224,17 @@ class VlcPlayer(PlayerBase):
|
|||||||
self._attach_events()
|
self._attach_events()
|
||||||
self._media_player.audio_set_volume(self._volume)
|
self._media_player.audio_set_volume(self._volume)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position(self) -> float:
|
||||||
|
# libVLC reports -1 for both of these until a media is actually opened.
|
||||||
|
return max(0.0, float(self._media_player.get_time()) / 1000)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
return max(0.0, float(self._media_player.get_length()) / 1000)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ actions
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
def set_playlist(self, playlist: Playlist) -> None:
|
def set_playlist(self, playlist: Playlist) -> None:
|
||||||
@@ -201,10 +255,13 @@ class VlcPlayer(PlayerBase):
|
|||||||
self._list_player.play()
|
self._list_player.play()
|
||||||
|
|
||||||
def play_from_start(self) -> None:
|
def play_from_start(self) -> None:
|
||||||
|
self.play_track(0)
|
||||||
|
|
||||||
|
def play_track(self, index: int) -> None:
|
||||||
if self._playlist is None or not self._playlist:
|
if self._playlist is None or not self._playlist:
|
||||||
_log.warning("Nothing to play: the playlist is empty")
|
_log.warning("Nothing to play: the playlist is empty")
|
||||||
return
|
return
|
||||||
self._list_player.play_item_at_index(0)
|
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
self._media_player.set_pause(1)
|
self._media_player.set_pause(1)
|
||||||
@@ -218,16 +275,11 @@ class VlcPlayer(PlayerBase):
|
|||||||
def previous_track(self) -> None:
|
def previous_track(self) -> None:
|
||||||
self._list_player.previous()
|
self._list_player.previous()
|
||||||
|
|
||||||
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
|
def seek(self, position: float) -> None:
|
||||||
clamped = self._clamp(volume)
|
self._media_player.set_time(int(max(0.0, position) * 1000))
|
||||||
if clamped == self._volume:
|
|
||||||
return
|
|
||||||
self._volume = clamped
|
|
||||||
self._media_player.audio_set_volume(clamped)
|
|
||||||
self._announce_volume(source)
|
|
||||||
|
|
||||||
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
|
def _apply_volume(self, volume: int) -> None:
|
||||||
self.set_volume(self._volume + delta, source=source)
|
self._media_player.audio_set_volume(volume)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Poll for state libVLC does not reliably report by event."""
|
"""Poll for state libVLC does not reliably report by event."""
|
||||||
@@ -249,6 +301,21 @@ class VlcPlayer(PlayerBase):
|
|||||||
self._volume = volume
|
self._volume = volume
|
||||||
self._announce_volume()
|
self._announce_volume()
|
||||||
self._set_playing(bool(self._list_player.is_playing()))
|
self._set_playing(bool(self._list_player.is_playing()))
|
||||||
|
self._sync_index()
|
||||||
|
|
||||||
|
def _sync_index(self) -> None:
|
||||||
|
"""Read which track is playing back off libVLC.
|
||||||
|
|
||||||
|
Asking the media player which media it holds works on every build; the
|
||||||
|
``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare
|
||||||
|
int rather than a Media, and the index would then never move off zero.
|
||||||
|
"""
|
||||||
|
media = self._media_player.get_media()
|
||||||
|
if media is None:
|
||||||
|
return
|
||||||
|
index = self._mrl_to_index.get(media.get_mrl())
|
||||||
|
if index is not None:
|
||||||
|
self._set_index(index)
|
||||||
|
|
||||||
def _attach_events(self) -> None:
|
def _attach_events(self) -> None:
|
||||||
vlc = self._vlc
|
vlc = self._vlc
|
||||||
@@ -274,11 +341,6 @@ class VlcPlayer(PlayerBase):
|
|||||||
self._set_playing(False)
|
self._set_playing(False)
|
||||||
self._announce_playlist_finished()
|
self._announce_playlist_finished()
|
||||||
|
|
||||||
def _on_next_item(self, event: Any) -> None:
|
def _on_next_item(self, _event: Any) -> None:
|
||||||
try:
|
# The event says *when* to look; what it carries is not portable, so ignore it.
|
||||||
mrl = event.u.media.get_mrl()
|
self._sync_index()
|
||||||
except AttributeError: # pragma: no cover - depends on the libVLC build
|
|
||||||
return
|
|
||||||
index = self._mrl_to_index.get(mrl)
|
|
||||||
if index is not None:
|
|
||||||
self._set_index(index)
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
from typing import override
|
||||||
|
|
||||||
from musicmouse.effects import (
|
from musicmouse.effects import (
|
||||||
EffectAlexaSwipeConfig,
|
EffectAlexaSwipeConfig,
|
||||||
@@ -366,6 +367,7 @@ class SetButtonBrightness:
|
|||||||
button: Button
|
button: Button
|
||||||
brightness: float
|
brightness: float
|
||||||
|
|
||||||
|
@override
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
|
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,20 @@ class PlayFigureRequested(IntentEvent):
|
|||||||
restart: bool = True
|
restart: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class PlayAlbumRequested(IntentEvent):
|
||||||
|
"""Start any album from the library, figure or not."""
|
||||||
|
|
||||||
|
album_id: str
|
||||||
|
track_index: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class SeekRequested(IntentEvent):
|
||||||
|
#: Seconds from the start of the current track.
|
||||||
|
position: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class VolumeChangeRequested(IntentEvent):
|
class VolumeChangeRequested(IntentEvent):
|
||||||
delta: int
|
delta: int
|
||||||
|
|||||||
164
python-backend/musicmouse/library/__init__.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""The music collection: what exists, what it is called, and what colour it is.
|
||||||
|
|
||||||
|
The scan is the only part of this backend that touches hundreds of files, so it runs
|
||||||
|
in a worker thread and its results are cached. Everything the rest of the app sees is
|
||||||
|
plain immutable data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from musicmouse.library.analysis import ANALYZER_VERSION, Analyzer, BeatGrid, NullAnalyzer
|
||||||
|
from musicmouse.library.cache import Fingerprint, LibraryCache
|
||||||
|
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
|
||||||
|
from musicmouse.library.scanner import scan_library
|
||||||
|
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||||
|
from musicmouse.media import Playlist
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ANALYZER_VERSION",
|
||||||
|
"SECTIONS",
|
||||||
|
"Album",
|
||||||
|
"Analyzer",
|
||||||
|
"BeatGrid",
|
||||||
|
"LibraryCache",
|
||||||
|
"LibraryTrack",
|
||||||
|
"MusicLibrary",
|
||||||
|
"NullAnalyzer",
|
||||||
|
"album_id",
|
||||||
|
"track_key",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MusicLibrary:
|
||||||
|
"""An immutable index of albums, rebuilt wholesale rather than mutated in place."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: Path,
|
||||||
|
cache: LibraryCache,
|
||||||
|
extensions: frozenset[str],
|
||||||
|
*,
|
||||||
|
analyzer: Analyzer | None = None,
|
||||||
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.cache = cache
|
||||||
|
self.extensions = extensions
|
||||||
|
self.analyzer: Analyzer = analyzer or NullAnalyzer()
|
||||||
|
#: What each figure holds. The only thing a folder name cannot say.
|
||||||
|
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
|
||||||
|
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- reading
|
||||||
|
|
||||||
|
@property
|
||||||
|
def albums(self) -> list[Album]:
|
||||||
|
return [album for album, _ in self._entries.values()]
|
||||||
|
|
||||||
|
def get(self, identifier: str | None) -> Album | None:
|
||||||
|
entry = self._entries.get(identifier) if identifier else None
|
||||||
|
return entry[0] if entry else None
|
||||||
|
|
||||||
|
def figure_playlists(self) -> dict[str, Playlist]:
|
||||||
|
"""One playlist per figure folder, keyed by figure name.
|
||||||
|
|
||||||
|
The figure path and the web path must hand the player the *same* object for the
|
||||||
|
same album: ``reactions.playback.play_figure`` resumes on an identity check.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
album.figure: album.to_playlist()
|
||||||
|
for album, _ in self._entries.values()
|
||||||
|
if album.figure is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
||||||
|
album = self.get(identifier)
|
||||||
|
if album is None or not 0 <= index < len(album.tracks):
|
||||||
|
return None
|
||||||
|
return self.cache.load_beats(track_key(album.tracks[index].path))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- writing
|
||||||
|
|
||||||
|
async def refresh(self) -> None:
|
||||||
|
"""Rescan from disk. Blocking work happens off the loop; the swap is atomic."""
|
||||||
|
known = dict(self._entries)
|
||||||
|
entries = await asyncio.to_thread(
|
||||||
|
scan_library,
|
||||||
|
self.root,
|
||||||
|
self.extensions,
|
||||||
|
self.cache,
|
||||||
|
known=known,
|
||||||
|
figure_kinds=self.figure_kinds,
|
||||||
|
)
|
||||||
|
self._entries = await asyncio.to_thread(self._with_analysis, entries)
|
||||||
|
await asyncio.to_thread(self.cache.store_index, self._entries)
|
||||||
|
|
||||||
|
def _with_analysis(
|
||||||
|
self, entries: dict[str, tuple[Album, Fingerprint]]
|
||||||
|
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||||
|
"""Fold cached analysis results into the freshly scanned index.
|
||||||
|
|
||||||
|
Skipped entirely while ``analysis/`` is empty, which is the normal case until an
|
||||||
|
analyzer has actually been run - no point stat-ing 900 files that cannot exist.
|
||||||
|
"""
|
||||||
|
if not any(self.cache.analysis.glob("*.json")):
|
||||||
|
return entries
|
||||||
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
|
for identifier, (album, fingerprint) in entries.items():
|
||||||
|
tracks = tuple(
|
||||||
|
replace(track, analysis=self.cache.load_analysis(track_key(track.path)))
|
||||||
|
for track in album.tracks
|
||||||
|
)
|
||||||
|
out[identifier] = (replace(album, tracks=tracks), fingerprint)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def build(
|
||||||
|
cls,
|
||||||
|
root: Path,
|
||||||
|
cache_dir: Path,
|
||||||
|
extensions: frozenset[str],
|
||||||
|
*,
|
||||||
|
analyzer: Analyzer | None = None,
|
||||||
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
|
) -> MusicLibrary:
|
||||||
|
cache = LibraryCache(cache_dir)
|
||||||
|
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds)
|
||||||
|
library._entries = await asyncio.to_thread(cache.load_index)
|
||||||
|
await library.refresh()
|
||||||
|
return library
|
||||||
|
|
||||||
|
async def analyze_pending(self) -> int:
|
||||||
|
"""Run the analyzer over tracks that have no current result.
|
||||||
|
|
||||||
|
Never called during startup: the index is what playback needs, and analysis is
|
||||||
|
minutes of DSP per track. Results are keyed by file content, so they can equally
|
||||||
|
well be produced on a faster machine and the ``analysis/`` folder copied over.
|
||||||
|
"""
|
||||||
|
analyzer = self.analyzer
|
||||||
|
if analyzer.version < ANALYZER_VERSION:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
done = 0
|
||||||
|
for album in self.albums:
|
||||||
|
for track in album.tracks:
|
||||||
|
key = track_key(track.path)
|
||||||
|
cached = self.cache.load_analysis(key)
|
||||||
|
if cached is not None and cached.version >= analyzer.version:
|
||||||
|
continue
|
||||||
|
analysis, grid = await asyncio.to_thread(analyzer.analyze, track.path)
|
||||||
|
if grid is not None:
|
||||||
|
self.cache.store_beats(key, grid)
|
||||||
|
self.cache.store_analysis(key, analysis)
|
||||||
|
done += 1
|
||||||
|
if done:
|
||||||
|
_log.info("Analyzed %d tracks", done)
|
||||||
|
return done
|
||||||
106
python-backend/musicmouse/library/analysis.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Offline audio analysis: the seam, not the implementation.
|
||||||
|
|
||||||
|
Nothing here computes anything yet. What it does is fix the shape of the results so
|
||||||
|
that the analyzer can arrive later without touching the scanner, the cache format, the
|
||||||
|
API contract or the frontend's data flow.
|
||||||
|
|
||||||
|
Two rules hold the design together:
|
||||||
|
|
||||||
|
* **Scalars travel with the index, time series do not.** A 25-minute podcast at 120 BPM
|
||||||
|
has ~3000 beats; 900 tracks of that inside ``GET /api/library`` would be tens of
|
||||||
|
megabytes. :class:`TrackAnalysis` is a handful of floats and rides along; the beat
|
||||||
|
grid lives in its own file and is fetched for the one track that is playing.
|
||||||
|
* **Every field is optional with a default.** Adding ``danceability`` later needs no
|
||||||
|
migration and no cache wipe: unknown keys on disk are dropped on load, missing ones
|
||||||
|
fall back to the default. Only :data:`ANALYZER_VERSION` moving past what a file
|
||||||
|
records marks that file stale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, fields
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final, Protocol
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ANALYZER_VERSION",
|
||||||
|
"Analyzer",
|
||||||
|
"BeatGrid",
|
||||||
|
"NullAnalyzer",
|
||||||
|
"TrackAnalysis",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Bumped when an analyzer's output changes meaning. Cached results recorded under a
|
||||||
|
#: lower version are recomputed; results at or above it are left alone.
|
||||||
|
ANALYZER_VERSION: Final = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TrackAnalysis:
|
||||||
|
"""What an animation may want to know about a track, in a few dozen bytes."""
|
||||||
|
|
||||||
|
#: The :data:`ANALYZER_VERSION` that produced this. ``0`` means "never analyzed".
|
||||||
|
version: int = 0
|
||||||
|
tempo: float | None = None
|
||||||
|
#: 0..1 overall loudness and drive.
|
||||||
|
energy: float | None = None
|
||||||
|
#: 0..1 mood, dark and calm through bright and happy.
|
||||||
|
valence: float | None = None
|
||||||
|
#: 0..1 spectral centroid. Drives the background hue.
|
||||||
|
brightness: float | None = None
|
||||||
|
#: Whether a beat grid for this track exists on disk.
|
||||||
|
beats: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_analyzed(self) -> bool:
|
||||||
|
return self.version >= ANALYZER_VERSION
|
||||||
|
|
||||||
|
def to_json(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: dict[str, Any]) -> TrackAnalysis:
|
||||||
|
"""Load leniently: unknown keys are dropped, missing keys take their default.
|
||||||
|
|
||||||
|
This is what makes the cache survive an analyzer that grew a field.
|
||||||
|
"""
|
||||||
|
known = {f.name for f in fields(cls)}
|
||||||
|
return cls(**{k: v for k, v in data.items() if k in known})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BeatGrid:
|
||||||
|
"""Beat onsets in seconds with a 0..1 strength each, for beat-synced animation."""
|
||||||
|
|
||||||
|
times: tuple[float, ...]
|
||||||
|
strengths: tuple[float, ...]
|
||||||
|
|
||||||
|
def to_json(self) -> dict[str, Any]:
|
||||||
|
# Flat pairs: half the JSON punctuation of a list of objects.
|
||||||
|
return {"beats": [x for pair in zip(self.times, self.strengths, strict=True) for x in pair]}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: dict[str, Any]) -> BeatGrid:
|
||||||
|
flat: list[float] = data["beats"]
|
||||||
|
return cls(tuple(flat[0::2]), tuple(flat[1::2]))
|
||||||
|
|
||||||
|
|
||||||
|
class Analyzer(Protocol):
|
||||||
|
"""Turns one audio file into cacheable analysis results.
|
||||||
|
|
||||||
|
Implementations are CPU-bound and run off the event loop. The real one will live
|
||||||
|
behind an optional dependency group so the device never installs numpy to play music.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version: int
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class NullAnalyzer:
|
||||||
|
"""Analyzes nothing. Keeps the wiring exercised until a real analyzer lands."""
|
||||||
|
|
||||||
|
version = 0
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None]: # noqa: ARG002
|
||||||
|
return TrackAnalysis(), None
|
||||||
204
python-backend/musicmouse/library/cache.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Where scan results live between runs.
|
||||||
|
|
||||||
|
A directory rather than a single file, because the three kinds of content cost wildly
|
||||||
|
different amounts to produce::
|
||||||
|
|
||||||
|
<cache_dir>/
|
||||||
|
├── index.json cheap: tags and structure. Thrown away freely.
|
||||||
|
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame
|
||||||
|
└── analysis/<track_key>.json expensive: minutes of DSP per track
|
||||||
|
analysis/<track_key>.beats.json
|
||||||
|
|
||||||
|
That split is the whole point. A rescan must be free to rebuild ``index.json`` without
|
||||||
|
destroying analysis, so everything expensive is keyed by a *content* key (see
|
||||||
|
:func:`~musicmouse.library.models.track_key`) rather than by album id - renaming a
|
||||||
|
folder or re-sorting a section then costs nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from musicmouse.library.analysis import BeatGrid, TrackAnalysis
|
||||||
|
from musicmouse.library.models import Album, LibraryTrack
|
||||||
|
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["Fingerprint", "LibraryCache"]
|
||||||
|
|
||||||
|
#: Bump whenever the scanner's *derivation* changes - how a title, artist or series is
|
||||||
|
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
|
||||||
|
#: files are untouched, so otherwise a change to that logic is invisible until somebody
|
||||||
|
#: edits their music folder.
|
||||||
|
_INDEX_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Fingerprint:
|
||||||
|
"""What makes a folder's cached entry still valid: its files, sizes and mtimes."""
|
||||||
|
|
||||||
|
files: tuple[tuple[str, int, int], ...]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def of(cls, paths: list[Path]) -> Fingerprint:
|
||||||
|
entries: list[tuple[str, int, int]] = []
|
||||||
|
for path in paths:
|
||||||
|
stat = path.stat()
|
||||||
|
entries.append((path.name, stat.st_size, int(stat.st_mtime)))
|
||||||
|
return cls(tuple(entries))
|
||||||
|
|
||||||
|
def to_json(self) -> list[list[Any]]:
|
||||||
|
return [list(entry) for entry in self.files]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: list[list[Any]]) -> Fingerprint:
|
||||||
|
return cls(tuple((str(n), int(s), int(m)) for n, s, m in data))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_atomic(path: Path, payload: str) -> None:
|
||||||
|
"""Write via a sibling temp file so a crash never leaves a half-written cache."""
|
||||||
|
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||||
|
temp.write_text(payload, encoding="utf-8")
|
||||||
|
temp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryCache:
|
||||||
|
def __init__(self, directory: Path) -> None:
|
||||||
|
self.directory = directory
|
||||||
|
self.covers = directory / "covers"
|
||||||
|
self.analysis = directory / "analysis"
|
||||||
|
|
||||||
|
def prepare(self) -> None:
|
||||||
|
for folder in (self.directory, self.covers, self.analysis):
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- covers
|
||||||
|
|
||||||
|
def cover_path(self, album_id: str) -> Path:
|
||||||
|
return self.covers / f"{album_id}.jpg"
|
||||||
|
|
||||||
|
def store_cover(self, album_id: str, data: bytes) -> Path:
|
||||||
|
path = self.cover_path(album_id)
|
||||||
|
path.write_bytes(data)
|
||||||
|
return path
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ analysis
|
||||||
|
|
||||||
|
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
||||||
|
path = self.analysis / f"{key}.json"
|
||||||
|
try:
|
||||||
|
return TrackAnalysis.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def store_analysis(self, key: str, analysis: TrackAnalysis) -> None:
|
||||||
|
_write_atomic(self.analysis / f"{key}.json", json.dumps(analysis.to_json()))
|
||||||
|
|
||||||
|
def load_beats(self, key: str) -> BeatGrid | None:
|
||||||
|
path = self.analysis / f"{key}.beats.json"
|
||||||
|
try:
|
||||||
|
return BeatGrid.from_json(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def store_beats(self, key: str, grid: BeatGrid) -> None:
|
||||||
|
_write_atomic(self.analysis / f"{key}.beats.json", json.dumps(grid.to_json()))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- index
|
||||||
|
|
||||||
|
def load_index(self) -> dict[str, tuple[Album, Fingerprint]]:
|
||||||
|
path = self.directory / "index.json"
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
if raw.get("version") != _INDEX_VERSION:
|
||||||
|
_log.info("Library index is from an older version; rescanning from scratch")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
|
for entry in raw.get("albums", []):
|
||||||
|
try:
|
||||||
|
out[entry["id"]] = (_album_from_json(entry), Fingerprint.from_json(entry["files"]))
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
_log.debug("Dropping unreadable index entry %r", entry.get("id"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def store_index(self, albums: dict[str, tuple[Album, Fingerprint]]) -> None:
|
||||||
|
payload = {
|
||||||
|
"version": _INDEX_VERSION,
|
||||||
|
"albums": [
|
||||||
|
_album_to_json(album) | {"files": fingerprint.to_json()}
|
||||||
|
for album, fingerprint in albums.values()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.prepare()
|
||||||
|
_write_atomic(self.directory / "index.json", json.dumps(payload, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ serialisation
|
||||||
|
|
||||||
|
|
||||||
|
def _album_to_json(album: Album) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": album.id,
|
||||||
|
"section": album.section,
|
||||||
|
"kind": album.kind,
|
||||||
|
"title": album.title,
|
||||||
|
"artist": album.artist,
|
||||||
|
"series": album.series,
|
||||||
|
"figure": album.figure,
|
||||||
|
"colors": list(album.colors),
|
||||||
|
"folder": str(album.folder),
|
||||||
|
"cover": str(album.cover) if album.cover else None,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"path": str(track.path),
|
||||||
|
"title": track.title,
|
||||||
|
"duration": track.duration,
|
||||||
|
"analysis": track.analysis.to_json() if track.analysis else None,
|
||||||
|
}
|
||||||
|
for track in album.tracks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _album_from_json(data: dict[str, Any]) -> Album:
|
||||||
|
section = str(data["section"])
|
||||||
|
if section not in SECTIONS:
|
||||||
|
raise ValueError(f"unknown section {section!r}")
|
||||||
|
# Read back what was stored rather than re-deriving it from the section: a figure's
|
||||||
|
# kind comes from the config, so the section cannot answer for it.
|
||||||
|
kind = str(data["kind"])
|
||||||
|
if kind not in ("music", "book"):
|
||||||
|
raise ValueError(f"unknown kind {kind!r}")
|
||||||
|
red, green, blue = data["colors"]
|
||||||
|
return Album(
|
||||||
|
id=str(data["id"]),
|
||||||
|
section=section,
|
||||||
|
kind=cast("AlbumKind", kind),
|
||||||
|
title=str(data["title"]),
|
||||||
|
artist=str(data["artist"]),
|
||||||
|
series=data["series"],
|
||||||
|
figure=data["figure"],
|
||||||
|
colors=(str(red), str(green), str(blue)),
|
||||||
|
folder=Path(data["folder"]),
|
||||||
|
cover=Path(data["cover"]) if data["cover"] else None,
|
||||||
|
tracks=tuple(
|
||||||
|
LibraryTrack(
|
||||||
|
path=Path(track["path"]),
|
||||||
|
title=str(track["title"]),
|
||||||
|
duration=float(track["duration"]),
|
||||||
|
analysis=(
|
||||||
|
TrackAnalysis.from_json(track["analysis"]) if track.get("analysis") else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for track in data["tracks"]
|
||||||
|
),
|
||||||
|
)
|
||||||
82
python-backend/musicmouse/library/colors.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Three colours per album, from its cover art.
|
||||||
|
|
||||||
|
The frontend paints an album card with these and the LED strips run an effect in them,
|
||||||
|
so a web-started album looks the same on the shelf as it does on the screen. Extraction
|
||||||
|
happens once per album ever - the result is cached next to the cover.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import colorsys
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from musicmouse.library.models import AlbumColors
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["colors_from_cover", "colors_from_id"]
|
||||||
|
|
||||||
|
#: Quantize to this many candidates before ranking. More just returns near-duplicates.
|
||||||
|
_PALETTE_SIZE = 8
|
||||||
|
#: Downscale first: colour proportions survive, the decode gets ~100x cheaper.
|
||||||
|
_SAMPLE_SIZE = (64, 64)
|
||||||
|
|
||||||
|
#: Greys and near-blacks read as mud on an RGBW strip and as dirt on a card, so a
|
||||||
|
#: candidate has to clear both bars to count as one of an album's colours.
|
||||||
|
_MIN_SATURATION = 0.25
|
||||||
|
_MIN_VALUE = 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def _hex(r: int, g: int, b: int) -> str:
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def colors_from_id(album_id: str) -> AlbumColors:
|
||||||
|
"""Synthesise a palette when there is no cover to take one from.
|
||||||
|
|
||||||
|
Analogous plus complementary off one hashed hue: distinct per album, never muddy.
|
||||||
|
"""
|
||||||
|
hue = int(hashlib.sha1(album_id.encode()).hexdigest()[:4], 16) % 360
|
||||||
|
out: list[str] = []
|
||||||
|
for offset, saturation, value in ((0, 0.72, 0.95), (30, 0.62, 0.80), (180, 0.70, 0.90)):
|
||||||
|
r, g, b = colorsys.hsv_to_rgb(((hue + offset) % 360) / 360, saturation, value)
|
||||||
|
out.append(_hex(int(r * 255), int(g * 255), int(b * 255)))
|
||||||
|
return out[0], out[1], out[2]
|
||||||
|
|
||||||
|
|
||||||
|
def colors_from_cover(image_data: bytes, album_id: str) -> AlbumColors:
|
||||||
|
"""Rank the cover's dominant colours, dropping the ones that would read as mud.
|
||||||
|
|
||||||
|
Falls back to :func:`colors_from_id` for the slots that do not fill - a black-and-
|
||||||
|
white cover legitimately has no three usable colours.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(BytesIO(image_data)) as image:
|
||||||
|
sample = image.convert("RGB").resize(_SAMPLE_SIZE)
|
||||||
|
quantized = sample.quantize(colors=_PALETTE_SIZE, method=Image.Quantize.MAXCOVERAGE)
|
||||||
|
palette: list[int] = quantized.getpalette() or []
|
||||||
|
# On a quantized ("P" mode) image getcolors() yields (count, palette index).
|
||||||
|
# Pillow's annotation covers every mode, hence the cast.
|
||||||
|
counts = cast("list[tuple[int, int]]", quantized.getcolors() or [])
|
||||||
|
except Exception: # pragma: no cover - defensive around arbitrary embedded art
|
||||||
|
_log.debug("Cover of %s could not be read; using a synthesised palette", album_id)
|
||||||
|
return colors_from_id(album_id)
|
||||||
|
|
||||||
|
picked: list[str] = []
|
||||||
|
for _count, index in sorted(counts, reverse=True):
|
||||||
|
r, g, b = palette[index * 3 : index * 3 + 3]
|
||||||
|
_, saturation, value = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
|
||||||
|
if saturation < _MIN_SATURATION or value < _MIN_VALUE:
|
||||||
|
continue
|
||||||
|
picked.append(_hex(r, g, b))
|
||||||
|
if len(picked) == 3:
|
||||||
|
return picked[0], picked[1], picked[2]
|
||||||
|
|
||||||
|
fallback = colors_from_id(album_id)
|
||||||
|
filled = picked + list(fallback[len(picked) :])
|
||||||
|
return filled[0], filled[1], filled[2]
|
||||||
88
python-backend/musicmouse/library/models.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""What the browse API serves: albums of tracks, with the colours to draw them in."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from musicmouse.library.analysis import TrackAnalysis
|
||||||
|
from musicmouse.library.sections import AlbumKind
|
||||||
|
from musicmouse.media import Playlist, Track
|
||||||
|
|
||||||
|
__all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
|
||||||
|
|
||||||
|
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
||||||
|
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
||||||
|
#: parsing and the frontend gets CSS colours for free.
|
||||||
|
type AlbumColors = tuple[str, str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def album_id(root: Path, folder: Path) -> str:
|
||||||
|
"""A stable id for an album folder, from its path relative to the library root."""
|
||||||
|
relative = folder.relative_to(root).as_posix()
|
||||||
|
return hashlib.sha1(relative.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def track_key(path: Path) -> str:
|
||||||
|
"""Content key for expensive per-track results.
|
||||||
|
|
||||||
|
Keyed on the *file*, not the album, so renaming a folder or re-sorting a section
|
||||||
|
never throws away analysis that took minutes to compute.
|
||||||
|
"""
|
||||||
|
stat = path.stat()
|
||||||
|
material = f"{path.name}:{stat.st_size}:{int(stat.st_mtime)}"
|
||||||
|
return hashlib.sha1(material.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LibraryTrack:
|
||||||
|
path: Path
|
||||||
|
title: str
|
||||||
|
#: Seconds, from the tags. ``0.0`` when the file carries no duration.
|
||||||
|
duration: float = 0.0
|
||||||
|
analysis: TrackAnalysis | None = None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"LibraryTrack({self.title!r}, {self.duration:.0f}s)"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Album:
|
||||||
|
id: str
|
||||||
|
section: str
|
||||||
|
kind: AlbumKind
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
#: Grouping key for audiobooks and podcasts. ``None`` for music, which groups by
|
||||||
|
#: artist instead - the two cases the browse UI's category view distinguishes.
|
||||||
|
series: str | None
|
||||||
|
#: Figure name when this folder sits under ``Figuren``, else ``None``.
|
||||||
|
figure: str | None
|
||||||
|
colors: AlbumColors
|
||||||
|
folder: Path
|
||||||
|
cover: Path | None
|
||||||
|
tracks: tuple[LibraryTrack, ...]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.tracks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
return sum(track.duration for track in self.tracks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def category(self) -> str:
|
||||||
|
"""How the browse view groups this album: series for books, artist for music."""
|
||||||
|
return self.series or self.artist
|
||||||
|
|
||||||
|
def to_playlist(self) -> Playlist:
|
||||||
|
"""The player's view of this album."""
|
||||||
|
return Playlist(
|
||||||
|
name=self.figure or self.title,
|
||||||
|
tracks=tuple(Track(track.path) for track in self.tracks),
|
||||||
|
album_id=self.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Album({self.title!r}, {self.artist!r}, {len(self.tracks)} tracks)"
|
||||||
226
python-backend/musicmouse/library/scanner.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""Turning folders on disk into :class:`~musicmouse.library.models.Album` objects.
|
||||||
|
|
||||||
|
One level under each section folder, every directory holding audio files is one album.
|
||||||
|
Tags come from mutagen; where a section's tags are known to be useless the folder name
|
||||||
|
wins instead (see :mod:`musicmouse.library.sections`). Nothing here raises on bad
|
||||||
|
input - an unreadable file loses its metadata, not the boot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from musicmouse.library.cache import Fingerprint, LibraryCache
|
||||||
|
from musicmouse.library.colors import colors_from_cover, colors_from_id
|
||||||
|
from musicmouse.library.models import Album, LibraryTrack, album_id
|
||||||
|
from musicmouse.library.sections import SECTIONS, AlbumKind, Section
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["scan_library"]
|
||||||
|
|
||||||
|
#: Checked in order for a cover sitting next to the audio.
|
||||||
|
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_files(folder: Path, extensions: frozenset[str]) -> list[Path]:
|
||||||
|
"""The playable files in ``folder``, alphabetically.
|
||||||
|
|
||||||
|
Dotfiles are skipped outright and everything else must match ``audio_extensions``,
|
||||||
|
which is what keeps a podcast folder's ``archive.json`` and its half-finished
|
||||||
|
``.podcast-dl-*.download.tmp`` out of the playlist.
|
||||||
|
"""
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
path
|
||||||
|
for path in folder.iterdir()
|
||||||
|
if path.is_file()
|
||||||
|
and not path.name.startswith(".")
|
||||||
|
and path.suffix.lower() in extensions
|
||||||
|
),
|
||||||
|
key=lambda path: path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tags(path: Path) -> tuple[dict[str, str], float]:
|
||||||
|
"""``(tags, duration)`` for one file. Empty and ``0.0`` when it cannot be read."""
|
||||||
|
try:
|
||||||
|
import mutagen
|
||||||
|
|
||||||
|
audio = mutagen.File(path, easy=True)
|
||||||
|
if audio is None:
|
||||||
|
return {}, 0.0
|
||||||
|
tags = {key: values[0] for key, values in dict(audio).items() if values}
|
||||||
|
return tags, float(getattr(audio.info, "length", 0.0) or 0.0)
|
||||||
|
except Exception: # pragma: no cover - mutagen raises freely on damaged files
|
||||||
|
_log.debug("No readable tags in %s", path)
|
||||||
|
return {}, 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _embedded_art(path: Path) -> bytes | None:
|
||||||
|
try:
|
||||||
|
import mutagen
|
||||||
|
|
||||||
|
raw = mutagen.File(path)
|
||||||
|
if raw is None or raw.tags is None:
|
||||||
|
return None
|
||||||
|
for key in raw.tags:
|
||||||
|
if key.startswith("APIC"):
|
||||||
|
data: bytes = raw.tags[key].data
|
||||||
|
return data
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
_log.debug("No readable embedded art in %s", path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_folder_name(name: str) -> tuple[str, str]:
|
||||||
|
"""``"Conni - Conni in den Bergen"`` -> ``("Conni", "Conni in den Bergen")``."""
|
||||||
|
artist, separator, title = name.partition(" - ")
|
||||||
|
return (artist, title) if separator else ("", name)
|
||||||
|
|
||||||
|
|
||||||
|
def _most_common(values: list[str]) -> str:
|
||||||
|
"""The tag value most files in an album agree on, ignoring blanks."""
|
||||||
|
counted = Counter(value for value in values if value)
|
||||||
|
return counted.most_common(1)[0][0] if counted else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_for(
|
||||||
|
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
|
||||||
|
) -> tuple[Path | None, bytes | None]:
|
||||||
|
for name in _COVER_NAMES:
|
||||||
|
candidate = folder / name
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate, candidate.read_bytes()
|
||||||
|
for path in paths[:3]:
|
||||||
|
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
||||||
|
art = _embedded_art(path)
|
||||||
|
if art is not None:
|
||||||
|
return cache.store_cover(identifier, art), art
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def scan_album(
|
||||||
|
folder: Path,
|
||||||
|
*,
|
||||||
|
root: Path,
|
||||||
|
section_name: str,
|
||||||
|
section: Section,
|
||||||
|
extensions: frozenset[str],
|
||||||
|
cache: LibraryCache,
|
||||||
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
|
) -> tuple[Album, Fingerprint] | None:
|
||||||
|
paths = _audio_files(folder, extensions)
|
||||||
|
if not paths:
|
||||||
|
_log.debug("No audio files in %s", folder)
|
||||||
|
return None
|
||||||
|
if section.order == "newest_first":
|
||||||
|
paths.reverse()
|
||||||
|
|
||||||
|
fingerprint = Fingerprint.of(paths)
|
||||||
|
identifier = album_id(root, folder)
|
||||||
|
|
||||||
|
tracks: list[LibraryTrack] = []
|
||||||
|
albums: list[str] = []
|
||||||
|
artists: list[str] = []
|
||||||
|
for path in paths:
|
||||||
|
tags, duration = _tags(path)
|
||||||
|
tracks.append(
|
||||||
|
LibraryTrack(path=path, title=tags.get("title") or path.stem, duration=duration)
|
||||||
|
)
|
||||||
|
albums.append(tags.get("album", ""))
|
||||||
|
artists.append(tags.get("albumartist") or tags.get("artist", ""))
|
||||||
|
|
||||||
|
figure = folder.name if section.figures else None
|
||||||
|
# Every other shelf is named after what is on it. A figure folder is named after the
|
||||||
|
# figurine, so its media type has to be declared in the config.
|
||||||
|
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
|
||||||
|
|
||||||
|
folder_artist, folder_title = _split_folder_name(folder.name)
|
||||||
|
|
||||||
|
if section.title_from == "folder":
|
||||||
|
# A figure folder is named in lowercase ("fuchs"); it sits next to real album
|
||||||
|
# titles in the browse grid, so give it a capital.
|
||||||
|
title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name
|
||||||
|
else:
|
||||||
|
title = _most_common(albums) or folder_title or folder.name
|
||||||
|
|
||||||
|
if section.artist_from == "folder":
|
||||||
|
artist = folder.name
|
||||||
|
else:
|
||||||
|
artist = _most_common(artists) or folder_artist
|
||||||
|
|
||||||
|
cover, art = _cover_for(folder, paths, identifier, cache)
|
||||||
|
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
|
||||||
|
|
||||||
|
album = Album(
|
||||||
|
id=identifier,
|
||||||
|
section=section_name,
|
||||||
|
kind=kind,
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
# Books group by who or what they are about; music groups by artist. The browse
|
||||||
|
# view's category row is built straight off this, so it takes only the part
|
||||||
|
# before the first comma - an ``album_artist`` of "Bobo Siebenschlaefer, Markus
|
||||||
|
# Osterwalder, ..." is a credit list whose first name is the character.
|
||||||
|
series=artist.split(",")[0].strip() if kind == "book" else None,
|
||||||
|
figure=figure,
|
||||||
|
colors=colors,
|
||||||
|
folder=folder,
|
||||||
|
cover=cover,
|
||||||
|
tracks=tuple(tracks),
|
||||||
|
)
|
||||||
|
return album, fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def scan_library(
|
||||||
|
root: Path,
|
||||||
|
extensions: frozenset[str],
|
||||||
|
cache: LibraryCache,
|
||||||
|
*,
|
||||||
|
known: dict[str, tuple[Album, Fingerprint]] | None = None,
|
||||||
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
|
) -> dict[str, tuple[Album, Fingerprint]]:
|
||||||
|
"""Scan every section under ``root``.
|
||||||
|
|
||||||
|
``known`` is the previously cached index: a folder whose files, sizes and mtimes are
|
||||||
|
unchanged is taken from it without a single tag being read. ``figure_kinds`` maps a
|
||||||
|
figure name to what it holds, which is the one thing the folders cannot say.
|
||||||
|
"""
|
||||||
|
cache.prepare()
|
||||||
|
known = known or {}
|
||||||
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
|
|
||||||
|
for section_name, section in SECTIONS.items():
|
||||||
|
section_root = root / section_name
|
||||||
|
if not section_root.is_dir():
|
||||||
|
_log.warning("Library section %r has no folder at %s", section_name, section_root)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for folder in sorted(section_root.iterdir(), key=lambda path: path.name):
|
||||||
|
if not folder.is_dir() or folder.name.startswith("."):
|
||||||
|
continue
|
||||||
|
identifier = album_id(root, folder)
|
||||||
|
cached = known.get(identifier)
|
||||||
|
if cached is not None:
|
||||||
|
paths = _audio_files(folder, extensions)
|
||||||
|
if paths and Fingerprint.of(paths) == cached[1]:
|
||||||
|
out[identifier] = cached
|
||||||
|
continue
|
||||||
|
scanned = scan_album(
|
||||||
|
folder,
|
||||||
|
root=root,
|
||||||
|
section_name=section_name,
|
||||||
|
section=section,
|
||||||
|
extensions=extensions,
|
||||||
|
cache=cache,
|
||||||
|
figure_kinds=figure_kinds,
|
||||||
|
)
|
||||||
|
if scanned is not None:
|
||||||
|
out[identifier] = scanned
|
||||||
|
|
||||||
|
_log.info("Library: %d albums under %s", len(out), root)
|
||||||
|
return out
|
||||||
46
python-backend/musicmouse/library/sections.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""The four shelves of the music library, and how each one differs.
|
||||||
|
|
||||||
|
These are folder names, not configuration. The library has one root and the layout
|
||||||
|
underneath it is fixed - a section that needed configuring would be a section whose
|
||||||
|
quirks are not understood yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Final, Literal
|
||||||
|
|
||||||
|
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
||||||
|
|
||||||
|
type AlbumKind = Literal["music", "book"]
|
||||||
|
type TrackOrder = Literal["filename", "newest_first"]
|
||||||
|
type TitleSource = Literal["tags", "folder"]
|
||||||
|
type ArtistSource = Literal["tags", "folder"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Section:
|
||||||
|
kind: AlbumKind = "music"
|
||||||
|
#: Subfolders are figure folders: their name is the figure name from the config.
|
||||||
|
figures: bool = False
|
||||||
|
order: TrackOrder = "filename"
|
||||||
|
title_from: TitleSource = "tags"
|
||||||
|
artist_from: ArtistSource = "tags"
|
||||||
|
|
||||||
|
|
||||||
|
#: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full
|
||||||
|
#: presenter list ("Thomas Welling, Sarah Schultes, ...") and its ``album`` tag is the
|
||||||
|
#: feed name, so neither groups usefully - the folder name is the show. And its files
|
||||||
|
#: are named ``YYYYMMDD - Title.mp3``, so reversing filename order puts the newest
|
||||||
|
#: episode first, which is the one anybody wants.
|
||||||
|
SECTIONS: Final[dict[str, Section]] = {
|
||||||
|
# A figure folder is named after the figure, and its contents are whatever that
|
||||||
|
# figure should play - often several albums' worth. The folder name is the honest
|
||||||
|
# title; the tags still supply a useful artist.
|
||||||
|
"Figuren": Section(figures=True, title_from="folder"),
|
||||||
|
"Musik": Section(),
|
||||||
|
"Hörbücher": Section(kind="book"),
|
||||||
|
"Kinderpodcasts": Section(
|
||||||
|
kind="book", order="newest_first", title_from="folder", artist_from="folder"
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
"""Playlist model.
|
"""Playlist model.
|
||||||
|
|
||||||
Deliberately a real type rather than a bare ``list[str]``: it is what a future
|
Deliberately a real type rather than a bare ``list[str]``: it is what the browse API
|
||||||
browse API would serve, and it keeps track metadata in one place.
|
serves, and it keeps track metadata in one place. Building one from a folder is
|
||||||
|
:mod:`musicmouse.library`'s job.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
__all__ = ["Playlist", "Track"]
|
||||||
|
|
||||||
__all__ = ["Playlist", "Track", "build_playlist"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -31,6 +29,9 @@ class Track:
|
|||||||
class Playlist:
|
class Playlist:
|
||||||
name: str
|
name: str
|
||||||
tracks: tuple[Track, ...]
|
tracks: tuple[Track, ...]
|
||||||
|
#: Set when this playlist came from a library album. It is how a front-end answers
|
||||||
|
#: "what is playing?" without keeping its own copy of that state.
|
||||||
|
album_id: str | None = None
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self.tracks)
|
return len(self.tracks)
|
||||||
@@ -43,23 +44,3 @@ class Playlist:
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Playlist({self.name!r}, {len(self.tracks)} tracks)"
|
return f"Playlist({self.name!r}, {len(self.tracks)} tracks)"
|
||||||
|
|
||||||
|
|
||||||
def build_playlist(name: str, folder: Path, extensions: tuple[str, ...]) -> Playlist:
|
|
||||||
"""Collect ``folder``'s audio files into a playlist, ordered alphabetically.
|
|
||||||
|
|
||||||
A missing or empty folder yields an empty playlist and a warning rather than an
|
|
||||||
error: an unfinished playlist should not stop the mouse from booting.
|
|
||||||
"""
|
|
||||||
if not folder.is_dir():
|
|
||||||
_log.warning("Figure %r: no media folder at %s", name, folder)
|
|
||||||
return Playlist(name=name, tracks=())
|
|
||||||
|
|
||||||
suffixes = {ext.lower() for ext in extensions}
|
|
||||||
paths = sorted(
|
|
||||||
(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in suffixes),
|
|
||||||
key=lambda p: p.name,
|
|
||||||
)
|
|
||||||
if not paths:
|
|
||||||
_log.warning("Figure %r: no audio files in %s", name, folder)
|
|
||||||
return Playlist(name=name, tracks=tuple(Track(p) for p in paths))
|
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ Publishing button presses to Home Assistant has no reaction of its own: the MQTT
|
|||||||
service subscribes to those events directly.
|
service subscribes to those events directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from musicmouse.reactions import lighting, playback # noqa: F401 (import = register)
|
from musicmouse.reactions import ( # noqa: F401 (import = register)
|
||||||
|
lighting,
|
||||||
|
playback,
|
||||||
|
status,
|
||||||
|
)
|
||||||
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
||||||
|
|
||||||
__all__ = ["Reaction", "on", "register_all", "registered"]
|
__all__ = ["Reaction", "on", "register_all", "registered"]
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import logging
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.color import ColorRGBW
|
from musicmouse.color import ColorRGBW, parse_color
|
||||||
from musicmouse.config import FigureColors
|
from musicmouse.config import FigureColors
|
||||||
from musicmouse.effects import (
|
from musicmouse.effects import (
|
||||||
|
EffectCircularConfig,
|
||||||
EffectRandomTwoColorInterpolationConfig,
|
EffectRandomTwoColorInterpolationConfig,
|
||||||
EffectReverseSwipe,
|
EffectReverseSwipe,
|
||||||
EffectStaticConfig,
|
EffectStaticConfig,
|
||||||
@@ -24,6 +25,7 @@ from musicmouse.effects import (
|
|||||||
)
|
)
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
ActiveFigureChanged,
|
ActiveFigureChanged,
|
||||||
|
PlaybackChanged,
|
||||||
PlaylistFinished,
|
PlaylistFinished,
|
||||||
TouchButtonPressed,
|
TouchButtonPressed,
|
||||||
TouchButtonReleased,
|
TouchButtonReleased,
|
||||||
@@ -40,6 +42,12 @@ MOUSE_SWIPE_START_DEGREES = 6 / 45 * 360
|
|||||||
MOUSE_BELL_CURVE_WIDTH = 16
|
MOUSE_BELL_CURVE_WIDTH = 16
|
||||||
SWIPE_SPEED = 180
|
SWIPE_SPEED = 180
|
||||||
|
|
||||||
|
#: The web front-end's animation: a three-quarter arc drifting round twice a minute, in
|
||||||
|
#: the album's own primary colour. Deliberately unlike the figure swipe - the strips
|
||||||
|
#: should say *which* way the mouse was started, not just that it was.
|
||||||
|
WEB_CIRCLE_WIDTH = 270.0 # degrees
|
||||||
|
WEB_CIRCLE_SPEED = 12.0 # degrees per second
|
||||||
|
|
||||||
|
|
||||||
@on(ActiveFigureChanged)
|
@on(ActiveFigureChanged)
|
||||||
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
||||||
@@ -52,6 +60,26 @@ def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@on(PlaybackChanged)
|
||||||
|
def web_playback(event: PlaybackChanged, app: App) -> None:
|
||||||
|
"""Light the strips for playback that no figure started.
|
||||||
|
|
||||||
|
Reacting to ``PlaybackChanged`` rather than to the play intent covers pause, stop
|
||||||
|
and playlist-end in one place. The guard is what keeps this off the figure path:
|
||||||
|
while a figure is on the reader its animation owns all three zones.
|
||||||
|
"""
|
||||||
|
if app.mouse.active_figure is not None:
|
||||||
|
return
|
||||||
|
if not event.playing:
|
||||||
|
off_animation(app)
|
||||||
|
return
|
||||||
|
|
||||||
|
album = app.library.get(event.playlist.album_id if event.playlist else None)
|
||||||
|
if album is None:
|
||||||
|
return
|
||||||
|
web_animation(app, parse_color(album.colors[0]))
|
||||||
|
|
||||||
|
|
||||||
@on(PlaylistFinished)
|
@on(PlaylistFinished)
|
||||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||||
off_animation(app)
|
off_animation(app)
|
||||||
@@ -108,6 +136,16 @@ def start_animation(app: App, colors: FigureColors) -> None:
|
|||||||
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
|
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
|
||||||
|
|
||||||
|
|
||||||
|
def web_animation(app: App, color: ColorRGBW) -> None:
|
||||||
|
for zone in LedZone:
|
||||||
|
app.mouse.set_effect(
|
||||||
|
zone,
|
||||||
|
EffectCircularConfig(speed=WEB_CIRCLE_SPEED, width=WEB_CIRCLE_WIDTH, color=color),
|
||||||
|
origin="device",
|
||||||
|
)
|
||||||
|
app.mouse.set_button_brightness(app.config.general.button_leds_brightness, origin="device")
|
||||||
|
|
||||||
|
|
||||||
def off_animation(app: App) -> None:
|
def off_animation(app: App) -> None:
|
||||||
_log.info("Running off animation")
|
_log.info("Running off animation")
|
||||||
app.mouse.set_effect(LedZone.RING, EffectReverseSwipe(), origin="device")
|
app.mouse.set_effect(LedZone.RING, EffectReverseSwipe(), origin="device")
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ from musicmouse.events import (
|
|||||||
ButtonEvent,
|
ButtonEvent,
|
||||||
NextTrackRequested,
|
NextTrackRequested,
|
||||||
PauseRequested,
|
PauseRequested,
|
||||||
|
PlayAlbumRequested,
|
||||||
PlayFigureRequested,
|
PlayFigureRequested,
|
||||||
PlaylistFinished,
|
PlaylistFinished,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PrevTrackRequested,
|
PrevTrackRequested,
|
||||||
RotaryTurned,
|
RotaryTurned,
|
||||||
|
SeekRequested,
|
||||||
SetVolumeRequested,
|
SetVolumeRequested,
|
||||||
VolumeChangeRequested,
|
VolumeChangeRequested,
|
||||||
)
|
)
|
||||||
@@ -72,6 +74,21 @@ def play_figure(event: PlayFigureRequested, app: App) -> None:
|
|||||||
app.player.play_from_start()
|
app.player.play_from_start()
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
# 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(event.track_index)
|
||||||
|
|
||||||
|
|
||||||
@on(PlaylistFinished)
|
@on(PlaylistFinished)
|
||||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||||
# Nothing was left half-played, so the next placement starts from the top.
|
# Nothing was left half-played, so the next placement starts from the top.
|
||||||
@@ -127,6 +144,11 @@ def pause(_event: PauseRequested, app: App) -> None:
|
|||||||
app.player.pause()
|
app.player.pause()
|
||||||
|
|
||||||
|
|
||||||
|
@on(SeekRequested)
|
||||||
|
def seek(event: SeekRequested, app: App) -> None:
|
||||||
|
app.player.seek(event.position)
|
||||||
|
|
||||||
|
|
||||||
@on(VolumeChangeRequested)
|
@on(VolumeChangeRequested)
|
||||||
def change_volume(event: VolumeChangeRequested, app: App) -> None:
|
def change_volume(event: VolumeChangeRequested, app: App) -> None:
|
||||||
app.player.change_volume(event.delta, source=event.source)
|
app.player.change_volume(event.delta, source=event.source)
|
||||||
|
|||||||
18
python-backend/musicmouse/reactions/status.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Remembering which links are up.
|
||||||
|
|
||||||
|
The firmware's state is readable straight off the transport, but a broker's is not:
|
||||||
|
:class:`~musicmouse.services.mqtt.service.MqttService` announces it and then forgets.
|
||||||
|
A front-end that wants to show a connection dot needs somewhere to read it from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from musicmouse.app import App
|
||||||
|
from musicmouse.events import ConnectionChanged
|
||||||
|
from musicmouse.reactions.registry import on
|
||||||
|
|
||||||
|
|
||||||
|
@on(ConnectionChanged)
|
||||||
|
def connection_changed(event: ConnectionChanged, app: App) -> None:
|
||||||
|
if event.target == "mqtt":
|
||||||
|
app.state.mqtt_connected = event.connected
|
||||||
6
python-backend/musicmouse/services/web/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""The web front-end: a browse-and-play UI over the same intents the buttons emit."""
|
||||||
|
|
||||||
|
from musicmouse.services.web.hub import StateHub
|
||||||
|
from musicmouse.services.web.service import WebService, build_app
|
||||||
|
|
||||||
|
__all__ = ["StateHub", "WebService", "build_app"]
|
||||||
192
python-backend/musicmouse/services/web/api.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""The HTTP surface.
|
||||||
|
|
||||||
|
Commands are REST and state is a one-way websocket. That split keeps every control path
|
||||||
|
testable with ``curl`` and means a command needs no new machinery: it emits the same
|
||||||
|
intent the MQTT service and the buttons emit, and lands in the same reaction.
|
||||||
|
|
||||||
|
Search is not here on purpose. The whole index goes to the browser once and filtering
|
||||||
|
happens there, which is what makes the design's type-to-search feel instant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from musicmouse.app import App
|
||||||
|
from musicmouse.events import (
|
||||||
|
IntentEvent,
|
||||||
|
NextTrackRequested,
|
||||||
|
PauseRequested,
|
||||||
|
PlayAlbumRequested,
|
||||||
|
PlayRequested,
|
||||||
|
PrevTrackRequested,
|
||||||
|
SeekRequested,
|
||||||
|
SetVolumeRequested,
|
||||||
|
)
|
||||||
|
from musicmouse.services.web.hub import StateHub
|
||||||
|
from musicmouse.services.web.schemas import (
|
||||||
|
AlbumOut,
|
||||||
|
BeatsOut,
|
||||||
|
LibraryOut,
|
||||||
|
PlayerStateOut,
|
||||||
|
PlayIn,
|
||||||
|
SeekIn,
|
||||||
|
SettingsIn,
|
||||||
|
SettingsOut,
|
||||||
|
VolumeIn,
|
||||||
|
)
|
||||||
|
from musicmouse.services.web.settings import (
|
||||||
|
read_settings,
|
||||||
|
to_device_volume,
|
||||||
|
to_percent,
|
||||||
|
write_settings,
|
||||||
|
)
|
||||||
|
from musicmouse.services.web.state import snapshot
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["build_router"]
|
||||||
|
|
||||||
|
def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
def emit(intent: IntentEvent) -> Response:
|
||||||
|
app.bus.emit(intent)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- library
|
||||||
|
|
||||||
|
@router.get("/library")
|
||||||
|
def get_library() -> LibraryOut:
|
||||||
|
return LibraryOut(albums=[AlbumOut.of(album) for album in app.library.albums])
|
||||||
|
|
||||||
|
@router.get("/albums/{album_id}/cover")
|
||||||
|
def get_cover(album_id: str) -> FileResponse:
|
||||||
|
album = app.library.get(album_id)
|
||||||
|
if album is None or album.cover is None:
|
||||||
|
# Not an error: the client paints the album's own colours instead.
|
||||||
|
raise HTTPException(status_code=404, detail="no cover")
|
||||||
|
return FileResponse(
|
||||||
|
album.cover,
|
||||||
|
# Cover files are content-addressed by album id and rewritten only by a
|
||||||
|
# rescan, so a long cache is safe and saves 20 requests per page load.
|
||||||
|
headers={"Cache-Control": "public, max-age=604800"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/tracks/{album_id}/{index}/analysis")
|
||||||
|
def get_track_analysis(album_id: str, index: int) -> BeatsOut:
|
||||||
|
grid = app.library.beats(album_id, index)
|
||||||
|
if grid is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not analyzed")
|
||||||
|
return BeatsOut(times=list(grid.times), strengths=list(grid.strengths))
|
||||||
|
|
||||||
|
@router.post("/library/refresh", status_code=202)
|
||||||
|
async def refresh_library() -> Response:
|
||||||
|
async def rescan() -> None:
|
||||||
|
await app.library.refresh()
|
||||||
|
app.playlists.clear()
|
||||||
|
app.playlists.update(app.library.figure_playlists())
|
||||||
|
await hub.broadcast_library()
|
||||||
|
|
||||||
|
# Returns immediately: a cold rescan reads tags from every file.
|
||||||
|
asyncio.create_task(rescan()) # noqa: RUF006
|
||||||
|
return Response(status_code=202)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
def get_state() -> PlayerStateOut:
|
||||||
|
return snapshot(app)
|
||||||
|
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def websocket(socket: WebSocket) -> None:
|
||||||
|
await hub.connect(socket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Push-only. Reading is how we notice the tab closed.
|
||||||
|
await socket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
hub.disconnect(socket)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ commands
|
||||||
|
|
||||||
|
@router.post("/play", status_code=204)
|
||||||
|
def play_album(body: PlayIn) -> Response:
|
||||||
|
if app.library.get(body.album_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="no such album")
|
||||||
|
return emit(
|
||||||
|
PlayAlbumRequested(
|
||||||
|
album_id=body.album_id, track_index=body.track_index, source="web"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/resume", status_code=204)
|
||||||
|
def resume() -> Response:
|
||||||
|
return emit(PlayRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/pause", status_code=204)
|
||||||
|
def pause() -> Response:
|
||||||
|
return emit(PauseRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/next", status_code=204)
|
||||||
|
def next_track() -> Response:
|
||||||
|
return emit(NextTrackRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/previous", status_code=204)
|
||||||
|
def previous_track() -> Response:
|
||||||
|
return emit(PrevTrackRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/seek", status_code=204)
|
||||||
|
def seek(body: SeekIn) -> Response:
|
||||||
|
return emit(SeekRequested(position=body.position, source="web"))
|
||||||
|
|
||||||
|
@router.post("/volume", status_code=204)
|
||||||
|
def set_volume(body: VolumeIn) -> Response:
|
||||||
|
general = app.config.general
|
||||||
|
if body.percent is not None:
|
||||||
|
target = body.percent
|
||||||
|
elif body.delta_percent is not None:
|
||||||
|
target = to_percent(app.player.volume, general) + body.delta_percent
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=422, detail="percent or delta_percent required")
|
||||||
|
return emit(
|
||||||
|
SetVolumeRequested(
|
||||||
|
volume=to_device_volume(max(0, min(100, target)), general), source="web"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- parent mode
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
def get_settings() -> SettingsOut:
|
||||||
|
return read_settings(app.config.general)
|
||||||
|
|
||||||
|
@router.put("/settings")
|
||||||
|
async def put_settings(body: SettingsIn) -> SettingsOut:
|
||||||
|
if body.min_volume > body.max_volume:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="min_volume must not exceed max_volume"
|
||||||
|
)
|
||||||
|
if not body.min_volume <= body.initial_volume <= body.max_volume:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="initial_volume must lie between min and max"
|
||||||
|
)
|
||||||
|
|
||||||
|
general = app.config.general
|
||||||
|
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
||||||
|
# song to be quieter, not the next boot.
|
||||||
|
for key, value in body.model_dump().items():
|
||||||
|
setattr(general, key, value)
|
||||||
|
app.player.set_volume_limits(general.min_volume, general.max_volume)
|
||||||
|
await asyncio.to_thread(write_settings, config_path, body)
|
||||||
|
await hub.broadcast_state()
|
||||||
|
return read_settings(general)
|
||||||
|
|
||||||
|
return router
|
||||||
104
python-backend/musicmouse/services/web/hub.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Getting state out to every open browser tab.
|
||||||
|
|
||||||
|
State events only fire on *change*, so a tab that connects halfway through a track
|
||||||
|
would otherwise sit there knowing nothing. The fix is the one
|
||||||
|
:class:`~musicmouse.services.mqtt.service.MqttService` already uses for a reconnecting
|
||||||
|
broker: send a full snapshot on connect, then deltas.
|
||||||
|
|
||||||
|
Position is the exception to "everything goes on the bus". A progress bar wants it
|
||||||
|
twice a second; an event at that rate would flood the queue, the MQTT service and the
|
||||||
|
log to serve one front-end. So the hub reads it straight off the player on its own
|
||||||
|
timer, and only while something is playing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from musicmouse.app import App
|
||||||
|
from musicmouse.events import Event, StateEvent
|
||||||
|
from musicmouse.services.web.state import snapshot
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["StateHub"]
|
||||||
|
|
||||||
|
#: Twice a second: smooth enough once the client interpolates between frames, cheap
|
||||||
|
#: enough to leave running.
|
||||||
|
POSITION_INTERVAL = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class StateHub:
|
||||||
|
def __init__(self, app: App) -> None:
|
||||||
|
self.app = app
|
||||||
|
self._clients: set[WebSocket] = set()
|
||||||
|
self._unsubscribe: Any = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- lifecycle
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self._unsubscribe is not None:
|
||||||
|
self._unsubscribe()
|
||||||
|
self._unsubscribe = None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Push the playback position while anything is playing."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(POSITION_INTERVAL)
|
||||||
|
if not self._clients or not self.app.player.is_playing:
|
||||||
|
continue
|
||||||
|
await self.broadcast(
|
||||||
|
{
|
||||||
|
"type": "position",
|
||||||
|
"position": self.app.player.position,
|
||||||
|
"duration": self.app.player.duration,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- clients
|
||||||
|
|
||||||
|
async def connect(self, socket: WebSocket) -> None:
|
||||||
|
await socket.accept()
|
||||||
|
self._clients.add(socket)
|
||||||
|
await self._send(socket, {"type": "state", "state": snapshot(self.app).model_dump()})
|
||||||
|
|
||||||
|
def disconnect(self, socket: WebSocket) -> None:
|
||||||
|
self._clients.discard(socket)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ publishing
|
||||||
|
|
||||||
|
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||||
|
if not self._clients:
|
||||||
|
return
|
||||||
|
payload = json.dumps(message)
|
||||||
|
for socket in list(self._clients):
|
||||||
|
try:
|
||||||
|
await socket.send_text(payload)
|
||||||
|
except Exception:
|
||||||
|
# A tab that closed mid-send is normal, not an error worth logging loudly.
|
||||||
|
_log.debug("Dropping a websocket client that went away")
|
||||||
|
self._clients.discard(socket)
|
||||||
|
|
||||||
|
async def broadcast_state(self) -> None:
|
||||||
|
await self.broadcast({"type": "state", "state": snapshot(self.app).model_dump()})
|
||||||
|
|
||||||
|
async def broadcast_library(self) -> None:
|
||||||
|
await self.broadcast({"type": "library"})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ internals
|
||||||
|
|
||||||
|
async def _send(self, socket: WebSocket, message: dict[str, Any]) -> None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await socket.send_text(json.dumps(message))
|
||||||
|
|
||||||
|
async def _on_state(self, _event: Event) -> None:
|
||||||
|
await self.broadcast_state()
|
||||||
156
python-backend/musicmouse/services/web/schemas.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""What the browser sees.
|
||||||
|
|
||||||
|
Two rules shape these. The whole library ships in one response, because the design's
|
||||||
|
incremental A-Z search has to be instant and ~25 albums of metadata is under 100 kB -
|
||||||
|
so no track carries anything it does not need. And volume is a *percentage* here: the
|
||||||
|
configured ceiling is a parent's business, not a child's, so it never crosses this line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from musicmouse.library import Album
|
||||||
|
from musicmouse.library.analysis import TrackAnalysis
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AlbumOut",
|
||||||
|
"BeatsOut",
|
||||||
|
"LibraryOut",
|
||||||
|
"PlayIn",
|
||||||
|
"PlayerStateOut",
|
||||||
|
"SeekIn",
|
||||||
|
"SettingsIn",
|
||||||
|
"SettingsOut",
|
||||||
|
"TrackOut",
|
||||||
|
"VolumeIn",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisOut(BaseModel):
|
||||||
|
tempo: float | None = None
|
||||||
|
energy: float | None = None
|
||||||
|
valence: float | None = None
|
||||||
|
brightness: float | None = None
|
||||||
|
beats: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def of(cls, analysis: TrackAnalysis | None) -> AnalysisOut | None:
|
||||||
|
if analysis is None or not analysis.is_analyzed:
|
||||||
|
return None
|
||||||
|
return cls(
|
||||||
|
tempo=analysis.tempo,
|
||||||
|
energy=analysis.energy,
|
||||||
|
valence=analysis.valence,
|
||||||
|
brightness=analysis.brightness,
|
||||||
|
beats=analysis.beats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackOut(BaseModel):
|
||||||
|
title: str
|
||||||
|
duration: float
|
||||||
|
#: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``.
|
||||||
|
analysis: AnalysisOut | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
section: str
|
||||||
|
kind: str
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
series: str | None
|
||||||
|
figure: str | None
|
||||||
|
category: str
|
||||||
|
colors: list[str]
|
||||||
|
has_cover: bool
|
||||||
|
duration: float
|
||||||
|
tracks: list[TrackOut]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def of(cls, album: Album) -> AlbumOut:
|
||||||
|
return cls(
|
||||||
|
id=album.id,
|
||||||
|
section=album.section,
|
||||||
|
kind=album.kind,
|
||||||
|
title=album.title,
|
||||||
|
artist=album.artist,
|
||||||
|
series=album.series,
|
||||||
|
figure=album.figure,
|
||||||
|
category=album.category,
|
||||||
|
colors=list(album.colors),
|
||||||
|
has_cover=album.cover is not None,
|
||||||
|
duration=album.duration,
|
||||||
|
tracks=[
|
||||||
|
TrackOut(
|
||||||
|
title=track.title,
|
||||||
|
duration=track.duration,
|
||||||
|
analysis=AnalysisOut.of(track.analysis),
|
||||||
|
)
|
||||||
|
for track in album.tracks
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryOut(BaseModel):
|
||||||
|
albums: list[AlbumOut]
|
||||||
|
|
||||||
|
|
||||||
|
class BeatsOut(BaseModel):
|
||||||
|
times: list[float]
|
||||||
|
strengths: list[float]
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionOut(BaseModel):
|
||||||
|
firmware: bool
|
||||||
|
mqtt: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerStateOut(BaseModel):
|
||||||
|
playing: bool
|
||||||
|
album_id: str | None
|
||||||
|
album_title: str | None
|
||||||
|
artist: str | None
|
||||||
|
kind: str | None
|
||||||
|
track_index: int
|
||||||
|
track_title: str | None
|
||||||
|
track_count: int
|
||||||
|
position: float
|
||||||
|
duration: float
|
||||||
|
#: 0..100. The device's own range is deliberately not exposed.
|
||||||
|
volume: int
|
||||||
|
active_figure: str | None
|
||||||
|
connected: ConnectionOut
|
||||||
|
|
||||||
|
|
||||||
|
class PlayIn(BaseModel):
|
||||||
|
album_id: str
|
||||||
|
track_index: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class SeekIn(BaseModel):
|
||||||
|
position: float = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeIn(BaseModel):
|
||||||
|
"""Either an absolute percentage or a relative step; exactly one of the two."""
|
||||||
|
|
||||||
|
percent: int | None = Field(default=None, ge=0, le=100)
|
||||||
|
delta_percent: int | None = Field(default=None, ge=-100, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsOut(BaseModel):
|
||||||
|
min_volume: int
|
||||||
|
max_volume: int
|
||||||
|
initial_volume: int
|
||||||
|
volume_increment: int
|
||||||
|
button_leds_brightness: float
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsIn(BaseModel):
|
||||||
|
min_volume: int = Field(ge=0, le=200)
|
||||||
|
max_volume: int = Field(ge=0, le=200)
|
||||||
|
initial_volume: int = Field(ge=0, le=200)
|
||||||
|
volume_increment: int = Field(ge=1, le=100)
|
||||||
|
button_leds_brightness: float = Field(ge=0, le=1)
|
||||||
88
python-backend/musicmouse/services/web/service.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""The web front-end, as a :class:`~musicmouse.services.base.Service`.
|
||||||
|
|
||||||
|
Everything runs on the one event loop the rest of the app already has: uvicorn's
|
||||||
|
``Server.serve()`` is a coroutine, so there is no second loop and no thread. The
|
||||||
|
service owns no state of its own - it turns HTTP into intents on the bus, and bus
|
||||||
|
events into websocket frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from musicmouse.app import App
|
||||||
|
from musicmouse.config import WebConfig
|
||||||
|
from musicmouse.services.web.api import build_router
|
||||||
|
from musicmouse.services.web.hub import StateHub
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["WebService", "build_app"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(app: App, config: WebConfig, config_path: Path) -> tuple[FastAPI, StateHub]:
|
||||||
|
"""Assemble the ASGI app. Separate from the service so tests can drive it directly."""
|
||||||
|
hub = StateHub(app)
|
||||||
|
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||||
|
api.include_router(build_router(app, hub, config_path))
|
||||||
|
|
||||||
|
if config.static_dir is not None:
|
||||||
|
if config.static_dir.is_dir():
|
||||||
|
# Mounted last and at the root so every /api route still wins; html=True
|
||||||
|
# falls back to index.html, which is what a client-side router needs.
|
||||||
|
api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web")
|
||||||
|
else:
|
||||||
|
_log.warning(
|
||||||
|
"web.static_dir %s does not exist; serving the API only "
|
||||||
|
"(run `npm run build` in web/, or drop the setting)",
|
||||||
|
config.static_dir,
|
||||||
|
)
|
||||||
|
return api, hub
|
||||||
|
|
||||||
|
|
||||||
|
class WebService:
|
||||||
|
name = "web"
|
||||||
|
|
||||||
|
def __init__(self, app: App, config: WebConfig, config_path: Path) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.api, self.hub = build_app(app, config, config_path)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
self.hub.start()
|
||||||
|
server = uvicorn.Server(
|
||||||
|
uvicorn.Config(
|
||||||
|
self.api,
|
||||||
|
host=self.config.host,
|
||||||
|
port=self.config.port,
|
||||||
|
# Access logs for a progress-bar poll every 500ms are noise.
|
||||||
|
access_log=False,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_log.info("Web front-end on http://%s:%d", self.config.host, self.config.port)
|
||||||
|
|
||||||
|
# serve() borrows SIGINT for a graceful shutdown and then re-raises it, so
|
||||||
|
# Ctrl-C still reaches __main__'s KeyboardInterrupt handler afterwards.
|
||||||
|
position = asyncio.create_task(self.hub.run(), name="web-position")
|
||||||
|
serving = asyncio.create_task(server.serve(), name="web-serve")
|
||||||
|
try:
|
||||||
|
await asyncio.shield(serving)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Shutdown reaches us as a cancellation. Cancelling uvicorn mid-accept
|
||||||
|
# would leave its listening socket to the garbage collector, so ask it to
|
||||||
|
# wind down and wait for it to let the port go.
|
||||||
|
server.should_exit = True
|
||||||
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
|
await asyncio.wait_for(serving, timeout=5.0)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
position.cancel()
|
||||||
|
serving.cancel()
|
||||||
|
self.hub.stop()
|
||||||
76
python-backend/musicmouse/services/web/settings.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Volume as a percentage, and the handful of settings parent mode may change.
|
||||||
|
|
||||||
|
Two jobs that both come down to "keep the config file's numbers out of the child's UI".
|
||||||
|
|
||||||
|
The volume mapping lives here rather than in the frontend so that MQTT, the rotary
|
||||||
|
encoder and the firmware carry on speaking device units, unaware that a browser is
|
||||||
|
using a different scale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
from musicmouse.config import GeneralConfig
|
||||||
|
from musicmouse.services.web.schemas import SettingsIn, SettingsOut
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["read_settings", "to_device_volume", "to_percent", "write_settings"]
|
||||||
|
|
||||||
|
|
||||||
|
def to_device_volume(percent: int, general: GeneralConfig) -> int:
|
||||||
|
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
|
||||||
|
span = general.max_volume - general.min_volume
|
||||||
|
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
|
||||||
|
|
||||||
|
|
||||||
|
def to_percent(volume: int, general: GeneralConfig) -> int:
|
||||||
|
"""The inverse, for the state snapshot."""
|
||||||
|
span = general.max_volume - general.min_volume
|
||||||
|
if span <= 0:
|
||||||
|
return 100
|
||||||
|
return max(0, min(100, round((volume - general.min_volume) / span * 100)))
|
||||||
|
|
||||||
|
|
||||||
|
def read_settings(general: GeneralConfig) -> SettingsOut:
|
||||||
|
return SettingsOut(
|
||||||
|
min_volume=general.min_volume,
|
||||||
|
max_volume=general.max_volume,
|
||||||
|
initial_volume=general.initial_volume,
|
||||||
|
volume_increment=general.volume_increment,
|
||||||
|
button_leds_brightness=general.button_leds_brightness,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_settings(path: Path, settings: SettingsIn) -> None:
|
||||||
|
"""Patch the settings into ``config.yml`` in place.
|
||||||
|
|
||||||
|
Loaded round-trip rather than with the safe loader :mod:`musicmouse.config` uses,
|
||||||
|
so the file keeps its comments, ordering and formatting - a config that explains
|
||||||
|
itself is worth more than one this could rewrite from scratch. Written through a
|
||||||
|
sibling temp file so an interrupted save cannot truncate the real one.
|
||||||
|
"""
|
||||||
|
yaml = YAML(typ="rt")
|
||||||
|
yaml.preserve_quotes = True
|
||||||
|
|
||||||
|
with path.open(encoding="utf-8") as handle:
|
||||||
|
document = yaml.load(handle)
|
||||||
|
|
||||||
|
general = document["general"]
|
||||||
|
for key, value in settings.model_dump().items():
|
||||||
|
general[key] = value
|
||||||
|
|
||||||
|
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||||
|
try:
|
||||||
|
with temp.open("w", encoding="utf-8") as handle:
|
||||||
|
yaml.dump(document, handle)
|
||||||
|
temp.replace(path)
|
||||||
|
except BaseException:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
_log.info("Wrote settings to %s", path)
|
||||||
47
python-backend/musicmouse/services/web/state.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""One place that answers "what is the mouse doing right now?".
|
||||||
|
|
||||||
|
Assembled on demand rather than accumulated from events. The join between what is
|
||||||
|
playing and which figure is on the reader is the same one the MQTT player entity makes:
|
||||||
|
read ``mouse.active_figure``, do not keep a second copy of it that can drift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from musicmouse.app import App
|
||||||
|
from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut
|
||||||
|
from musicmouse.services.web.settings import to_percent
|
||||||
|
|
||||||
|
__all__ = ["snapshot"]
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(app: App) -> PlayerStateOut:
|
||||||
|
player = app.player
|
||||||
|
playlist = player.playlist
|
||||||
|
album = app.library.get(playlist.album_id if playlist else None)
|
||||||
|
index = player.track_index
|
||||||
|
# The player only knows a file path, and a tagged file's name is rarely its title
|
||||||
|
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.
|
||||||
|
indexed = (
|
||||||
|
album.tracks[index] if album and 0 <= index < len(album.tracks) else None
|
||||||
|
)
|
||||||
|
track = player.current_track
|
||||||
|
|
||||||
|
return PlayerStateOut(
|
||||||
|
playing=player.is_playing,
|
||||||
|
album_id=album.id if album else None,
|
||||||
|
album_title=album.title if album else (playlist.name if playlist else None),
|
||||||
|
artist=album.artist if album else None,
|
||||||
|
kind=album.kind if album else None,
|
||||||
|
track_index=index,
|
||||||
|
track_title=indexed.title if indexed else (track.title if track else None),
|
||||||
|
track_count=len(playlist) if playlist else 0,
|
||||||
|
position=player.position,
|
||||||
|
# Likewise the duration: the player only has one once libVLC has opened the file.
|
||||||
|
duration=(indexed.duration if indexed else 0.0) or player.duration,
|
||||||
|
volume=to_percent(player.volume, app.config.general),
|
||||||
|
active_figure=app.mouse.active_figure,
|
||||||
|
connected=ConnectionOut(
|
||||||
|
firmware=app.mouse.connected,
|
||||||
|
mqtt=app.state.mqtt_connected,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -24,10 +24,12 @@ from musicmouse.events import (
|
|||||||
ButtonEvent,
|
ButtonEvent,
|
||||||
NextTrackRequested,
|
NextTrackRequested,
|
||||||
PauseRequested,
|
PauseRequested,
|
||||||
|
PlayAlbumRequested,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PrevTrackRequested,
|
PrevTrackRequested,
|
||||||
RfidTokenRead,
|
RfidTokenRead,
|
||||||
RotaryTurned,
|
RotaryTurned,
|
||||||
|
SeekRequested,
|
||||||
SetVolumeRequested,
|
SetVolumeRequested,
|
||||||
TouchButtonPressed,
|
TouchButtonPressed,
|
||||||
TouchButtonReleased,
|
TouchButtonReleased,
|
||||||
@@ -169,6 +171,25 @@ class SimulatorDriver:
|
|||||||
self.app.bus.emit(SetVolumeRequested(volume=volume, source="simulator"))
|
self.app.bus.emit(SetVolumeRequested(volume=volume, source="simulator"))
|
||||||
await self.settle()
|
await self.settle()
|
||||||
|
|
||||||
|
async def play_album(self, title: str, track_index: int = 0) -> None:
|
||||||
|
"""Start a library album by title - what the web front-end does."""
|
||||||
|
album = next(
|
||||||
|
(a for a in self.app.library.albums if a.title.lower() == title.lower()), None
|
||||||
|
)
|
||||||
|
if album is None:
|
||||||
|
known = ", ".join(sorted(a.title for a in self.app.library.albums))
|
||||||
|
raise ScriptError(f"unknown album {title!r} (known: {known})")
|
||||||
|
self.app.bus.emit(
|
||||||
|
PlayAlbumRequested(
|
||||||
|
album_id=album.id, track_index=track_index, source="simulator"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.settle()
|
||||||
|
|
||||||
|
async def seek(self, position: float) -> None:
|
||||||
|
self.app.bus.emit(SeekRequested(position=position, source="simulator"))
|
||||||
|
await self.settle()
|
||||||
|
|
||||||
# -------------------------------------------------------------------- time
|
# -------------------------------------------------------------------- time
|
||||||
|
|
||||||
async def wait(self, seconds: float) -> None:
|
async def wait(self, seconds: float) -> None:
|
||||||
@@ -213,6 +234,13 @@ class SimulatorDriver:
|
|||||||
return self.app.mouse.active_figure or "none"
|
return self.app.mouse.active_figure or "none"
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return player.playlist.name if player.playlist else "none"
|
return player.playlist.name if player.playlist else "none"
|
||||||
|
case "album":
|
||||||
|
album = self.app.library.get(
|
||||||
|
player.playlist.album_id if player.playlist else None
|
||||||
|
)
|
||||||
|
return album.title if album else "none"
|
||||||
|
case "position":
|
||||||
|
return f"{player.position:.1f}"
|
||||||
case "track":
|
case "track":
|
||||||
return str(player.track_index)
|
return str(player.track_index)
|
||||||
case "title":
|
case "title":
|
||||||
@@ -227,8 +255,8 @@ class SimulatorDriver:
|
|||||||
return type(effect).__name__ if effect is not None else "none"
|
return type(effect).__name__ if effect is not None else "none"
|
||||||
case _:
|
case _:
|
||||||
raise ScriptError(
|
raise ScriptError(
|
||||||
f"unknown property {key!r} (try: playing, figure, playlist, track, "
|
f"unknown property {key!r} (try: playing, figure, playlist, album, "
|
||||||
f"title, volume, brightness, ring, mouse, shelf)"
|
f"track, title, position, volume, brightness, ring, mouse, shelf)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ scripts
|
# ------------------------------------------------------------------ scripts
|
||||||
@@ -275,6 +303,10 @@ class SimulatorDriver:
|
|||||||
await self.emit_pause()
|
await self.emit_pause()
|
||||||
case ("volume", [level]):
|
case ("volume", [level]):
|
||||||
await self.set_volume(_int(level))
|
await self.set_volume(_int(level))
|
||||||
|
case ("album", [*words]) if words:
|
||||||
|
await self.play_album(" ".join(words))
|
||||||
|
case ("seek", [position]):
|
||||||
|
await self.seek(Duration.parse(position).seconds)
|
||||||
case ("disconnect", []):
|
case ("disconnect", []):
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
case ("reconnect", []):
|
case ("reconnect", []):
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import logging
|
|||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.clock import Clock, RealClock
|
from musicmouse.clock import Clock, RealClock
|
||||||
from musicmouse.devices.player import PlayerBase
|
from musicmouse.devices.player import PlayerBase
|
||||||
from musicmouse.events import EventSource
|
|
||||||
from musicmouse.media import Playlist
|
from musicmouse.media import Playlist
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -45,6 +44,20 @@ class FakePlayer(PlayerBase):
|
|||||||
self._timer: asyncio.Task[None] | None = None
|
self._timer: asyncio.Task[None] | None = None
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position(self) -> float:
|
||||||
|
"""Derived from the clock, so it is exact under ``FakeClock`` too."""
|
||||||
|
remaining = self._remaining
|
||||||
|
if self._started_at is not None:
|
||||||
|
remaining = max(0.0, remaining - (self._clock.now() - self._started_at))
|
||||||
|
return max(0.0, self.track_duration - remaining)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
return self.track_duration if self._playlist else 0.0
|
||||||
|
|
||||||
# ------------------------------------------------------------------ actions
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
def set_playlist(self, playlist: Playlist) -> None:
|
def set_playlist(self, playlist: Playlist) -> None:
|
||||||
@@ -64,11 +77,14 @@ class FakePlayer(PlayerBase):
|
|||||||
self._start_timer()
|
self._start_timer()
|
||||||
|
|
||||||
def play_from_start(self) -> None:
|
def play_from_start(self) -> None:
|
||||||
|
self.play_track(0)
|
||||||
|
|
||||||
|
def play_track(self, index: int) -> None:
|
||||||
if self._playlist is None or not self._playlist:
|
if self._playlist is None or not self._playlist:
|
||||||
_log.warning("Nothing to play: the playlist is empty")
|
_log.warning("Nothing to play: the playlist is empty")
|
||||||
return
|
return
|
||||||
self._cancel_timer()
|
self._cancel_timer()
|
||||||
self._set_index(0)
|
self._set_index(max(0, min(index, len(self._playlist) - 1)))
|
||||||
self._remaining = self.track_duration
|
self._remaining = self.track_duration
|
||||||
self._set_playing(True)
|
self._set_playing(True)
|
||||||
self._start_timer()
|
self._start_timer()
|
||||||
@@ -90,15 +106,12 @@ class FakePlayer(PlayerBase):
|
|||||||
def previous_track(self) -> None:
|
def previous_track(self) -> None:
|
||||||
self._skip(-1)
|
self._skip(-1)
|
||||||
|
|
||||||
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
|
def seek(self, position: float) -> None:
|
||||||
clamped = self._clamp(volume)
|
if self._playlist is None:
|
||||||
if clamped == self._volume:
|
|
||||||
return
|
return
|
||||||
self._volume = clamped
|
self._remaining = max(0.0, self.track_duration - max(0.0, position))
|
||||||
self._announce_volume(source)
|
if self._playing:
|
||||||
|
self._start_timer()
|
||||||
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
|
|
||||||
self.set_volume(self._volume + delta, source=source)
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ from dataclasses import dataclass
|
|||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.clock import Clock, FakeClock
|
from musicmouse.clock import Clock, FakeClock
|
||||||
from musicmouse.config import Config, build_playlists
|
from musicmouse.config import Config
|
||||||
from musicmouse.devices.mouse import MusicMouseDevice
|
from musicmouse.devices.mouse import MusicMouseDevice
|
||||||
|
from musicmouse.library import MusicLibrary
|
||||||
from musicmouse.reactions import register_all
|
from musicmouse.reactions import register_all
|
||||||
from musicmouse.simulator.driver import SimulatorDriver
|
from musicmouse.simulator.driver import SimulatorDriver
|
||||||
from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer
|
from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer
|
||||||
@@ -41,6 +42,7 @@ async def build_simulation(
|
|||||||
*,
|
*,
|
||||||
clock: Clock | None = None,
|
clock: Clock | None = None,
|
||||||
track_duration: float = DEFAULT_TRACK_DURATION,
|
track_duration: float = DEFAULT_TRACK_DURATION,
|
||||||
|
library: MusicLibrary | None = None,
|
||||||
) -> Simulation:
|
) -> Simulation:
|
||||||
bus = EventBus()
|
bus = EventBus()
|
||||||
await bus.start()
|
await bus.start()
|
||||||
@@ -59,12 +61,22 @@ async def build_simulation(
|
|||||||
**FakePlayer.volume_kwargs(config.general),
|
**FakePlayer.volume_kwargs(config.general),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if library is None:
|
||||||
|
library_config = config.general.library
|
||||||
|
library = await MusicLibrary.build(
|
||||||
|
library_config.root,
|
||||||
|
library_config.cache,
|
||||||
|
frozenset(config.general.audio_extensions),
|
||||||
|
figure_kinds=config.figure_kinds,
|
||||||
|
)
|
||||||
|
|
||||||
app = App(
|
app = App(
|
||||||
config=config,
|
config=config,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
mouse=mouse,
|
mouse=mouse,
|
||||||
player=player,
|
player=player,
|
||||||
playlists=build_playlists(config),
|
library=library,
|
||||||
|
playlists=library.figure_playlists(),
|
||||||
clock=clock,
|
clock=clock,
|
||||||
)
|
)
|
||||||
register_all(bus, app)
|
register_all(bus, app)
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ description = "Host backend for the MusicMouse RFID music player"
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"mutagen>=1.47",
|
||||||
|
"pillow>=10.4",
|
||||||
"pydantic>=2.7",
|
"pydantic>=2.7",
|
||||||
"pyserial-asyncio>=0.6",
|
"pyserial-asyncio>=0.6",
|
||||||
"python-vlc>=3.0",
|
"python-vlc>=3.0",
|
||||||
"ruamel.yaml>=0.18",
|
"ruamel.yaml>=0.18",
|
||||||
|
"uvicorn>=0.30",
|
||||||
|
# uvicorn ships no websocket implementation of its own; without this the
|
||||||
|
# state-push upgrade is answered with a 404 and the UI never updates.
|
||||||
|
"websockets>=13",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
|
# httpx2 is what starlette.testclient wants now; plain httpx is deprecated there.
|
||||||
|
dev = ["httpx2>=2.12", "mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
musicmouse = "musicmouse.__main__:main"
|
musicmouse = "musicmouse.__main__:main"
|
||||||
|
|||||||
52
python-backend/scenarios/web_play.txt
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# The web front-end's path: any album, not just the five with a figure.
|
||||||
|
# Run it by hand with:
|
||||||
|
# python -m musicmouse --config <config.yml> --simulate --script scenarios/web_play.txt
|
||||||
|
|
||||||
|
expect playing false
|
||||||
|
expect ring none
|
||||||
|
|
||||||
|
# Starting an album that no figure owns lights the strips differently from a figure -
|
||||||
|
# a slow three-quarter circle in the album's own colour, so the shelf shows which way
|
||||||
|
# the mouse was started.
|
||||||
|
album Kinderparty Lieder
|
||||||
|
expect playing true
|
||||||
|
expect album Kinderparty Lieder
|
||||||
|
expect track 0
|
||||||
|
expect ring EffectCircularConfig
|
||||||
|
expect mouse EffectCircularConfig
|
||||||
|
expect shelf EffectCircularConfig
|
||||||
|
expect brightness 0.50
|
||||||
|
|
||||||
|
# Transport works the same whoever asked for it.
|
||||||
|
next
|
||||||
|
expect track 1
|
||||||
|
|
||||||
|
wait 6s
|
||||||
|
expect playing false
|
||||||
|
expect ring EffectReverseSwipe
|
||||||
|
|
||||||
|
# A figure still wins: its animation owns the strips while it is on the reader.
|
||||||
|
place fuchs
|
||||||
|
expect figure fuchs
|
||||||
|
expect album Fuchs
|
||||||
|
expect ring EffectSwipeAndChange
|
||||||
|
|
||||||
|
# ...and the web animation does not fight it.
|
||||||
|
album Eule
|
||||||
|
expect album Eule
|
||||||
|
expect ring EffectSwipeAndChange
|
||||||
|
|
||||||
|
remove
|
||||||
|
expect playing false
|
||||||
|
|
||||||
|
# Starting a figure's album from the web is the same playlist the figure plays.
|
||||||
|
album Fuchs
|
||||||
|
expect playlist fuchs
|
||||||
|
expect playing true
|
||||||
|
|
||||||
|
seek 2s
|
||||||
|
expect position 2.0
|
||||||
|
|
||||||
|
pause
|
||||||
|
expect playing false
|
||||||
|
expect ring EffectReverseSwipe
|
||||||
@@ -6,29 +6,93 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
#: Half a second of silence, so ``mutagen`` reports a real duration and tags can be
|
||||||
|
#: written onto a file that is actually an MP3. Generated once with ffmpeg.
|
||||||
|
SILENCE = Path(__file__).parent / "data" / "silence.mp3"
|
||||||
|
|
||||||
VALID_CONFIG: dict[str, Any] = {
|
VALID_CONFIG: dict[str, Any] = {
|
||||||
"general": {
|
"general": {
|
||||||
"figure_folder": "music",
|
"library": {"root": "music", "cache": ".cache"},
|
||||||
"serial_port": "/dev/ttyUSB0",
|
"serial_port": "/dev/ttyUSB0",
|
||||||
|
"alsa_device": "simulate",
|
||||||
"min_volume": 0,
|
"min_volume": 0,
|
||||||
"max_volume": 60,
|
"max_volume": 60,
|
||||||
"initial_volume": 40,
|
"initial_volume": 40,
|
||||||
},
|
},
|
||||||
"figures": {
|
"figures": {
|
||||||
"fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]},
|
"fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]},
|
||||||
"eule": {"id": "04b2c3d4e5", "colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"]},
|
# One of each, so the figure-kind branch is exercised by every fixture.
|
||||||
|
"eule": {
|
||||||
|
"id": "04b2c3d4e5",
|
||||||
|
"colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"],
|
||||||
|
"kind": "book",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_track(path: Path, **tags: str) -> Path:
|
||||||
|
"""Copy the silence fixture to ``path`` and stamp the given easy-mode ID3 tags."""
|
||||||
|
import mutagen
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(SILENCE.read_bytes())
|
||||||
|
if tags:
|
||||||
|
audio = mutagen.File(path, easy=True)
|
||||||
|
if audio.tags is None:
|
||||||
|
audio.add_tags()
|
||||||
|
for key, value in tags.items():
|
||||||
|
audio[key] = value
|
||||||
|
audio.save()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def config_dir(tmp_path: Path) -> Path:
|
def config_dir(tmp_path: Path) -> Path:
|
||||||
"""A directory with a valid ``music/`` tree and two figures' worth of tracks."""
|
"""A directory holding a small but structurally real music library.
|
||||||
|
|
||||||
|
Two figure folders (matching ``VALID_CONFIG``), one music album, one audiobook and
|
||||||
|
one podcast - enough for every branch the scanner has.
|
||||||
|
"""
|
||||||
|
root = tmp_path / "music"
|
||||||
|
|
||||||
for figure, tracks in (("fuchs", 3), ("eule", 2)):
|
for figure, tracks in (("fuchs", 3), ("eule", 2)):
|
||||||
folder = tmp_path / "music" / figure
|
|
||||||
folder.mkdir(parents=True)
|
|
||||||
for index in range(tracks):
|
for index in range(tracks):
|
||||||
(folder / f"{index:02d} - track.mp3").write_bytes(b"")
|
write_track(
|
||||||
|
root / "Figuren" / figure / f"{index:02d} - track.mp3",
|
||||||
|
title=f"Track {index}",
|
||||||
|
album="Waldlieder",
|
||||||
|
albumartist="Die Tiere",
|
||||||
|
)
|
||||||
|
|
||||||
|
for index in range(2):
|
||||||
|
write_track(
|
||||||
|
root / "Musik" / "Kinderparty - Kinderparty Lieder" / f"{index:02d} - lied.mp3",
|
||||||
|
title=f"Lied {index}",
|
||||||
|
album="Kinderparty Lieder",
|
||||||
|
albumartist="Kinderparty",
|
||||||
|
)
|
||||||
|
|
||||||
|
for index in range(2):
|
||||||
|
write_track(
|
||||||
|
root / "Hörbücher" / "Conni - Conni in den Bergen" / f"{index:02d} - teil.mp3",
|
||||||
|
title=f"Teil {index}",
|
||||||
|
album="Conni in den Bergen",
|
||||||
|
albumartist="Conni, Julia Boehme",
|
||||||
|
)
|
||||||
|
|
||||||
|
podcast = root / "Kinderpodcasts" / "Wissen macht Ah"
|
||||||
|
for date, title in (("20240101", "Alt"), ("20260101", "Neu")):
|
||||||
|
write_track(
|
||||||
|
podcast / f"{date} - {title}.mp3",
|
||||||
|
title=title,
|
||||||
|
album="Wissen macht Ah! - Podcast",
|
||||||
|
albumartist="Ein Name, Noch Einer, Und Einer",
|
||||||
|
)
|
||||||
|
# The two kinds of scratch file a podcast downloader leaves behind.
|
||||||
|
(podcast / "archive.json").write_text("[]")
|
||||||
|
(podcast / ".podcast-dl-abc.download.tmp").write_bytes(b"")
|
||||||
|
|
||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
python-backend/tests/data/silence.mp3
Normal file
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.color import ColorRGBW, parse_color
|
from musicmouse.color import ColorRGBW, parse_color
|
||||||
from musicmouse.config import ConfigError, build_playlists, load_config
|
from musicmouse.config import ConfigError, load_config
|
||||||
from tests.conftest import VALID_CONFIG, write_config
|
from tests.conftest import VALID_CONFIG, write_config
|
||||||
|
|
||||||
|
|
||||||
@@ -27,57 +27,29 @@ def test_loads_valid_config(config_dir: Path) -> None:
|
|||||||
assert config.general.max_volume == 60
|
assert config.general.max_volume == 60
|
||||||
|
|
||||||
|
|
||||||
def test_figure_folder_resolves_relative_to_the_config_file(config_dir: Path) -> None:
|
def test_library_root_resolves_relative_to_the_config_file(config_dir: Path) -> None:
|
||||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||||
assert config.general.figure_folder == (config_dir / "music").resolve()
|
assert config.general.library.root == (config_dir / "music").resolve()
|
||||||
|
assert config.general.library.figure_folder == (config_dir / "music" / "Figuren").resolve()
|
||||||
|
assert config.folder_for("fuchs").name == "fuchs"
|
||||||
|
|
||||||
|
|
||||||
def test_tag_map_and_playlists(config_dir: Path) -> None:
|
def test_tag_map(config_dir: Path) -> None:
|
||||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||||
|
|
||||||
assert config.tag_map == {
|
assert config.tag_map == {
|
||||||
bytes.fromhex("04a1b2c3d4"): "fuchs",
|
bytes.fromhex("04a1b2c3d4"): "fuchs",
|
||||||
bytes.fromhex("04b2c3d4e5"): "eule",
|
bytes.fromhex("04b2c3d4e5"): "eule",
|
||||||
}
|
}
|
||||||
playlists = build_playlists(config)
|
|
||||||
assert [t.path.name for t in playlists["fuchs"].tracks] == [
|
|
||||||
"00 - track.mp3",
|
|
||||||
"01 - track.mp3",
|
|
||||||
"02 - track.mp3",
|
|
||||||
]
|
|
||||||
assert len(playlists["eule"]) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_playlist_is_alphabetical_regardless_of_creation_order(config_dir: Path) -> None:
|
def test_web_section_is_optional(config_dir: Path) -> None:
|
||||||
folder = config_dir / "music" / "fuchs"
|
assert load_config(write_config(config_dir, VALID_CONFIG)).general.web is None
|
||||||
for name in ("zz last.mp3", "aa first.mp3"):
|
|
||||||
(folder / name).write_bytes(b"")
|
|
||||||
|
|
||||||
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
|
|
||||||
names = [t.path.name for t in playlists["fuchs"].tracks]
|
|
||||||
assert names == sorted(names)
|
|
||||||
assert names[0] == "00 - track.mp3"
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_audio_files_are_ignored(config_dir: Path) -> None:
|
|
||||||
(config_dir / "music" / "eule" / "cover.jpg").write_bytes(b"")
|
|
||||||
(config_dir / "music" / "eule" / "notes.txt").write_bytes(b"")
|
|
||||||
|
|
||||||
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
|
|
||||||
assert len(playlists["eule"]) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_figure_folder_warns_but_does_not_fail(
|
|
||||||
config_dir: Path, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
data = copy.deepcopy(VALID_CONFIG)
|
|
||||||
data["figures"]["neu"] = {"id": "0400000001", "colors": ["#111111"] * 4}
|
|
||||||
|
|
||||||
|
data = _config(web={"port": 9000, "static_dir": "dist"})
|
||||||
config = load_config(write_config(config_dir, data))
|
config = load_config(write_config(config_dir, data))
|
||||||
playlists = build_playlists(config)
|
assert config.general.web is not None
|
||||||
|
assert config.general.web.port == 9000
|
||||||
assert len(playlists["neu"]) == 0
|
assert config.general.web.static_dir == (config_dir / "dist").resolve()
|
||||||
assert "no media folder" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- error paths
|
# --------------------------------------------------------------------- error paths
|
||||||
@@ -147,9 +119,9 @@ def test_initial_volume_must_lie_in_range(config_dir: Path) -> None:
|
|||||||
assert "must lie between" in message
|
assert "must lie between" in message
|
||||||
|
|
||||||
|
|
||||||
def test_missing_figure_folder_is_an_error(config_dir: Path) -> None:
|
def test_missing_library_root_is_an_error(config_dir: Path) -> None:
|
||||||
message = _error(config_dir, _config(figure_folder="does-not-exist"))
|
message = _error(config_dir, _config(library={"root": "does-not-exist"}))
|
||||||
assert "general.figure_folder" in message
|
assert "general.library.root" in message
|
||||||
assert "no such directory" in message
|
assert "no such directory" in message
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
309
python-backend/tests/test_library.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""The library scan: what ends up in the index, and what stays out of it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||||
|
from musicmouse.library import Album, MusicLibrary
|
||||||
|
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis
|
||||||
|
from musicmouse.library.cache import LibraryCache
|
||||||
|
from musicmouse.library.colors import colors_from_id
|
||||||
|
from musicmouse.library.models import album_id
|
||||||
|
from tests.conftest import VALID_CONFIG, write_config, write_track
|
||||||
|
|
||||||
|
EXTENSIONS = frozenset(DEFAULT_AUDIO_EXTENSIONS)
|
||||||
|
|
||||||
|
|
||||||
|
async def build(config_dir: Path) -> MusicLibrary:
|
||||||
|
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||||
|
return await MusicLibrary.build(
|
||||||
|
config.general.library.root,
|
||||||
|
config.general.library.cache,
|
||||||
|
EXTENSIONS,
|
||||||
|
figure_kinds=config.figure_kinds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def album_named(library: MusicLibrary, title: str) -> Album:
|
||||||
|
return next(album for album in library.albums if album.title == title)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ structure
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_section_is_scanned(config_dir: Path) -> None:
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert {album.title for album in library.albums} == {
|
||||||
|
"Fuchs",
|
||||||
|
"Eule",
|
||||||
|
"Kinderparty Lieder",
|
||||||
|
"Conni in den Bergen",
|
||||||
|
"Wissen macht Ah",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> None:
|
||||||
|
library = await build(config_dir)
|
||||||
|
playlists = library.figure_playlists()
|
||||||
|
|
||||||
|
assert set(playlists) == {"fuchs", "eule"}
|
||||||
|
assert len(playlists["fuchs"]) == 3
|
||||||
|
assert [track.path.name for track in playlists["fuchs"].tracks] == [
|
||||||
|
"00 - track.mp3",
|
||||||
|
"01 - track.mp3",
|
||||||
|
"02 - track.mp3",
|
||||||
|
]
|
||||||
|
# The playlist carries the album it came from, which is how a front-end answers
|
||||||
|
# "what is playing?" without keeping its own copy of that state.
|
||||||
|
assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
||||||
|
assert album.kind == "music"
|
||||||
|
assert album.artist == "Kinderparty"
|
||||||
|
# Music groups by artist, so it has no series.
|
||||||
|
assert album.series is None
|
||||||
|
assert album.category == "Kinderparty"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_audiobooks_group_by_the_name_before_the_comma(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Conni in den Bergen")
|
||||||
|
assert album.kind == "book"
|
||||||
|
# album_artist is a credit list; only the first name is the character.
|
||||||
|
assert album.artist == "Conni, Julia Boehme"
|
||||||
|
assert album.series == "Conni"
|
||||||
|
assert album.category == "Conni"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_podcasts_are_named_after_the_folder_not_the_tags(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Wissen macht Ah")
|
||||||
|
# The tags say album="Wissen macht Ah! - Podcast" and artist=<six presenters>.
|
||||||
|
assert album.kind == "book"
|
||||||
|
assert album.artist == "Wissen macht Ah"
|
||||||
|
assert album.series == "Wissen macht Ah"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Wissen macht Ah")
|
||||||
|
assert [track.title for track in album.tracks] == ["Neu", "Alt"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_other_sections_keep_filename_order(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
||||||
|
assert [track.path.name for track in album.tracks] == [
|
||||||
|
"00 - lied.mp3",
|
||||||
|
"01 - lied.mp3",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_figures_are_marked_and_others_are_not(config_dir: Path) -> None:
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert album_named(library, "Fuchs").figure == "fuchs"
|
||||||
|
assert album_named(library, "Kinderparty Lieder").figure is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_durations_come_from_the_files(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Eule")
|
||||||
|
assert all(track.duration > 0 for track in album.tracks)
|
||||||
|
assert album.duration == pytest.approx(sum(t.duration for t in album.tracks))
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- what is skipped
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scratch_files_never_reach_a_playlist(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Wissen macht Ah")
|
||||||
|
names = {track.path.name for track in album.tracks}
|
||||||
|
assert "archive.json" not in names
|
||||||
|
assert not any(name.startswith(".") for name in names)
|
||||||
|
assert len(album.tracks) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_top_level_folders_are_ignored(config_dir: Path) -> None:
|
||||||
|
stray = config_dir / "music" / "Sonstiges" / "Irgendwas"
|
||||||
|
write_track(stray / "01 - x.mp3", title="X")
|
||||||
|
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert not any(album.section == "Sonstiges" for album in library.albums)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_missing_section_warns_rather_than_failing(
|
||||||
|
config_dir: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(config_dir / "music" / "Musik")
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
assert "Musik" in caplog.text
|
||||||
|
assert len(library.albums) == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_empty_album_folder_is_not_an_album(config_dir: Path) -> None:
|
||||||
|
(config_dir / "music" / "Musik" / "Leer").mkdir()
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert not any(album.title == "Leer" for album in library.albums)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- colours
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_album_has_three_colours(config_dir: Path) -> None:
|
||||||
|
library = await build(config_dir)
|
||||||
|
for album in library.albums:
|
||||||
|
assert len(album.colors) == 3
|
||||||
|
assert all(colour.startswith("#") and len(colour) == 7 for colour in album.colors)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_colours_are_synthesised_when_there_is_no_cover(config_dir: Path) -> None:
|
||||||
|
album = album_named(await build(config_dir), "Fuchs")
|
||||||
|
assert album.cover is None
|
||||||
|
assert album.colors == colors_from_id(album.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
folder = config_dir / "music" / "Musik" / "Kinderparty - Kinderparty Lieder"
|
||||||
|
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
|
||||||
|
|
||||||
|
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
||||||
|
assert album.cover == folder / "cover.jpg"
|
||||||
|
# A solid red cover has one usable colour; the rest fall back to the synthesised
|
||||||
|
# palette rather than repeating it.
|
||||||
|
assert album.colors[0] != colors_from_id(album.id)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- cache
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_second_build_reuses_the_index(config_dir: Path) -> None:
|
||||||
|
first = await build(config_dir)
|
||||||
|
second = await build(config_dir)
|
||||||
|
assert {a.id for a in first.albums} == {a.id for a in second.albums}
|
||||||
|
assert (config_dir / ".cache" / "index.json").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_changed_folder_is_rescanned(config_dir: Path) -> None:
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert len(album_named(library, "Eule").tracks) == 2
|
||||||
|
|
||||||
|
write_track(config_dir / "music" / "Figuren" / "eule" / "99 - neu.mp3", title="Neu")
|
||||||
|
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert len(album_named(library, "Eule").tracks) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_rescan_leaves_analysis_alone(config_dir: Path) -> None:
|
||||||
|
"""The whole reason the cache is a directory rather than one file."""
|
||||||
|
library = await build(config_dir)
|
||||||
|
album = album_named(library, "Eule")
|
||||||
|
from musicmouse.library.models import track_key
|
||||||
|
|
||||||
|
key = track_key(album.tracks[0].path)
|
||||||
|
library.cache.store_analysis(key, TrackAnalysis(version=ANALYZER_VERSION, tempo=128.0))
|
||||||
|
library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
|
||||||
|
|
||||||
|
# Force a full rescan by dropping the cheap part of the cache.
|
||||||
|
(config_dir / ".cache" / "index.json").unlink()
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
album = album_named(library, "Eule")
|
||||||
|
assert album.tracks[0].analysis is not None
|
||||||
|
assert album.tracks[0].analysis.tempo == 128.0
|
||||||
|
grid = library.beats(album.id, 0)
|
||||||
|
assert grid is not None
|
||||||
|
assert grid.times == (0.5, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_analysis_survives_an_analyzer_that_grew_a_field(tmp_path: Path) -> None:
|
||||||
|
cache = LibraryCache(tmp_path / "cache")
|
||||||
|
cache.prepare()
|
||||||
|
(cache.analysis / "abc.json").write_text(
|
||||||
|
json.dumps({"version": 1, "tempo": 90.0, "danceability": 0.7})
|
||||||
|
)
|
||||||
|
|
||||||
|
analysis = cache.load_analysis("abc")
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.tempo == 90.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_corrupt_index_is_rebuilt_rather_than_fatal(config_dir: Path) -> None:
|
||||||
|
await build(config_dir)
|
||||||
|
(config_dir / ".cache" / "index.json").write_text("{ not json")
|
||||||
|
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert len(library.albums) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None:
|
||||||
|
first = album_id(tmp_path, tmp_path / "Musik" / "Ein Album")
|
||||||
|
second = album_id(tmp_path, tmp_path / "Musik" / "Ein Album")
|
||||||
|
other = album_id(tmp_path, tmp_path / "Musik" / "Ein Anderes")
|
||||||
|
assert first == second
|
||||||
|
assert first != other
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- figure kinds
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_is_music_unless_the_config_says_otherwise(
|
||||||
|
config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A figure folder is named after the figurine, so nothing on disk says what it is."""
|
||||||
|
library = await build(config_dir)
|
||||||
|
assert album_named(library, "Fuchs").kind == "music"
|
||||||
|
assert album_named(library, "Eule").kind == "book"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_book_figure_groups_with_the_audiobooks(config_dir: Path) -> None:
|
||||||
|
"""The bug this fixed: a figure holding an audiobook landed in its artist's shelf
|
||||||
|
as music, so the category showed "4 Alben" and was drawn like one."""
|
||||||
|
for index in range(2):
|
||||||
|
write_track(
|
||||||
|
config_dir / "music" / "Figuren" / "eule" / f"{index:02d} - track.mp3",
|
||||||
|
title=f"Teil {index}",
|
||||||
|
album="Conni in den Bergen",
|
||||||
|
albumartist="Conni, Julia Boehme",
|
||||||
|
)
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
figure = album_named(library, "Eule")
|
||||||
|
assert figure.kind == "book"
|
||||||
|
# Books group by series, which a music album does not have at all.
|
||||||
|
assert figure.series == "Conni"
|
||||||
|
assert figure.category == "Conni"
|
||||||
|
|
||||||
|
conni = [album for album in library.albums if album.category == "Conni"]
|
||||||
|
assert len(conni) == 2
|
||||||
|
assert {album.kind for album in conni} == {"book"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_music_figure_has_no_series(config_dir: Path) -> None:
|
||||||
|
figure = album_named(await build(config_dir), "Fuchs")
|
||||||
|
assert figure.series is None
|
||||||
|
assert figure.category == "Die Tiere"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figures_kind_survives_the_cache(config_dir: Path) -> None:
|
||||||
|
"""It cannot be re-derived on load: the section says "Figuren", not what is in it.
|
||||||
|
|
||||||
|
The index used to rebuild `kind` from the section, so a book figure came back as
|
||||||
|
music on the second boot - and the browse view drew it square.
|
||||||
|
"""
|
||||||
|
first = await build(config_dir)
|
||||||
|
assert album_named(first, "Eule").kind == "book"
|
||||||
|
assert (config_dir / ".cache" / "index.json").is_file()
|
||||||
|
|
||||||
|
# Nothing on disk changed, so this run is served entirely from the index.
|
||||||
|
second = await build(config_dir)
|
||||||
|
eule = album_named(second, "Eule")
|
||||||
|
assert eule.kind == "book"
|
||||||
|
assert eule.series is not None
|
||||||
|
assert album_named(second, "Fuchs").kind == "music"
|
||||||
|
assert album_named(second, "Fuchs").series is None
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Tests for the shared player behaviour, exercised through FakePlayer.
|
"""Tests for the shared player behaviour, exercised through FakePlayer.
|
||||||
|
|
||||||
VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio
|
VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio
|
||||||
device and is covered by the on-device checklist, not here.
|
device and is covered by the on-device checklist, not here - except for how it works
|
||||||
|
out which track is playing, which is stubbed out at the bottom of this file because
|
||||||
|
getting it wrong is invisible until a front-end displays it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,7 +15,7 @@ import pytest
|
|||||||
|
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.clock import FakeClock
|
from musicmouse.clock import FakeClock
|
||||||
from musicmouse.devices.player import Player, VlcPlayer
|
from musicmouse.devices.player import Player, PlayerBase, VlcPlayer
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
Event,
|
Event,
|
||||||
PlaybackChanged,
|
PlaybackChanged,
|
||||||
@@ -295,3 +297,69 @@ def test_fake_player_satisfies_the_player_protocol(player: FakePlayer) -> None:
|
|||||||
def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player:
|
def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player:
|
||||||
"""Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate."""
|
"""Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate."""
|
||||||
return real
|
return real
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- track index without libVLC
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMedia:
|
||||||
|
def __init__(self, mrl: str) -> None:
|
||||||
|
self._mrl = mrl
|
||||||
|
|
||||||
|
def get_mrl(self) -> str:
|
||||||
|
return self._mrl
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMediaPlayer:
|
||||||
|
"""Just enough of libVLC's media player to exercise index syncing."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.media: _FakeMedia | None = None
|
||||||
|
|
||||||
|
def get_media(self) -> _FakeMedia | None:
|
||||||
|
return self.media
|
||||||
|
|
||||||
|
|
||||||
|
def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer:
|
||||||
|
"""A VlcPlayer with its libVLC parts stubbed out, without calling __init__."""
|
||||||
|
player = object.__new__(VlcPlayer)
|
||||||
|
PlayerBase.__init__(player, bus)
|
||||||
|
player._media_player = _FakeMediaPlayer() # type: ignore[assignment]
|
||||||
|
player._playlist = playlist
|
||||||
|
player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)}
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> None:
|
||||||
|
"""Not taken from the event payload.
|
||||||
|
|
||||||
|
``MediaListPlayerNextItemSet`` carries a bare int rather than a Media on some
|
||||||
|
libVLC builds, and trusting it left the index pinned at zero: the UI showed track 1
|
||||||
|
of an album that was audibly on track 3.
|
||||||
|
"""
|
||||||
|
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
|
||||||
|
player = _vlc_like(bus, playlist)
|
||||||
|
events: list[Event] = []
|
||||||
|
bus.subscribe(TrackChanged, events.append)
|
||||||
|
|
||||||
|
player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined]
|
||||||
|
player._on_next_item(object())
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert player.track_index == 2
|
||||||
|
assert player.current_track is not None
|
||||||
|
assert player.current_track.path.name == "2.mp3"
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None:
|
||||||
|
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
|
||||||
|
player = _vlc_like(bus, playlist)
|
||||||
|
|
||||||
|
player._media_player.media = None # type: ignore[attr-defined]
|
||||||
|
player._on_next_item(object())
|
||||||
|
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined]
|
||||||
|
player._on_next_item(object())
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert player.track_index == 0
|
||||||
|
|||||||
176
python-backend/tests/test_settings.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Parent mode: the small set of settings the UI may change, and how they are saved."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.config import WebConfig, load_config
|
||||||
|
from musicmouse.services.web.service import build_app
|
||||||
|
from musicmouse.services.web.settings import to_device_volume, to_percent
|
||||||
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
|
from tests.conftest import VALID_CONFIG, write_config
|
||||||
|
|
||||||
|
SETTINGS = {
|
||||||
|
"min_volume": 0,
|
||||||
|
"max_volume": 60,
|
||||||
|
"initial_volume": 40,
|
||||||
|
"volume_increment": 5,
|
||||||
|
"button_leds_brightness": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: A config file with comments, to prove saving does not flatten them.
|
||||||
|
COMMENTED = """\
|
||||||
|
# The MusicMouse config.
|
||||||
|
general:
|
||||||
|
library:
|
||||||
|
root: music # where the shelves live
|
||||||
|
cache: .cache
|
||||||
|
serial_port: "/dev/ttyUSB0"
|
||||||
|
alsa_device: simulate # no sound from the test suite, please
|
||||||
|
# Volume, 0..100.
|
||||||
|
min_volume: 0
|
||||||
|
max_volume: 60
|
||||||
|
initial_volume: 40
|
||||||
|
|
||||||
|
figures:
|
||||||
|
fuchs:
|
||||||
|
id: "04a1b2c3d4"
|
||||||
|
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||||
|
eule:
|
||||||
|
id: "04b2c3d4e5"
|
||||||
|
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||||
|
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||||
|
simulation = await build_simulation(config)
|
||||||
|
try:
|
||||||
|
yield simulation
|
||||||
|
finally:
|
||||||
|
await simulation.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]:
|
||||||
|
path = config_dir / "config.yml"
|
||||||
|
path.write_text(COMMENTED, encoding="utf-8")
|
||||||
|
api, hub = build_app(sim.app, WebConfig(), path)
|
||||||
|
hub.start()
|
||||||
|
transport = httpx2.ASGITransport(app=api)
|
||||||
|
try:
|
||||||
|
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
|
||||||
|
yield http
|
||||||
|
finally:
|
||||||
|
hub.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- mapping
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_percent_is_the_configured_ceiling(config_dir: Path) -> None:
|
||||||
|
general = load_config(write_config(config_dir, VALID_CONFIG)).general
|
||||||
|
assert to_device_volume(100, general) == 60
|
||||||
|
assert to_device_volume(0, general) == 0
|
||||||
|
assert to_device_volume(50, general) == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_percent_round_trips(config_dir: Path) -> None:
|
||||||
|
general = load_config(write_config(config_dir, VALID_CONFIG)).general
|
||||||
|
for percent in (0, 25, 50, 75, 100):
|
||||||
|
assert to_percent(to_device_volume(percent, general), general) == percent
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None:
|
||||||
|
"""min == max is a legal config; it must not divide by zero."""
|
||||||
|
general = load_config(write_config(config_dir, VALID_CONFIG)).general
|
||||||
|
general.min_volume = general.max_volume = 40
|
||||||
|
assert to_percent(40, general) == 100
|
||||||
|
assert to_device_volume(50, general) == 40
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------- reading
|
||||||
|
|
||||||
|
|
||||||
|
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
|
||||||
|
body = (await client.get("/api/settings")).json()
|
||||||
|
assert set(body) == set(SETTINGS)
|
||||||
|
assert body["max_volume"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------- writing
|
||||||
|
|
||||||
|
|
||||||
|
async def test_saving_keeps_the_file_readable_and_commented(
|
||||||
|
client: httpx2.AsyncClient, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
response = await client.put("/api/settings", json={**SETTINGS, "max_volume": 45})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
text = (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||||
|
assert "max_volume: 45" in text
|
||||||
|
assert "# The MusicMouse config." in text
|
||||||
|
assert "# where the shelves live" in text
|
||||||
|
assert "# Volume, 0..100." in text
|
||||||
|
assert "# no sound from the test suite, please" in text
|
||||||
|
|
||||||
|
# And it still loads.
|
||||||
|
reloaded = load_config(config_dir / "config.yml")
|
||||||
|
assert reloaded.general.max_volume == 45
|
||||||
|
assert set(reloaded.figures) == {"fuchs", "eule"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_new_ceiling_applies_to_the_running_player(
|
||||||
|
client: httpx2.AsyncClient, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
"""A parent lowering the ceiling expects the next song to be quieter, not the next boot."""
|
||||||
|
await client.post("/api/volume", json={"percent": 100})
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert sim.player.volume == 60
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
"/api/settings", json={**SETTINGS, "max_volume": 30, "initial_volume": 20}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert sim.player.volume == 30
|
||||||
|
assert (await client.get("/api/state")).json()["volume"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_inverted_range_is_rejected(client: httpx2.AsyncClient) -> None:
|
||||||
|
response = await client.put("/api/settings", json={**SETTINGS, "min_volume": 70})
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "min_volume" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_initial_volume_outside_the_range_is_rejected(
|
||||||
|
client: httpx2.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
response = await client.put("/api/settings", json={**SETTINGS, "initial_volume": 90})
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "initial_volume" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_out_of_bounds_values_are_rejected_by_the_schema(
|
||||||
|
client: httpx2.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
assert (
|
||||||
|
await client.put("/api/settings", json={**SETTINGS, "button_leds_brightness": 5})
|
||||||
|
).status_code == 422
|
||||||
|
assert (
|
||||||
|
await client.put("/api/settings", json={**SETTINGS, "volume_increment": 0})
|
||||||
|
).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_rejected_save_leaves_the_file_alone(
|
||||||
|
client: httpx2.AsyncClient, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
before = (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||||
|
await client.put("/api/settings", json={**SETTINGS, "min_volume": 70})
|
||||||
|
assert (config_dir / "config.yml").read_text(encoding="utf-8") == before
|
||||||
128
python-backend/tests/test_startup.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""What the app does when the config leaves the hardware out.
|
||||||
|
|
||||||
|
Both of these are deliberately warnings rather than errors: a spare machine running
|
||||||
|
only the web front-end is a supported way to use this, and it should say so once at
|
||||||
|
startup rather than fail or - worse - look like it is working when it is not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.__main__ import _build_player, wants_hardware
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import RealClock
|
||||||
|
from musicmouse.config import SIMULATE, Config, ConfigError, load_config
|
||||||
|
from musicmouse.devices.null_transport import NullTransport
|
||||||
|
from musicmouse.simulator.fake_player import FakePlayer
|
||||||
|
from tests.conftest import VALID_CONFIG, write_config
|
||||||
|
|
||||||
|
|
||||||
|
def _config(config_dir: Path, *, drop: tuple[str, ...] = (), **general: Any) -> Config:
|
||||||
|
data = copy.deepcopy(VALID_CONFIG)
|
||||||
|
data["general"].update(general)
|
||||||
|
for key in drop:
|
||||||
|
data["general"].pop(key, None)
|
||||||
|
return load_config(write_config(config_dir, data))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- serial port
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_serial_port_is_an_error(config_dir: Path) -> None:
|
||||||
|
"""Simulation has to be asked for. A config that lost the line must not boot."""
|
||||||
|
with pytest.raises(ConfigError) as excinfo:
|
||||||
|
_config(config_dir, drop=("serial_port",))
|
||||||
|
assert "general.serial_port" in str(excinfo.value)
|
||||||
|
assert "required" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_alsa_device_is_an_error(config_dir: Path) -> None:
|
||||||
|
with pytest.raises(ConfigError) as excinfo:
|
||||||
|
_config(config_dir, drop=("alsa_device",))
|
||||||
|
assert "general.alsa_device" in str(excinfo.value)
|
||||||
|
assert "required" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_missing_are_reported_together(config_dir: Path) -> None:
|
||||||
|
with pytest.raises(ConfigError) as excinfo:
|
||||||
|
_config(config_dir, drop=("serial_port", "alsa_device"))
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "2 problems" in message
|
||||||
|
assert "general.serial_port" in message
|
||||||
|
assert "general.alsa_device" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_simulate_switches_off_the_serial_link(
|
||||||
|
config_dir: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
config = _config(config_dir, serial_port=SIMULATE)
|
||||||
|
assert config.general.serial_simulated is True
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
assert wants_hardware(config, False) is False
|
||||||
|
assert "running without the mouse" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_port_is_used(config_dir: Path) -> None:
|
||||||
|
config = _config(config_dir)
|
||||||
|
assert config.general.serial_simulated is False
|
||||||
|
assert wants_hardware(config, False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_flag_wins_over_a_configured_port(config_dir: Path) -> None:
|
||||||
|
assert wants_hardware(_config(config_dir), True) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ audio device
|
||||||
|
|
||||||
|
|
||||||
|
async def test_simulate_gives_a_silent_player(
|
||||||
|
config_dir: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
bus = EventBus()
|
||||||
|
await bus.start()
|
||||||
|
try:
|
||||||
|
general = _config(config_dir, alsa_device=SIMULATE).general
|
||||||
|
assert general.audio_simulated is True
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
player = _build_player(bus, general, clock=RealClock())
|
||||||
|
|
||||||
|
assert isinstance(player, FakePlayer)
|
||||||
|
assert "alsa_device" in caplog.text
|
||||||
|
assert "nothing will be audible" in caplog.text
|
||||||
|
# The clamps still come from the config, so volume behaves the same either way.
|
||||||
|
assert player.volume == 40
|
||||||
|
player.set_volume(999)
|
||||||
|
assert player.volume == 60
|
||||||
|
finally:
|
||||||
|
player.close()
|
||||||
|
await bus.stop()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_alsa_device_asks_for_the_real_player(config_dir: Path) -> None:
|
||||||
|
"""Only that it tries: instantiating VlcPlayer needs libVLC, which CI has not got."""
|
||||||
|
bus = EventBus()
|
||||||
|
await bus.start()
|
||||||
|
try:
|
||||||
|
general = _config(config_dir, alsa_device="default").general
|
||||||
|
assert general.audio_simulated is False
|
||||||
|
with pytest.raises(Exception): # noqa: B017 - ImportError or an OSError from libVLC
|
||||||
|
_build_player(bus, general, clock=RealClock())
|
||||||
|
finally:
|
||||||
|
await bus.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- null transport
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_null_transport_drops_what_it_is_handed() -> None:
|
||||||
|
transport = NullTransport()
|
||||||
|
transport.write(b"\x00\x01\x02")
|
||||||
|
assert transport.connected is False
|
||||||
357
python-backend/tests/test_web.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
"""The web front-end, driven against the simulator.
|
||||||
|
|
||||||
|
Everything below the HTTP layer is production code: the same bus, the same reactions,
|
||||||
|
the same player interface - only the serial link and VLC are fake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from musicmouse.config import WebConfig, load_config
|
||||||
|
from musicmouse.services.web.service import build_app
|
||||||
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
|
from tests.conftest import VALID_CONFIG, write_config
|
||||||
|
from tests.websocket_harness import websocket_connect
|
||||||
|
|
||||||
|
TRACK_SECONDS = 10.0
|
||||||
|
|
||||||
|
#: Shorthand: every test takes the same client type.
|
||||||
|
type Client = httpx2.AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||||
|
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||||
|
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
|
||||||
|
try:
|
||||||
|
yield simulation
|
||||||
|
finally:
|
||||||
|
await simulation.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api(sim: Simulation, config_dir: Path) -> Iterator[FastAPI]:
|
||||||
|
"""The real ASGI app, on the test's own event loop."""
|
||||||
|
application, hub = build_app(sim.app, WebConfig(), config_dir / "config.yml")
|
||||||
|
hub.start()
|
||||||
|
try:
|
||||||
|
yield application
|
||||||
|
finally:
|
||||||
|
hub.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(api: FastAPI) -> AsyncIterator[httpx2.AsyncClient]:
|
||||||
|
transport = httpx2.ASGITransport(app=api)
|
||||||
|
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
|
||||||
|
yield http
|
||||||
|
|
||||||
|
|
||||||
|
async def album_by_title(client: httpx2.AsyncClient, title: str) -> dict:
|
||||||
|
albums = (await client.get("/api/library")).json()["albums"]
|
||||||
|
return next(album for album in albums if album["title"] == title)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- library
|
||||||
|
|
||||||
|
|
||||||
|
async def test_library_lists_every_album_with_its_tracks(client: Client) -> None:
|
||||||
|
body = (await client.get("/api/library")).json()
|
||||||
|
titles = {album["title"] for album in body["albums"]}
|
||||||
|
assert titles == {
|
||||||
|
"Fuchs",
|
||||||
|
"Eule",
|
||||||
|
"Kinderparty Lieder",
|
||||||
|
"Conni in den Bergen",
|
||||||
|
"Wissen macht Ah",
|
||||||
|
}
|
||||||
|
|
||||||
|
album = await album_by_title(client, "Conni in den Bergen")
|
||||||
|
assert album["kind"] == "book"
|
||||||
|
assert album["category"] == "Conni"
|
||||||
|
assert len(album["colors"]) == 3
|
||||||
|
assert [track["title"] for track in album["tracks"]] == ["Teil 0", "Teil 1"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
|
||||||
|
album = await album_by_title(client, "Fuchs")
|
||||||
|
assert album["has_cover"] is False
|
||||||
|
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unanalyzed_tracks_report_no_analysis(client: Client) -> None:
|
||||||
|
album = await album_by_title(client, "Eule")
|
||||||
|
assert all(track["analysis"] is None for track in album["tracks"])
|
||||||
|
assert (await client.get(f"/api/tracks/{album['id']}/0/analysis")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
|
||||||
|
async def test_state_starts_idle(client: Client) -> None:
|
||||||
|
state = (await client.get("/api/state")).json()
|
||||||
|
assert state["playing"] is False
|
||||||
|
assert state["album_id"] is None
|
||||||
|
assert state["active_figure"] is None
|
||||||
|
assert state["connected"] == {"firmware": True, "mqtt": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None:
|
||||||
|
album = await album_by_title(client, "Kinderparty Lieder")
|
||||||
|
response = await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
|
assert response.status_code == 204
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert sim.player.is_playing
|
||||||
|
assert sim.player.playlist is not None
|
||||||
|
assert sim.player.playlist.album_id == album["id"]
|
||||||
|
assert (await client.get("/api/state")).json()["album_title"] == "Kinderparty Lieder"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_track_titles_come_from_the_tags_not_the_filename(
|
||||||
|
client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
"""``01 - So ein schoener Tag.mp3`` is a filename, not a title."""
|
||||||
|
album = await album_by_title(client, "Fuchs")
|
||||||
|
await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
state = (await client.get("/api/state")).json()
|
||||||
|
assert state["track_title"] == "Track 0"
|
||||||
|
assert state["duration"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_play_can_start_at_a_track(client: Client, sim: Simulation) -> None:
|
||||||
|
album = await album_by_title(client, "Fuchs")
|
||||||
|
await client.post("/api/play", json={"album_id": album["id"], "track_index": 2})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert sim.player.track_index == 2
|
||||||
|
assert (await client.get("/api/state")).json()["track_index"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_playing_a_figure_album_reuses_the_figure_playlist(
|
||||||
|
client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
"""Identity matters: ``play_figure`` resumes on an ``is`` check."""
|
||||||
|
album = await album_by_title(client, "Fuchs")
|
||||||
|
await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert sim.player.playlist is sim.app.playlists["fuchs"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
|
||||||
|
response = await client.post("/api/play", json={"album_id": "nope"})
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transport_commands_reach_the_player(client: Client, sim: Simulation) -> None:
|
||||||
|
album = await album_by_title(client, "Fuchs")
|
||||||
|
await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
await client.post("/api/next")
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert sim.player.track_index == 1
|
||||||
|
|
||||||
|
await client.post("/api/previous")
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert sim.player.track_index == 0
|
||||||
|
|
||||||
|
await client.post("/api/pause")
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert not sim.player.is_playing
|
||||||
|
|
||||||
|
await client.post("/api/resume")
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert sim.player.is_playing
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
|
||||||
|
album = await album_by_title(client, "Fuchs")
|
||||||
|
await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
response = await client.post("/api/seek", json={"position": 4.0})
|
||||||
|
assert response.status_code == 204
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert sim.player.position == pytest.approx(4.0, abs=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seeking_backwards_is_rejected(client: Client) -> None:
|
||||||
|
assert (await client.post("/api/seek", json={"position": -1})).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- volume
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_volume_means_the_configured_ceiling(
|
||||||
|
client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
|
||||||
|
response = await client.post("/api/volume", json={"percent": 100})
|
||||||
|
assert response.status_code == 204
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert sim.player.volume == 60
|
||||||
|
assert (await client.get("/api/state")).json()["volume"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_volume_scales_across_the_allowed_range(
|
||||||
|
client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await client.post("/api/volume", json={"percent": 50})
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert sim.player.volume == 30
|
||||||
|
assert (await client.get("/api/state")).json()["volume"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
async def test_volume_steps_are_relative_to_the_percentage(
|
||||||
|
client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await client.post("/api/volume", json={"percent": 50})
|
||||||
|
await sim.bus.drain()
|
||||||
|
await client.post("/api/volume", json={"delta_percent": 10})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert (await client.get("/api/state")).json()["volume"] == 60
|
||||||
|
assert sim.player.volume == 36
|
||||||
|
|
||||||
|
|
||||||
|
async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -> None:
|
||||||
|
await client.post("/api/volume", json={"percent": 95})
|
||||||
|
await sim.bus.drain()
|
||||||
|
await client.post("/api/volume", json={"delta_percent": 20})
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert (await client.get("/api/state")).json()["volume"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
|
||||||
|
state = (await client.get("/api/state")).json()
|
||||||
|
assert "volume_min" not in state
|
||||||
|
assert "volume_max" not in state
|
||||||
|
|
||||||
|
|
||||||
|
async def test_volume_needs_one_of_the_two_fields(client: Client) -> None:
|
||||||
|
assert (await client.post("/api/volume", json={})).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- websocket
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -> None:
|
||||||
|
"""State events only fire on change, so a tab that connects mid-track needs this."""
|
||||||
|
async with websocket_connect(api, "/api/ws") as socket:
|
||||||
|
message = await socket.next_json()
|
||||||
|
assert message["type"] == "state"
|
||||||
|
assert message["state"]["playing"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
|
||||||
|
async with websocket_connect(api, "/api/ws") as first, websocket_connect(
|
||||||
|
api, "/api/ws"
|
||||||
|
) as second:
|
||||||
|
await first.next_json()
|
||||||
|
await second.next_json()
|
||||||
|
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
for socket in (first, second):
|
||||||
|
message = await socket.next_json()
|
||||||
|
assert message["type"] == "state"
|
||||||
|
assert message["state"]["active_figure"] == "fuchs"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_refresh_tells_the_clients_to_reload_the_library(
|
||||||
|
api: FastAPI, client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
async with websocket_connect(api, "/api/ws") as socket:
|
||||||
|
await socket.next_json()
|
||||||
|
|
||||||
|
response = await client.post("/api/library/refresh")
|
||||||
|
assert response.status_code == 202
|
||||||
|
|
||||||
|
message = await socket.next_json(timeout=5.0)
|
||||||
|
assert message["type"] == "library"
|
||||||
|
assert set(sim.app.playlists) == {"fuchs", "eule"}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ figures
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_web_ui_follows_a_figure_placed_on_the_reader(
|
||||||
|
client: Client, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
state = (await client.get("/api/state")).json()
|
||||||
|
assert state["active_figure"] == "fuchs"
|
||||||
|
assert state["playing"] is True
|
||||||
|
assert state["album_title"] == "Fuchs"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- through a real uvicorn, not just ASGI
|
||||||
|
|
||||||
|
|
||||||
|
async def test_websockets_survive_a_real_server(sim: Simulation, config_dir: Path) -> None:
|
||||||
|
"""Boot the actual service and open an actual websocket.
|
||||||
|
|
||||||
|
Everything above drives the ASGI app directly, which cannot see whether uvicorn is
|
||||||
|
able to answer an upgrade at all - and it is not, unless a websocket implementation
|
||||||
|
is installed alongside it. That gap answered ``/api/ws`` with a 404 in production
|
||||||
|
while every other test passed.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from musicmouse.services.web.service import WebService
|
||||||
|
|
||||||
|
with socket.socket() as probe:
|
||||||
|
probe.bind(("127.0.0.1", 0))
|
||||||
|
port = int(probe.getsockname()[1])
|
||||||
|
|
||||||
|
service = WebService(
|
||||||
|
sim.app,
|
||||||
|
WebConfig(host="127.0.0.1", port=port),
|
||||||
|
config_dir / "config.yml",
|
||||||
|
)
|
||||||
|
server = asyncio.create_task(service.run(), name="web-service")
|
||||||
|
try:
|
||||||
|
await _wait_for_port(port)
|
||||||
|
async with websockets.connect(f"ws://127.0.0.1:{port}/api/ws") as client:
|
||||||
|
snapshot = json.loads(await asyncio.wait_for(client.recv(), 5.0))
|
||||||
|
assert snapshot["type"] == "state"
|
||||||
|
assert snapshot["state"]["playing"] is False
|
||||||
|
finally:
|
||||||
|
server.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await server
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_port(port: int, timeout: float = 5.0) -> None:
|
||||||
|
deadline = asyncio.get_running_loop().time() + timeout
|
||||||
|
while asyncio.get_running_loop().time() < deadline:
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||||||
|
except OSError:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
continue
|
||||||
|
del reader
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
return
|
||||||
|
raise AssertionError(f"nothing listening on {port} after {timeout}s")
|
||||||
68
python-backend/tests/websocket_harness.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Drive an ASGI websocket endpoint on the caller's own event loop.
|
||||||
|
|
||||||
|
Starlette's ``TestClient`` runs the app in a second thread with its own loop, which
|
||||||
|
would put the bus and the websockets on different loops - so ``bus.emit`` would take
|
||||||
|
its thread-safe path and a following ``drain()`` could return before the event was even
|
||||||
|
queued. In the real app there is only ever one loop, and this harness keeps the tests
|
||||||
|
that way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketSession:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.to_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||||
|
self.from_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||||
|
|
||||||
|
async def receive(self) -> dict[str, Any]:
|
||||||
|
return await self.to_app.get()
|
||||||
|
|
||||||
|
async def send(self, message: dict[str, Any]) -> None:
|
||||||
|
await self.from_app.put(message)
|
||||||
|
|
||||||
|
async def next_json(self, timeout: float = 2.0) -> dict[str, Any]:
|
||||||
|
"""The next ``websocket.send`` frame, decoded."""
|
||||||
|
while True:
|
||||||
|
message = await asyncio.wait_for(self.from_app.get(), timeout)
|
||||||
|
if message["type"] == "websocket.send":
|
||||||
|
text: str = message["text"]
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def websocket_connect(app: Any, path: str) -> AsyncIterator[WebSocketSession]:
|
||||||
|
session = WebSocketSession()
|
||||||
|
scope = {
|
||||||
|
"type": "websocket",
|
||||||
|
"asgi": {"version": "3.0", "spec_version": "2.3"},
|
||||||
|
"http_version": "1.1",
|
||||||
|
"scheme": "ws",
|
||||||
|
"path": path,
|
||||||
|
"raw_path": path.encode(),
|
||||||
|
"query_string": b"",
|
||||||
|
"root_path": "",
|
||||||
|
"headers": [(b"host", b"testserver")],
|
||||||
|
"client": ("testclient", 50000),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
"subprotocols": [],
|
||||||
|
"state": {},
|
||||||
|
}
|
||||||
|
await session.to_app.put({"type": "websocket.connect"})
|
||||||
|
task = asyncio.create_task(app(scope, session.receive, session.send))
|
||||||
|
|
||||||
|
accepted = await asyncio.wait_for(session.from_app.get(), 2.0)
|
||||||
|
assert accepted["type"] == "websocket.accept", accepted
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.to_app.put({"type": "websocket.disconnect", "code": 1000})
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
|
await task
|
||||||
3
web/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
120
web/README.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Musik Delphin — the web front-end
|
||||||
|
|
||||||
|
Browse the whole music collection and play any of it from a browser: the six figurines
|
||||||
|
are only five of the twenty-odd albums on the shelf, and everything else was previously
|
||||||
|
unreachable without swapping files around on disk.
|
||||||
|
|
||||||
|
Built from `../claude-design/Dolphin Beats.dc.html`, which is a finished interaction and
|
||||||
|
visual spec rather than a sketch. The keyboard model, the layout, the colours and the
|
||||||
|
German copy are ported from it; what changed is that a real player sits behind it.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
The backend serves the API. Either of these will do - `--simulate` needs no audio device
|
||||||
|
and no libVLC at all, which makes it the easier one to develop against:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ../python-backend
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware # real audio
|
||||||
|
python -m musicmouse --config ./config.yml --simulate # fake player
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173, /api and the websocket proxied to :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
For the device, `npm run build` writes `dist/`, and `general.web.static_dir` in
|
||||||
|
`config.yml` points at it so one process serves both.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test # vitest over src/lib
|
||||||
|
npx tsc --noEmit # strict, noUncheckedIndexedAccess
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the pure modules are tested - the search, the keyboard machine and the time
|
||||||
|
formatting. That is where the behaviour lives; the components are mostly layout.
|
||||||
|
|
||||||
|
## How it is put together
|
||||||
|
|
||||||
|
The split that matters: **what is playing** comes from the backend, **what you are
|
||||||
|
looking at** lives in the browser. The mockup kept both in one state object, but a real
|
||||||
|
player has other front-ends - the buttons on the mouse, a figurine on the reader, Home
|
||||||
|
Assistant - and this UI has to follow them rather than assume it is in charge. So
|
||||||
|
`isPlaying`, `volume`, the position and the current album all arrive over the websocket,
|
||||||
|
and only `search / mode / filter / category / selIndex / view / openAlbumId / showHelp`
|
||||||
|
are local.
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/keyboard.ts` | The key map, as a pure function from a keypress to a list of actions. Testable without a DOM. |
|
||||||
|
| `src/lib/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant. |
|
||||||
|
| `src/lib/covers.ts` | Generated cover art from the album's three colours, and the book-spine treatment. |
|
||||||
|
| `src/hooks/usePlayerState.ts` | The websocket, with reconnect-and-reseed. |
|
||||||
|
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
|
||||||
|
| `src/components/` | Layout only. |
|
||||||
|
|
||||||
|
`src/api/types.ts` mirrors `musicmouse/services/web/schemas.py`; the two are hand-kept in
|
||||||
|
step.
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
The device is meant to be driven from a keyboard, so every screen is reachable without a
|
||||||
|
pointer. `F1` shows the same list in-app.
|
||||||
|
|
||||||
|
| Key | |
|
||||||
|
|---|---|
|
||||||
|
| `SPACE` | play / pause |
|
||||||
|
| `CTRL+H` / `CTRL+L` | previous / next track |
|
||||||
|
| `CTRL+J` / `CTRL+K` | quieter / louder |
|
||||||
|
| `SHIFT+←` / `SHIFT+→` | seek 15 s |
|
||||||
|
| `A-Z`, `0-9` | search albums as you type |
|
||||||
|
| `?` | switch to searching individual tracks |
|
||||||
|
| `TAB` | cycle all / music / audiobooks |
|
||||||
|
| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing |
|
||||||
|
| `ENTER` | play the selection |
|
||||||
|
| `ESC` | back out one layer at a time |
|
||||||
|
| `/` | back to browsing |
|
||||||
|
| `F1` | this list |
|
||||||
|
|
||||||
|
`SHIFT+arrows` is the one addition to the mockup: plain arrows were already taken, and
|
||||||
|
seeking is the one thing a real player can do that the mockup could not.
|
||||||
|
|
||||||
|
## Parent mode
|
||||||
|
|
||||||
|
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
|
||||||
|
brightness, and a button to re-read the library. Saving writes the device's
|
||||||
|
`config.yml`.
|
||||||
|
|
||||||
|
It is hidden from the child, not protected from them: there is no authentication on the
|
||||||
|
endpoints behind it. See `../python-backend/README.md`.
|
||||||
|
|
||||||
|
## Installing it on a phone
|
||||||
|
|
||||||
|
It is a PWA: `public/manifest.webmanifest`, icons generated from the mascot, and a small
|
||||||
|
service worker in `public/sw.js`. On iOS, Share → *Zum Home-Bildschirm*; it then opens
|
||||||
|
full-screen with no Safari chrome, because iOS reads the `apple-mobile-web-app-*` meta
|
||||||
|
tags in `index.html` rather than the manifest.
|
||||||
|
|
||||||
|
Two things worth knowing:
|
||||||
|
|
||||||
|
- **The service worker only registers over HTTPS or on `localhost`.** At
|
||||||
|
`http://<mouse>:8080` on the LAN, iOS will still install the icon and run it
|
||||||
|
standalone - that part is the meta tags - but there is no offline caching. Put the
|
||||||
|
mouse behind a TLS-terminating proxy if you want that.
|
||||||
|
- **The worker deliberately caches very little**: the app shell, the font and cover art.
|
||||||
|
Not `/api/state`, not the library, not anything else live. A player that shows stale
|
||||||
|
state is worse than one that says it cannot reach the mouse.
|
||||||
|
|
||||||
|
Nunito is self-hosted in `public/fonts/` (one variable file per subset, ~74 kB) rather
|
||||||
|
than pulled from Google. The mouse may have no internet, and a cached shell rendering in
|
||||||
|
a fallback face looks broken.
|
||||||
|
|
||||||
|
## Not implemented
|
||||||
|
|
||||||
|
The mockup's second page, "Mein Zimmer" (Hue lights, a roller shutter and scenes), is
|
||||||
|
not built. Nothing here depends on it.
|
||||||
38
web/index.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<!-- viewport-fit=cover so the sea reaches under the iPhone's rounded corners;
|
||||||
|
user-scalable=no because a pinch-zoom on a big pink play button is a misfire. -->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no"
|
||||||
|
/>
|
||||||
|
<title>Musik Delphin</title>
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#0d2f3c" />
|
||||||
|
<meta name="description" content="Musik und Hörbücher für die MusicMouse" />
|
||||||
|
|
||||||
|
<!-- iOS does not read the manifest for home-screen installs; these are what it uses. -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Musik" />
|
||||||
|
<!-- black-translucent lets the page's own gradient run behind the status bar. -->
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" href="/icon-192.png" type="image/png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/nunito.css" />
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/nunito-latin.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2333
web/package-lock.json
generated
Normal file
28
web/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "musicmouse-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "^7.1.0",
|
||||||
|
"vitest": "^3.2.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.28.2": true
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
web/public/dolphin-mascot.png
Normal file
|
After Width: | Height: | Size: 697 KiB |
BIN
web/public/fonts/nunito-latin-ext.woff2
Normal file
BIN
web/public/fonts/nunito-latin.woff2
Normal file
22
web/public/fonts/nunito.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/* Nunito, self-hosted: one variable file per subset covers 500-900.
|
||||||
|
|
||||||
|
Google Fonts would be a network round trip the mouse may not have, and a
|
||||||
|
cached app shell rendered in the fallback face looks broken. latin-ext is
|
||||||
|
what carries "Hörbücher" and "Käpt'n"; the Cyrillic and Vietnamese subsets
|
||||||
|
Google also offers are dropped, since nothing in this UI can produce them. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Nunito";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/nunito-latin-ext.woff2") format("woff2");
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Nunito";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/nunito-latin.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
BIN
web/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
web/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
web/public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
22
web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Musik Delphin",
|
||||||
|
"short_name": "Musik",
|
||||||
|
"description": "Musik und Hörbücher für die MusicMouse",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0d2f3c",
|
||||||
|
"theme_color": "#0d2f3c",
|
||||||
|
"lang": "de",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
79
web/public/sw.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// A deliberately small service worker: it exists so iOS treats this as an installable
|
||||||
|
// app and so the shell survives a flaky wifi moment. It does NOT try to make the player
|
||||||
|
// work offline - the audio, the library and the state all live on the mouse, and a
|
||||||
|
// cached answer for any of those would be a lie.
|
||||||
|
|
||||||
|
const SHELL = "musikdelphin-shell-v1";
|
||||||
|
const COVERS = "musikdelphin-covers-v1";
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL)
|
||||||
|
.then((cache) =>
|
||||||
|
cache.addAll([
|
||||||
|
"/",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/dolphin-mascot.png",
|
||||||
|
"/fonts/nunito.css",
|
||||||
|
"/fonts/nunito-latin.woff2",
|
||||||
|
"/fonts/nunito-latin-ext.woff2",
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((names) =>
|
||||||
|
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Cover art is immutable per album id and is the only heavy thing here.
|
||||||
|
if (url.pathname.startsWith("/api/albums/")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(COVERS).then(async (cache) => {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else under /api is live state. Never cache it, never serve it stale.
|
||||||
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
|
||||||
|
// The shell: network first so a rebuilt frontend is picked up on the next load,
|
||||||
|
// falling back to the cache when the mouse is off or out of range.
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(SHELL).then((cache) => cache.put(request, copy));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const hit = await caches.match(request);
|
||||||
|
// A client-side app: any in-scope navigation resolves to the one document.
|
||||||
|
return hit ?? (await caches.match("/")) ?? Response.error();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
322
web/src/App.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
/** Owns the browse state, mounts the keyboard handler, and switches between views.
|
||||||
|
*
|
||||||
|
* The split that matters: everything about *what is playing* comes from the backend
|
||||||
|
* over the websocket, and everything about *what you are looking at* lives here. The
|
||||||
|
* mockup kept both in one object; a real player has other front-ends - the buttons on
|
||||||
|
* the mouse, a figure on the reader, Home Assistant - and this UI has to follow them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "./api/client";
|
||||||
|
import type { Album } from "./api/types";
|
||||||
|
import { AlbumModal } from "./components/AlbumModal";
|
||||||
|
import { AppHeader } from "./components/AppHeader";
|
||||||
|
import { BrowseView } from "./components/BrowseView";
|
||||||
|
import { Bubbles } from "./components/Bubbles";
|
||||||
|
import { HelpOverlay } from "./components/HelpOverlay";
|
||||||
|
import { ParentPanel } from "./components/ParentPanel";
|
||||||
|
import { PlayerBar } from "./components/PlayerBar";
|
||||||
|
import { PlayView } from "./components/PlayView";
|
||||||
|
import { useGridColumns } from "./hooks/useGridColumns";
|
||||||
|
import { useLibrary } from "./hooks/useLibrary";
|
||||||
|
import { usePlaybackClock } from "./hooks/usePlaybackClock";
|
||||||
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
|
import type { Action, UiState } from "./lib/keyboard";
|
||||||
|
import { handleKey, initialUiState } from "./lib/keyboard";
|
||||||
|
import { playPop } from "./lib/pop";
|
||||||
|
import type { Filter, Results, SongHit } from "./lib/search";
|
||||||
|
import { results as computeResults } from "./lib/search";
|
||||||
|
|
||||||
|
/** Volume when un-muting, matching the mockup. */
|
||||||
|
const UNMUTE_PERCENT = 60;
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [ui, setUi] = useState<UiState>(initialUiState);
|
||||||
|
const library = useLibrary();
|
||||||
|
const connection = usePlayerState(library.reload);
|
||||||
|
const [gridRef, columns] = useGridColumns();
|
||||||
|
const [parentMode, setParentMode] = useState(
|
||||||
|
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = connection.state;
|
||||||
|
const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
const results: Results = useMemo(
|
||||||
|
() =>
|
||||||
|
computeResults({
|
||||||
|
albums: library.albums,
|
||||||
|
search: ui.search,
|
||||||
|
mode: ui.mode,
|
||||||
|
filter: ui.filter,
|
||||||
|
category: ui.category,
|
||||||
|
}),
|
||||||
|
[library.albums, ui.search, ui.mode, ui.filter, ui.category],
|
||||||
|
);
|
||||||
|
|
||||||
|
const byId = useMemo(
|
||||||
|
() => new Map(library.albums.map((album) => [album.id, album])),
|
||||||
|
[library.albums],
|
||||||
|
);
|
||||||
|
const currentAlbum: Album | null = state?.album_id ? (byId.get(state.album_id) ?? null) : null;
|
||||||
|
const openAlbum: Album | null = ui.openAlbumId ? (byId.get(ui.openAlbumId) ?? null) : null;
|
||||||
|
|
||||||
|
const play = useCallback((albumId: string, trackIndex: number) => {
|
||||||
|
playPop(300);
|
||||||
|
setUi((previous) => ({ ...previous, view: "play", openAlbumId: null }));
|
||||||
|
void api.play(albumId, trackIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVolume = useCallback(
|
||||||
|
(percent: number) => {
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
// Optimistic, so the bars move on the keypress rather than a frame later.
|
||||||
|
connection.optimistic({ volume: clamped });
|
||||||
|
void api.setVolume(clamped);
|
||||||
|
},
|
||||||
|
[connection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (!state) return;
|
||||||
|
if (state.album_id === null) {
|
||||||
|
// Nothing loaded yet: space starts whatever is on screen, as in the mockup.
|
||||||
|
const first = results.albums[0] ?? library.albums[0];
|
||||||
|
if (first) play(first.id, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection.optimistic({ playing: !state.playing });
|
||||||
|
void (state.playing ? api.pause() : api.resume());
|
||||||
|
}, [connection, library.albums, play, results.albums, state]);
|
||||||
|
|
||||||
|
/** The one place a keyboard action, a click or a tap all end up. */
|
||||||
|
const run = useCallback(
|
||||||
|
(actions: Action[]) => {
|
||||||
|
for (const action of actions) {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ui":
|
||||||
|
setUi((previous) => ({ ...previous, ...action.patch }));
|
||||||
|
break;
|
||||||
|
case "play":
|
||||||
|
play(action.albumId, action.trackIndex);
|
||||||
|
break;
|
||||||
|
case "toggle":
|
||||||
|
toggle();
|
||||||
|
break;
|
||||||
|
case "next":
|
||||||
|
playPop(260);
|
||||||
|
void api.next();
|
||||||
|
break;
|
||||||
|
case "previous":
|
||||||
|
playPop(260);
|
||||||
|
void api.previous();
|
||||||
|
break;
|
||||||
|
case "volume":
|
||||||
|
setVolume((state?.volume ?? 0) + action.delta);
|
||||||
|
break;
|
||||||
|
case "seek":
|
||||||
|
if (state?.duration) {
|
||||||
|
void api.seek(Math.max(0, Math.min(state.duration, position + action.delta)));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "pop":
|
||||||
|
playPop(action.freq);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[play, position, setVolume, state, toggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Held in a ref so the listener is installed once rather than on every state change.
|
||||||
|
const latest = useRef({ ui, results, run });
|
||||||
|
latest.current = { ui, results, run };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
// Parent mode has real text fields; typing a title into one must not start a song.
|
||||||
|
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
||||||
|
|
||||||
|
const current = latest.current;
|
||||||
|
const actions = handleKey(event, current.ui, current.results);
|
||||||
|
if (!actions.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
current.run(actions);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onFilter = (filter: Filter) => {
|
||||||
|
playPop(380);
|
||||||
|
setUi((previous) => ({
|
||||||
|
...previous,
|
||||||
|
filter,
|
||||||
|
selIndex: 0,
|
||||||
|
view: "browse",
|
||||||
|
category: null,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCategory = (key: string | null) => {
|
||||||
|
if (key) playPop(440);
|
||||||
|
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenAlbum = (album: Album, navIndex: number) => {
|
||||||
|
playPop(420);
|
||||||
|
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
|
||||||
|
const onSeek = (target: number) => void api.seek(target);
|
||||||
|
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stage">
|
||||||
|
<Bubbles />
|
||||||
|
|
||||||
|
{ui.view === "browse" && state && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppHeader status={<ConnectionDot online={connection.online} state={state} />} />
|
||||||
|
<BrowseView
|
||||||
|
results={results}
|
||||||
|
filter={ui.filter}
|
||||||
|
mode={ui.mode}
|
||||||
|
search={ui.search}
|
||||||
|
category={ui.category}
|
||||||
|
selIndex={ui.selIndex}
|
||||||
|
currentAlbumId={state.album_id}
|
||||||
|
gridRef={gridRef}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onCategory={onCategory}
|
||||||
|
onOpenAlbum={onOpenAlbum}
|
||||||
|
onPlaySong={onPlaySong}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ui.view === "play" && state && (
|
||||||
|
<PlayView
|
||||||
|
state={state}
|
||||||
|
album={currentAlbum}
|
||||||
|
position={position}
|
||||||
|
onToggle={toggle}
|
||||||
|
onNext={() => run([{ type: "next" }])}
|
||||||
|
onPrevious={() => run([{ type: "previous" }])}
|
||||||
|
onSeek={onSeek}
|
||||||
|
onVolume={setVolume}
|
||||||
|
onMute={onMute}
|
||||||
|
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openAlbum && state && (
|
||||||
|
<AlbumModal
|
||||||
|
album={openAlbum}
|
||||||
|
currentAlbumId={state.album_id}
|
||||||
|
currentTrackIndex={state.track_index}
|
||||||
|
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
|
||||||
|
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }))}
|
||||||
|
title="Zaubertasten (F1)"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 18,
|
||||||
|
right: 24,
|
||||||
|
zIndex: 3,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
background: "oklch(97% 0.01 210 / .95)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--ink)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{ui.showHelp && (
|
||||||
|
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ui.view === "browse" && state && (
|
||||||
|
<PlayerBar
|
||||||
|
state={state}
|
||||||
|
album={currentAlbum}
|
||||||
|
position={position}
|
||||||
|
onToggle={toggle}
|
||||||
|
onNext={() => run([{ type: "next" }])}
|
||||||
|
onPrevious={() => run([{ type: "previous" }])}
|
||||||
|
onSeek={onSeek}
|
||||||
|
onVolume={setVolume}
|
||||||
|
onMute={onMute}
|
||||||
|
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
|
||||||
|
|
||||||
|
{(!state || library.loading) && <Splash error={library.error} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) {
|
||||||
|
const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
|
||||||
|
if (!label) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: online ? "var(--paper)" : "oklch(75% 0.16 30)",
|
||||||
|
background: "oklch(97% 0.01 210 / .16)",
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Splash({ error }: { error: string | null }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overlay"
|
||||||
|
style={{ zIndex: 10, background: "oklch(20% 0.045 210 / .92)", flexDirection: "column", gap: 18 }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/dolphin-mascot.png"
|
||||||
|
alt=""
|
||||||
|
style={{ width: 140, height: 140, objectFit: "contain", animation: "dolphinBob 1.6s ease-in-out infinite" }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, color: "var(--paper)" }}>
|
||||||
|
{error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/src/api/client.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
|
||||||
|
|
||||||
|
import type { Album, PlayerState, Settings } from "./types";
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.status === 204 ? (undefined as T) : ((await response.json()) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(path: string, body?: unknown): Promise<void> {
|
||||||
|
return request<void>(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
|
||||||
|
state: () => request<PlayerState>("/state"),
|
||||||
|
refreshLibrary: () => post("/library/refresh"),
|
||||||
|
|
||||||
|
play: (albumId: string, trackIndex = 0) =>
|
||||||
|
post("/play", { album_id: albumId, track_index: trackIndex }),
|
||||||
|
resume: () => post("/resume"),
|
||||||
|
pause: () => post("/pause"),
|
||||||
|
next: () => post("/next"),
|
||||||
|
previous: () => post("/previous"),
|
||||||
|
seek: (position: number) => post("/seek", { position }),
|
||||||
|
setVolume: (percent: number) => post("/volume", { percent }),
|
||||||
|
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
|
||||||
|
|
||||||
|
settings: () => request<Settings>("/settings"),
|
||||||
|
saveSettings: (settings: Settings) =>
|
||||||
|
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
||||||
65
web/src/api/types.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/** The shapes the backend serves. Mirrors `musicmouse/services/web/schemas.py`. */
|
||||||
|
|
||||||
|
export type AlbumKind = "music" | "book";
|
||||||
|
|
||||||
|
export interface TrackAnalysis {
|
||||||
|
tempo: number | null;
|
||||||
|
energy: number | null;
|
||||||
|
valence: number | null;
|
||||||
|
brightness: number | null;
|
||||||
|
beats: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
title: string;
|
||||||
|
/** Seconds, read from the file's tags at scan time. */
|
||||||
|
duration: number;
|
||||||
|
analysis: TrackAnalysis | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
id: string;
|
||||||
|
section: string;
|
||||||
|
kind: AlbumKind;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
series: string | null;
|
||||||
|
figure: string | null;
|
||||||
|
/** Series for audiobooks, artist for music. What the browse view groups by. */
|
||||||
|
category: string;
|
||||||
|
/** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */
|
||||||
|
colors: string[];
|
||||||
|
has_cover: boolean;
|
||||||
|
duration: number;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
playing: boolean;
|
||||||
|
album_id: string | null;
|
||||||
|
album_title: string | null;
|
||||||
|
artist: string | null;
|
||||||
|
kind: AlbumKind | null;
|
||||||
|
track_index: number;
|
||||||
|
track_title: string | null;
|
||||||
|
track_count: number;
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
/** Percent, 0..100. The device's configured range never leaves the backend. */
|
||||||
|
volume: number;
|
||||||
|
active_figure: string | null;
|
||||||
|
connected: { firmware: boolean; mqtt: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
min_volume: number;
|
||||||
|
max_volume: number;
|
||||||
|
initial_volume: number;
|
||||||
|
volume_increment: number;
|
||||||
|
button_leds_brightness: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| { type: "state"; state: PlayerState }
|
||||||
|
| { type: "position"; position: number; duration: number }
|
||||||
|
| { type: "library" };
|
||||||
189
web/src/components/AlbumModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/** The album detail sheet: cover, metadata, and a numbered track list. */
|
||||||
|
|
||||||
|
import type { Album } from "../api/types";
|
||||||
|
import { isBook, unitLabel } from "../lib/covers";
|
||||||
|
import { clock } from "../lib/format";
|
||||||
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
album: Album;
|
||||||
|
currentAlbumId: string | null;
|
||||||
|
currentTrackIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onPlay: (trackIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumModal({
|
||||||
|
album,
|
||||||
|
currentAlbumId,
|
||||||
|
currentTrackIndex,
|
||||||
|
onClose,
|
||||||
|
onPlay,
|
||||||
|
}: Props) {
|
||||||
|
const book = isBook(album);
|
||||||
|
return (
|
||||||
|
<div className="overlay" style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: "oklch(96% 0.012 210)",
|
||||||
|
borderRadius: 26,
|
||||||
|
padding: 26,
|
||||||
|
width: 640,
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
overflow: "auto",
|
||||||
|
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||||||
|
<div style={{ width: 150, flex: "none" }}>
|
||||||
|
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "oklch(22% 0.03 210)",
|
||||||
|
lineHeight: 1.15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "oklch(45% 0.03 210 / .85)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.artist}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "oklch(50% 0.03 210 / .7)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{book ? "📖 Hörbuch" : "🎵 Musik"} · {unitLabel(album, album.tracks.length)} ·{" "}
|
||||||
|
{clock(album.duration)}
|
||||||
|
{album.figure && ` · 🧸 ${album.figure}`}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onPlay(0)}
|
||||||
|
style={{
|
||||||
|
marginTop: 14,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: "12px 22px",
|
||||||
|
borderRadius: 999,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 16,
|
||||||
|
background: "#fff",
|
||||||
|
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "oklch(88% 0.02 210)",
|
||||||
|
color: "var(--ink)",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 900,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 999,
|
||||||
|
flex: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 22 }}>
|
||||||
|
{album.tracks.map((track, index) => {
|
||||||
|
const current = currentAlbumId === album.id && currentTrackIndex === index;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => onPlay(index)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
padding: "10px 14px",
|
||||||
|
borderRadius: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "none",
|
||||||
|
font: "inherit",
|
||||||
|
textAlign: "left",
|
||||||
|
background: current
|
||||||
|
? "oklch(70% 0.16 340 / .16)"
|
||||||
|
: "oklch(30% 0.03 210 / .05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 999,
|
||||||
|
flex: "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
font: "800 13px ui-monospace, Menlo, monospace",
|
||||||
|
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
|
||||||
|
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
font: "700 13px ui-monospace, Menlo, monospace",
|
||||||
|
color: "oklch(45% 0.03 210 / .7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clock(track.duration)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
web/src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
||||||
|
status?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppHeader({ status }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
// The status bar is translucent in standalone mode, so make room for it.
|
||||||
|
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
||||||
|
flex: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/dolphin-mascot.png"
|
||||||
|
alt=""
|
||||||
|
style={{ width: 64, height: 64, objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
|
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Musik Delphin
|
||||||
|
</div>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
475
web/src/components/BrowseView.tsx
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
/** The browse screen: filter pills, the search pill, and whichever of the three result
|
||||||
|
* lists applies. Selection indices run flat across songs, then categories, then albums,
|
||||||
|
* which is what lets one pair of arrow keys walk the whole page. */
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import type { Album } from "../api/types";
|
||||||
|
import {
|
||||||
|
albumLine,
|
||||||
|
aspectOfKind,
|
||||||
|
cardBackground,
|
||||||
|
cardShadow,
|
||||||
|
isBook,
|
||||||
|
unitLabel,
|
||||||
|
} from "../lib/covers";
|
||||||
|
import { clock } from "../lib/format";
|
||||||
|
import type { Filter, Results, SongHit } from "../lib/search";
|
||||||
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
results: Results;
|
||||||
|
filter: Filter;
|
||||||
|
mode: "albums" | "tracks";
|
||||||
|
search: string;
|
||||||
|
category: string | null;
|
||||||
|
selIndex: number;
|
||||||
|
currentAlbumId: string | null;
|
||||||
|
gridRef: (element: HTMLElement | null) => void;
|
||||||
|
onFilter: (filter: Filter) => void;
|
||||||
|
onCategory: (key: string | null) => void;
|
||||||
|
onOpenAlbum: (album: Album, navIndex: number) => void;
|
||||||
|
onPlaySong: (hit: SongHit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: Array<[Filter, string]> = [
|
||||||
|
["all", "Alles"],
|
||||||
|
["music", "🎵 Musik"],
|
||||||
|
["book", "📖 Hörbücher"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BrowseView({
|
||||||
|
results,
|
||||||
|
filter,
|
||||||
|
mode,
|
||||||
|
search,
|
||||||
|
category,
|
||||||
|
selIndex,
|
||||||
|
currentAlbumId,
|
||||||
|
gridRef,
|
||||||
|
onFilter,
|
||||||
|
onCategory,
|
||||||
|
onOpenAlbum,
|
||||||
|
onPlaySong,
|
||||||
|
}: Props) {
|
||||||
|
const scroller = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { songs, categories, albums, total } = results;
|
||||||
|
const selected = total ? Math.min(selIndex, total - 1) : -1;
|
||||||
|
|
||||||
|
// Keep the selection on screen as the arrow keys walk past the fold.
|
||||||
|
useEffect(() => {
|
||||||
|
const box = scroller.current;
|
||||||
|
const element = box?.querySelector<HTMLElement>(`[data-nav-index="${selected}"]`);
|
||||||
|
if (!box || !element) return;
|
||||||
|
const top = element.offsetTop - box.offsetTop;
|
||||||
|
const bottom = top + element.offsetHeight;
|
||||||
|
const pad = 24;
|
||||||
|
if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad);
|
||||||
|
else if (bottom + pad > box.scrollTop + box.clientHeight) {
|
||||||
|
box.scrollTop = bottom + pad - box.clientHeight;
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const showSearchBar = search.length > 0 || mode === "tracks";
|
||||||
|
const countLabel =
|
||||||
|
mode === "tracks"
|
||||||
|
? `${songs.length} Titel gefunden`
|
||||||
|
: albums.length
|
||||||
|
? `${albums.length} Album${albums.length === 1 ? "" : "en"} gefunden`
|
||||||
|
: "nichts gefunden";
|
||||||
|
|
||||||
|
const sectionLabel =
|
||||||
|
filter === "book" ? "Hörbücher" : filter === "music" ? "Alben" : "Alben & Hörbücher";
|
||||||
|
const categoryLabel =
|
||||||
|
filter === "book" ? "Figuren" : filter === "music" ? "Künstler" : "Figuren & Künstler";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "4px 32px 6px",
|
||||||
|
flex: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FILTERS.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
className="pill"
|
||||||
|
data-active={filter === value}
|
||||||
|
onClick={() => onFilter(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearchBar && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "2px 32px 6px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
flex: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--paper)",
|
||||||
|
color: "var(--ink)",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 20,
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: 999,
|
||||||
|
boxShadow: "0 4px 14px var(--shadow)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 800,
|
||||||
|
background: "var(--ink)",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"}
|
||||||
|
</span>
|
||||||
|
<span>{search ? `"${search}"` : "tippe …"}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "oklch(90% 0.02 210 / .85)", fontWeight: 700, fontSize: 14 }}>
|
||||||
|
{countLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scroller}
|
||||||
|
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}
|
||||||
|
>
|
||||||
|
{songs.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 30 }}>
|
||||||
|
<SectionTitle>
|
||||||
|
Titel <Muted>{songs.length} Treffer</Muted>
|
||||||
|
</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
maxWidth: 760,
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{songs.map((hit, index) => (
|
||||||
|
<button
|
||||||
|
key={`${hit.album.id}-${hit.index}`}
|
||||||
|
data-nav-index={index}
|
||||||
|
onClick={() => onPlaySong(hit)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderRadius: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "none",
|
||||||
|
textAlign: "left",
|
||||||
|
font: "inherit",
|
||||||
|
background: `oklch(97% 0.01 210 / ${
|
||||||
|
currentAlbumId === hit.album.id ? ".22" : ".10"
|
||||||
|
})`,
|
||||||
|
outline:
|
||||||
|
selected === index ? "4px solid var(--paper)" : undefined,
|
||||||
|
outlineOffset: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ height: 40, flex: "none", display: "flex" }}>
|
||||||
|
<Cover
|
||||||
|
album={hit.album}
|
||||||
|
size={40}
|
||||||
|
fit="height"
|
||||||
|
radius={isBook(hit.album) ? "5px 10px 10px 5px" : "10px"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: "var(--paper)" }}>
|
||||||
|
{hit.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "oklch(88% 0.02 210 / .65)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{albumLine(hit.album)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
font: "700 13px ui-monospace, Menlo, monospace",
|
||||||
|
color: "oklch(88% 0.02 210 / .6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clock(hit.duration)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle centered>{categoryLabel}</SectionTitle>
|
||||||
|
<div className="grid" ref={gridRef}>
|
||||||
|
{categories.map((entry, index) => {
|
||||||
|
const navIndex = songs.length + index;
|
||||||
|
const books = entry.albums.filter(isBook).length;
|
||||||
|
const allBooks = books === entry.albums.length;
|
||||||
|
const mostlyBooks = books * 2 > entry.albums.length;
|
||||||
|
const shown = entry.albums.slice(0, 4);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.key}
|
||||||
|
className="card"
|
||||||
|
data-nav-index={navIndex}
|
||||||
|
data-selected={selected === navIndex}
|
||||||
|
onClick={() => onCategory(entry.key)}
|
||||||
|
style={{
|
||||||
|
background: allBooks
|
||||||
|
? "oklch(93% 0.055 88 / .7)"
|
||||||
|
: "oklch(95% 0.015 210 / .66)",
|
||||||
|
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
|
||||||
|
boxShadow: "0 6px 18px var(--shadow)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
// A category of one gets one full-bleed cover rather than a
|
||||||
|
// 2x2 grid with three empty holes in it.
|
||||||
|
// Always a full 2x2 for more than one, so every cell has the
|
||||||
|
// same shape as the tile and the covers fill it exactly. Two
|
||||||
|
// albums in a single row would each be twice as wide as their
|
||||||
|
// cell and spill out of it.
|
||||||
|
gridTemplateColumns: shown.length === 1 ? "1fr" : "1fr 1fr",
|
||||||
|
gridTemplateRows: shown.length === 1 ? "1fr" : "1fr 1fr",
|
||||||
|
gap: 4,
|
||||||
|
padding: 8,
|
||||||
|
// The tile takes the shape of what it holds, so a shelf of
|
||||||
|
// audiobooks is visibly taller than a shelf of albums.
|
||||||
|
aspectRatio: aspectOfKind(mostlyBooks ? "book" : "music"),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shown.map((album) => (
|
||||||
|
<div
|
||||||
|
key={album.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Cover
|
||||||
|
album={album}
|
||||||
|
size={shown.length === 1 ? 160 : 70}
|
||||||
|
fit="height"
|
||||||
|
radius="4px"
|
||||||
|
label={shown.length === 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "4px 12px 14px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(22% 0.03 210)",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.key}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(40% 0.17 340)",
|
||||||
|
marginTop: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.albums.length}{" "}
|
||||||
|
{allBooks
|
||||||
|
? entry.albums.length === 1
|
||||||
|
? "Hörbuch"
|
||||||
|
: "Hörbücher"
|
||||||
|
: entry.albums.length === 1
|
||||||
|
? "Album"
|
||||||
|
: "Alben"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{category !== null && !search && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="pill"
|
||||||
|
data-active="true"
|
||||||
|
onClick={() => onCategory(null)}
|
||||||
|
>
|
||||||
|
← Alle
|
||||||
|
</button>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 900, color: "var(--paper)" }}>
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{search.length > 0 && <SectionTitle centered>{sectionLabel}</SectionTitle>}
|
||||||
|
<div className="grid" ref={gridRef}>
|
||||||
|
{albums.map((album, index) => {
|
||||||
|
const navIndex = songs.length + categories.length + index;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={album.id}
|
||||||
|
className="card"
|
||||||
|
data-nav-index={navIndex}
|
||||||
|
data-selected={selected === navIndex}
|
||||||
|
data-current={album.id === currentAlbumId}
|
||||||
|
onClick={() => onOpenAlbum(album, navIndex)}
|
||||||
|
style={{
|
||||||
|
background: cardBackground(album),
|
||||||
|
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
|
||||||
|
boxShadow: cardShadow(album),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Cover album={album} size={180} radius="0" label />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px 14px",
|
||||||
|
paddingRight: isBook(album) ? 22 : 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(22% 0.03 210)",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "oklch(30% 0.03 210)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.artist}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(40% 0.17 340)",
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{unitLabel(album, album.tracks.length)}
|
||||||
|
{album.figure && " · 🧸"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total === 0 && (
|
||||||
|
<div
|
||||||
|
style={{ textAlign: "center", marginTop: 60, color: "oklch(92% 0.02 210 / .8)" }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/dolphin-mascot.png"
|
||||||
|
alt=""
|
||||||
|
style={{ width: 120, height: 120, objectFit: "contain", opacity: 0.9 }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
|
||||||
|
Nichts gefunden — probier andere Buchstaben!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({
|
||||||
|
children,
|
||||||
|
centered,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
centered?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: centered ? "center" : "left",
|
||||||
|
display: centered ? "block" : "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Muted({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
web/src/components/Bubbles.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/** The ambient bubble field. Positions are fixed so they do not reshuffle on render. */
|
||||||
|
|
||||||
|
const BUBBLES: Array<[left: number, size: number, alpha: number, seconds: number, delay: number]> =
|
||||||
|
[
|
||||||
|
[5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4],
|
||||||
|
[58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5],
|
||||||
|
[2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2],
|
||||||
|
[38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2],
|
||||||
|
[66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2],
|
||||||
|
[96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Bubbles() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{BUBBLES.map(([left, size, alpha, seconds, delay], index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bubble"
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
background: `oklch(90% 0.02 210 / ${alpha})`,
|
||||||
|
animationDuration: `${seconds}s`,
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
web/src/components/Cover.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/** Album art, or a generated stand-in built from the album's own three colours.
|
||||||
|
*
|
||||||
|
* The gradient sits underneath the image rather than instead of it, so a cover that is
|
||||||
|
* still loading - or an album that has none - never shows a hole.
|
||||||
|
*
|
||||||
|
* The shape comes from the media type and from nowhere else: square for an album,
|
||||||
|
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { coverUrl } from "../api/client";
|
||||||
|
import type { Album } from "../api/types";
|
||||||
|
import { aspectOf, coverBackground, isBook } from "../lib/covers";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
album: Album;
|
||||||
|
/** Rendered size in px along the fixed axis; drives the stripe width so the generated
|
||||||
|
* pattern scales with the art. */
|
||||||
|
size: number;
|
||||||
|
/** Which axis the parent constrains. `height` keeps rows and grids even when a book
|
||||||
|
* and an album sit side by side. */
|
||||||
|
fit?: "width" | "height";
|
||||||
|
radius?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||||
|
label?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
|
||||||
|
const book = isBook(album);
|
||||||
|
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
|
||||||
|
const box: React.CSSProperties =
|
||||||
|
fit === "height"
|
||||||
|
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
|
||||||
|
: { width: "100%", aspectRatio: aspectOf(album) };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
flex: "none",
|
||||||
|
borderRadius: radius ?? defaultRadius,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: coverBackground(album, Math.max(6, Math.round(size / 13))),
|
||||||
|
...box,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!album.has_cover && label && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: book ? "10% 14% 10% 20%" : "10%",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: Math.max(11, Math.round(size / 11)),
|
||||||
|
fontWeight: 900,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
color: "oklch(99% 0 0 / .92)",
|
||||||
|
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{album.has_cover && (
|
||||||
|
<img
|
||||||
|
src={coverUrl(album.id)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
// Books keep a sliver of spine showing on the right, so the shelf metaphor
|
||||||
|
// survives contact with real square artwork.
|
||||||
|
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
web/src/components/HelpOverlay.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
|
||||||
|
* it changes here. */
|
||||||
|
|
||||||
|
const KEYS: Array<[caps: string[], label: string]> = [
|
||||||
|
[["LEER"], "Play / Pause"],
|
||||||
|
[["CTRL+H", "CTRL+L"], "Song zurück / vor"],
|
||||||
|
[["CTRL+J", "CTRL+K"], "Leiser / lauter"],
|
||||||
|
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
||||||
|
[["A-Z"], "Album oder Hörbuch suchen"],
|
||||||
|
[["?"], "Einzelne Titel suchen"],
|
||||||
|
[["F1"], "Diese Hilfe"],
|
||||||
|
[["TAB"], "Musik / Hörbücher / alles"],
|
||||||
|
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||||
|
[["ENTER"], "Auswahl abspielen"],
|
||||||
|
[["ESC"], "Schließen / Suche löschen"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="overlay" style={{ zIndex: 5 }} onClick={onClose}>
|
||||||
|
<div className="sheet" style={{ padding: "26px 30px", width: 330 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--ink)",
|
||||||
|
marginBottom: 14,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⌨️ Zaubertasten
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{KEYS.map(([caps, label]) => (
|
||||||
|
<div key={label} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
{caps.map((cap) => (
|
||||||
|
<div key={cap} className="key-cap" style={{ minWidth: caps.length > 1 ? 0 : 56 }}>
|
||||||
|
{cap}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: "oklch(35% 0.03 210)" }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "oklch(50% 0.03 210 / .8)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tippe irgendwohin, um zu schließen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
web/src/components/ParentPanel.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI.
|
||||||
|
*
|
||||||
|
* This hides the settings, it does not protect them - there is no login, and the
|
||||||
|
* endpoints behind it are as open as the rest of the API. That matches the device's
|
||||||
|
* threat model (a box on a home network) and is stated in the README rather than
|
||||||
|
* implied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Settings } from "../api/types";
|
||||||
|
|
||||||
|
const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [
|
||||||
|
["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1],
|
||||||
|
["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1],
|
||||||
|
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
|
||||||
|
["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1],
|
||||||
|
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.settings()
|
||||||
|
.then(setSettings)
|
||||||
|
.catch((cause: unknown) => setStatus(String(cause)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
setBusy(true);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
setSettings(await api.saveSettings(settings));
|
||||||
|
setStatus("Gespeichert.");
|
||||||
|
} catch {
|
||||||
|
// The backend rejects an inverted range or an out-of-band start volume.
|
||||||
|
setStatus("Nicht gespeichert — bitte die Werte prüfen (min ≤ Start ≤ max).");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rescan = async () => {
|
||||||
|
setStatus("Bibliothek wird eingelesen …");
|
||||||
|
await api.refreshLibrary();
|
||||||
|
setStatus("Einlesen gestartet. Neue Titel erscheinen gleich von selbst.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 210 / .75)" }}>
|
||||||
|
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||||
|
🔧 Eltern-Einstellungen
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "oklch(88% 0.02 210)",
|
||||||
|
color: "var(--ink)",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 900,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings === null ? (
|
||||||
|
<div style={{ marginTop: 18, color: "oklch(45% 0.03 210)" }}>Wird geladen …</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
||||||
|
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||||
|
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={settings[key]}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({ ...settings, [key]: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "2px solid oklch(85% 0.02 210)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: "11px 20px",
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void rescan()}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "oklch(88% 0.02 210)",
|
||||||
|
color: "var(--ink)",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: "11px 20px",
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bibliothek neu einlesen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "oklch(50% 0.03 210)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
|
||||||
|
Obergrenze gilt sofort, ohne Neustart.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
web/src/components/PlayView.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/** The full-screen now-playing view. */
|
||||||
|
|
||||||
|
import type { Album, PlayerState } from "../api/types";
|
||||||
|
import { albumLine, isBook } from "../lib/covers";
|
||||||
|
import { clock, remainingInAlbum } from "../lib/format";
|
||||||
|
import { Cover } from "./Cover";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
|
import { Transport } from "./Transport";
|
||||||
|
import { VolumeBars } from "./VolumeBars";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
state: PlayerState;
|
||||||
|
album: Album | null;
|
||||||
|
position: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onSeek: (position: number) => void;
|
||||||
|
onVolume: (percent: number) => void;
|
||||||
|
onMute: () => void;
|
||||||
|
onBrowse: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayView({
|
||||||
|
state,
|
||||||
|
album,
|
||||||
|
position,
|
||||||
|
onToggle,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
onSeek,
|
||||||
|
onVolume,
|
||||||
|
onMute,
|
||||||
|
onBrowse,
|
||||||
|
}: Props) {
|
||||||
|
const book = album ? isBook(album) : false;
|
||||||
|
const remaining = album
|
||||||
|
? remainingInAlbum(
|
||||||
|
album.tracks.map((track) => track.duration),
|
||||||
|
state.track_index,
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", zIndex: 1, height: "100%", minHeight: 0, overflow: "auto" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "clamp(8px, 1.8vh, 26px)",
|
||||||
|
padding: "clamp(14px, 3.5vh, 40px) 40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/dolphin-mascot.png"
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "14%",
|
||||||
|
left: 0,
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
objectFit: "contain",
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0.92,
|
||||||
|
animation: "dolphinSwim 62s linear infinite",
|
||||||
|
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "min(340px, 32vh)",
|
||||||
|
flex: "none",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
display: "flex",
|
||||||
|
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 210 / .55))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album ? (
|
||||||
|
<Cover
|
||||||
|
album={album}
|
||||||
|
size={340}
|
||||||
|
fit="height"
|
||||||
|
radius={book ? "18px 34px 34px 18px" : "28px"}
|
||||||
|
label
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 28,
|
||||||
|
background: "oklch(35% 0.03 210)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "center", maxWidth: 760, position: "relative", zIndex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "clamp(28px, 4.6vh, 46px)",
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
textWrap: "pretty",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.track_title ?? "Wähl ein Album!"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "oklch(88% 0.02 210 / .8)",
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||||
|
</div>
|
||||||
|
{album && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--accent-dim)",
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: "min(680px, 90%)", position: "relative", zIndex: 1 }}>
|
||||||
|
<ProgressBar
|
||||||
|
position={position}
|
||||||
|
duration={state.duration}
|
||||||
|
onSeek={onSeek}
|
||||||
|
height={14}
|
||||||
|
interactive
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(88% 0.02 210 / .8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
|
||||||
|
{state.duration ? `−${clock(state.duration - position)}` : ""}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{album ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: "relative", zIndex: 1 }}>
|
||||||
|
<Transport
|
||||||
|
playing={state.playing}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onNext={onNext}
|
||||||
|
onPrevious={onPrevious}
|
||||||
|
size={72}
|
||||||
|
gap={22}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: "relative", zIndex: 1 }}>
|
||||||
|
<VolumeBars
|
||||||
|
volume={state.volume}
|
||||||
|
onChange={onVolume}
|
||||||
|
onMute={onMute}
|
||||||
|
barWidth={18}
|
||||||
|
barHeight="clamp(26px, 4.6vh, 44px)"
|
||||||
|
gap={5}
|
||||||
|
iconSize={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onBrowse}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 28,
|
||||||
|
left: 32,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "oklch(97% 0.01 210 / .95)",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "11px 20px",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--ink)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
font: "800 13px ui-monospace, monospace",
|
||||||
|
background: "var(--ink)",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
Zurück zur Suche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
web/src/components/PlayerBar.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/** The bar along the bottom of the browse screen. */
|
||||||
|
|
||||||
|
import type { Album, PlayerState } from "../api/types";
|
||||||
|
import { albumLine, isBook } from "../lib/covers";
|
||||||
|
import { Cover } from "./Cover";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
|
import { Transport } from "./Transport";
|
||||||
|
import { VolumeBars } from "./VolumeBars";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
state: PlayerState;
|
||||||
|
album: Album | null;
|
||||||
|
position: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onSeek: (position: number) => void;
|
||||||
|
onVolume: (percent: number) => void;
|
||||||
|
onMute: () => void;
|
||||||
|
onOpenPlayView: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerBar({
|
||||||
|
state,
|
||||||
|
album,
|
||||||
|
position,
|
||||||
|
onToggle,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
onSeek,
|
||||||
|
onVolume,
|
||||||
|
onMute,
|
||||||
|
onOpenPlayView,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
background: "oklch(18% 0.05 210 / .96)",
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
padding: "16px clamp(12px, 3vw, 32px)",
|
||||||
|
// Clear of the iPhone home indicator when installed to the home screen.
|
||||||
|
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "clamp(10px, 1.6vw, 22px)",
|
||||||
|
borderTop: "3px solid oklch(70% 0.16 340 / .5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/dolphin-mascot.png"
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: "clamp(56px, 7vw, 84px)",
|
||||||
|
height: "clamp(56px, 7vw, 84px)",
|
||||||
|
objectFit: "contain",
|
||||||
|
flex: "0 1 auto",
|
||||||
|
minWidth: 0,
|
||||||
|
animation: state.playing ? "dolphinBob 1.6s ease-in-out infinite" : "none",
|
||||||
|
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOpenPlayView}
|
||||||
|
aria-label="Große Ansicht"
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
width: album ? "auto" : 56,
|
||||||
|
flex: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
cursor: album ? "pointer" : "default",
|
||||||
|
background: album ? "none" : "oklch(35% 0.03 210)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album && <Cover album={album} size={56} fit="height" radius="10px" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ flex: "0 1 220px", minWidth: 0, overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--paper)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.track_title ?? "Wähl ein Album!"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "oklch(85% 0.02 210 / .75)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||||
|
</div>
|
||||||
|
{album && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--accent-dim)",
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "}
|
||||||
|
{state.track_count}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: "1 1 auto", maxWidth: 360, minWidth: 90 }}>
|
||||||
|
<ProgressBar
|
||||||
|
position={position}
|
||||||
|
duration={state.duration}
|
||||||
|
onSeek={onSeek}
|
||||||
|
height={10}
|
||||||
|
interactive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transport
|
||||||
|
playing={state.playing}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onNext={onNext}
|
||||||
|
onPrevious={onPrevious}
|
||||||
|
size={48}
|
||||||
|
gap={10}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VolumeBars
|
||||||
|
volume={state.volume}
|
||||||
|
onChange={onVolume}
|
||||||
|
onMute={onMute}
|
||||||
|
barWidth={11}
|
||||||
|
barHeight="30px"
|
||||||
|
gap={3}
|
||||||
|
iconSize={18}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
web/src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/** The progress bar, and the one thing the mockup could not do: scrubbing. */
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
onSeek: (position: number) => void;
|
||||||
|
height: number;
|
||||||
|
interactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ position, duration, onSeek, height, interactive }: Props) {
|
||||||
|
const track = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [dragging, setDragging] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const positionAt = useCallback(
|
||||||
|
(clientX: number): number => {
|
||||||
|
const box = track.current?.getBoundingClientRect();
|
||||||
|
if (!box || !duration) return 0;
|
||||||
|
const ratio = Math.min(1, Math.max(0, (clientX - box.left) / box.width));
|
||||||
|
return ratio * duration;
|
||||||
|
},
|
||||||
|
[duration],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shown = dragging ?? position;
|
||||||
|
const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0;
|
||||||
|
|
||||||
|
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!interactive || !duration) return;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setDragging(positionAt(event.clientX));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={track}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
if (dragging !== null) setDragging(positionAt(event.clientX));
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
if (dragging === null) return;
|
||||||
|
const target = positionAt(event.clientX);
|
||||||
|
setDragging(null);
|
||||||
|
onSeek(target);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "oklch(30% 0.03 210 / .6)",
|
||||||
|
overflow: "hidden",
|
||||||
|
cursor: interactive && duration ? "pointer" : "default",
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "var(--accent)",
|
||||||
|
width: `${percent}%`,
|
||||||
|
// No easing while dragging, or the fill lags the finger.
|
||||||
|
transition: dragging === null ? "width .25s linear" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
web/src/components/Transport.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/** The three transport buttons, at two sizes. The glyphs are CSS shapes, as in the
|
||||||
|
* mockup - no icon font to load on a device that boots offline. */
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
playing: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
/** Side length of the skip buttons; the play button scales from it. */
|
||||||
|
size: number;
|
||||||
|
gap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }: Props) {
|
||||||
|
const glyph = Math.round(size * 0.33);
|
||||||
|
const bar = Math.max(4, Math.round(size * 0.085));
|
||||||
|
const playSize = Math.round(size * 1.26);
|
||||||
|
const playBar = Math.max(6, Math.round(playSize * 0.1));
|
||||||
|
const playGlyph = Math.round(playSize * 0.37);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap }}>
|
||||||
|
<button
|
||||||
|
className="round"
|
||||||
|
onClick={onPrevious}
|
||||||
|
aria-label="Vorheriger Titel"
|
||||||
|
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
||||||
|
>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: glyph,
|
||||||
|
height: glyph,
|
||||||
|
background: "#fff",
|
||||||
|
clipPath: "polygon(100% 0%, 100% 100%, 0% 50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="round"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={playing ? "Pause" : "Abspielen"}
|
||||||
|
style={{
|
||||||
|
width: playSize,
|
||||||
|
height: playSize,
|
||||||
|
background: "var(--accent)",
|
||||||
|
boxShadow: "0 6px 20px oklch(70% 0.16 340 / .5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<span style={{ display: "flex", gap: playBar * 0.8 }}>
|
||||||
|
<span
|
||||||
|
style={{ width: playBar, height: playGlyph, borderRadius: 2, background: "#fff" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{ width: playBar, height: playGlyph, borderRadius: 2, background: "#fff" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: playGlyph,
|
||||||
|
height: playGlyph,
|
||||||
|
background: "#fff",
|
||||||
|
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="round"
|
||||||
|
onClick={onNext}
|
||||||
|
aria-label="Nächster Titel"
|
||||||
|
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
||||||
|
>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: glyph,
|
||||||
|
height: glyph,
|
||||||
|
background: "#fff",
|
||||||
|
clipPath: "polygon(0% 0%, 0% 100%, 100% 50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
web/src/components/VolumeBars.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/** Five bars, not a slider. The device's real range never reaches the browser: 100 %
|
||||||
|
* here means whatever ceiling a parent configured. */
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 0..100. */
|
||||||
|
volume: number;
|
||||||
|
onChange: (percent: number) => void;
|
||||||
|
onMute: () => void;
|
||||||
|
barWidth: number;
|
||||||
|
barHeight: string;
|
||||||
|
gap: number;
|
||||||
|
iconSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VolumeBars({
|
||||||
|
volume,
|
||||||
|
onChange,
|
||||||
|
onMute,
|
||||||
|
barWidth,
|
||||||
|
barHeight,
|
||||||
|
gap,
|
||||||
|
iconSize,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={onMute}
|
||||||
|
title="Stumm"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: iconSize,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{volume === 0 ? "🔇" : "🔊"}
|
||||||
|
</button>
|
||||||
|
<div style={{ display: "flex", gap }}>
|
||||||
|
{[20, 40, 60, 80, 100].map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => onChange(level)}
|
||||||
|
title={`Lautstärke ${level} %`}
|
||||||
|
aria-label={`Lautstärke ${level} Prozent`}
|
||||||
|
style={{
|
||||||
|
width: barWidth,
|
||||||
|
height: barHeight,
|
||||||
|
borderRadius: 5,
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
background:
|
||||||
|
volume >= level ? "var(--accent)" : "oklch(88% 0.02 210 / .4)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
web/src/hooks/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** How many columns the album grid actually has.
|
||||||
|
|
||||||
|
Arrow-key navigation has to move a whole row at a time, and the grid is
|
||||||
|
`repeat(auto-fill, minmax(180px, 1fr))` - so the count is whatever the browser worked
|
||||||
|
out, not something the code can assume.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useGridColumns(): [(element: HTMLElement | null) => void, number] {
|
||||||
|
const [columns, setColumns] = useState(4);
|
||||||
|
const element = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const grid = element.current;
|
||||||
|
if (!grid) return;
|
||||||
|
const count = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length;
|
||||||
|
if (count) setColumns((previous) => (count === previous ? previous : count));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ref = useCallback(
|
||||||
|
(node: HTMLElement | null) => {
|
||||||
|
element.current = node;
|
||||||
|
if (node) requestAnimationFrame(measure);
|
||||||
|
},
|
||||||
|
[measure],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
if (element.current) observer.observe(element.current);
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener("resize", measure);
|
||||||
|
};
|
||||||
|
}, [measure]);
|
||||||
|
|
||||||
|
return [ref, columns];
|
||||||
|
}
|
||||||
38
web/src/hooks/useLibrary.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/** The whole album index, fetched once.
|
||||||
|
|
||||||
|
At roughly 90 kB for a real collection this is cheaper than a search endpoint and makes
|
||||||
|
type-to-search instant, which is what the keyboard-first design needs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { Album } from "../api/types";
|
||||||
|
|
||||||
|
export interface Library {
|
||||||
|
albums: Album[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLibrary(): Library {
|
||||||
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
api
|
||||||
|
.library()
|
||||||
|
.then((loaded) => {
|
||||||
|
setAlbums(loaded);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => setError(String(cause)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(reload, [reload]);
|
||||||
|
|
||||||
|
return { albums, loading, error, reload };
|
||||||
|
}
|
||||||
33
web/src/hooks/usePlaybackClock.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/** A smooth playback position, from a position that only arrives twice a second.
|
||||||
|
|
||||||
|
Driving a progress bar straight off the websocket visibly steps. This interpolates
|
||||||
|
between frames on every animation frame and re-seeds whenever a real one lands, which
|
||||||
|
also gives beat-synced animation the frame-accurate clock it will need later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function usePlaybackClock(position: number, playing: boolean): number {
|
||||||
|
const [interpolated, setInterpolated] = useState(position);
|
||||||
|
const anchor = useRef({ position, at: performance.now() });
|
||||||
|
|
||||||
|
// A new server frame is the truth; restart the interpolation from it.
|
||||||
|
useEffect(() => {
|
||||||
|
anchor.current = { position, at: performance.now() };
|
||||||
|
setInterpolated(position);
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
let frame = 0;
|
||||||
|
const tick = () => {
|
||||||
|
const { position: base, at } = anchor.current;
|
||||||
|
setInterpolated(base + (performance.now() - at) / 1000);
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
return playing ? interpolated : position;
|
||||||
|
}
|
||||||
82
web/src/hooks/usePlayerState.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/** The live connection to the backend.
|
||||||
|
|
||||||
|
State arrives over a websocket that only pushes: every command goes out as a plain
|
||||||
|
POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is
|
||||||
|
still right, and `onLibraryChanged` fires when a rescan has finished.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { PlayerState, ServerMessage } from "../api/types";
|
||||||
|
|
||||||
|
const RECONNECT_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
export interface Connection {
|
||||||
|
state: PlayerState | null;
|
||||||
|
online: boolean;
|
||||||
|
/** Apply a change locally so a keypress feels instant; the next frame reconciles. */
|
||||||
|
optimistic: (patch: Partial<PlayerState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerState(onLibraryChanged: () => void): Connection {
|
||||||
|
const [state, setState] = useState<PlayerState | null>(null);
|
||||||
|
const [online, setOnline] = useState(false);
|
||||||
|
|
||||||
|
// Kept in a ref so reconnecting never re-runs the effect that owns the socket.
|
||||||
|
const libraryChanged = useRef(onLibraryChanged);
|
||||||
|
libraryChanged.current = onLibraryChanged;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
socket = new WebSocket(`${protocol}//${location.host}/api/ws`);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setOnline(true);
|
||||||
|
// The server sends a snapshot on connect, but a reconnect may have missed
|
||||||
|
// changes in between, so ask for the truth as well.
|
||||||
|
void api.state().then(setState).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data as string) as ServerMessage;
|
||||||
|
if (message.type === "state") {
|
||||||
|
setState(message.state);
|
||||||
|
} else if (message.type === "position") {
|
||||||
|
setState((previous) =>
|
||||||
|
previous
|
||||||
|
? { ...previous, position: message.position, duration: message.duration }
|
||||||
|
: previous,
|
||||||
|
);
|
||||||
|
} else if (message.type === "library") {
|
||||||
|
libraryChanged.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnect = () => {
|
||||||
|
setOnline(false);
|
||||||
|
if (!closed) retry = setTimeout(connect, RECONNECT_DELAY_MS);
|
||||||
|
};
|
||||||
|
socket.onclose = reconnect;
|
||||||
|
socket.onerror = () => socket?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
clearTimeout(retry);
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const optimistic = useCallback((patch: Partial<PlayerState>) => {
|
||||||
|
setState((previous) => (previous ? { ...previous, ...patch } : previous));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, online, optimistic };
|
||||||
|
}
|
||||||
34
web/src/lib/__tests__/format.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { clock, remainingInAlbum } from "../format";
|
||||||
|
|
||||||
|
describe("clock", () => {
|
||||||
|
it("formats minutes and seconds", () => {
|
||||||
|
expect(clock(0)).toBe("0:00");
|
||||||
|
expect(clock(9)).toBe("0:09");
|
||||||
|
expect(clock(200.5)).toBe("3:21");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grows an hours field for a long audiobook", () => {
|
||||||
|
expect(clock(3661)).toBe("1:01:01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never shows negative time", () => {
|
||||||
|
expect(clock(-5)).toBe("0:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remainingInAlbum", () => {
|
||||||
|
it("counts the rest of this track plus every track after it", () => {
|
||||||
|
expect(remainingInAlbum([60, 30, 10], 0, 20)).toBe(80);
|
||||||
|
expect(remainingInAlbum([60, 30, 10], 2, 0)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copes with a position past the end of the track", () => {
|
||||||
|
expect(remainingInAlbum([60, 30], 0, 999)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zero for an empty album", () => {
|
||||||
|
expect(remainingInAlbum([], 0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
224
web/src/lib/__tests__/keyboard.test.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Album } from "../../api/types";
|
||||||
|
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
|
||||||
|
import { normalize, results as computeResults } from "../search";
|
||||||
|
|
||||||
|
function album(id: string, over: Partial<Album> = {}): Album {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
section: "Musik",
|
||||||
|
kind: "music",
|
||||||
|
title: `Album ${id}`,
|
||||||
|
artist: "Kinderparty",
|
||||||
|
series: null,
|
||||||
|
figure: null,
|
||||||
|
category: "Kinderparty",
|
||||||
|
colors: ["#111111", "#222222", "#333333"],
|
||||||
|
has_cover: false,
|
||||||
|
duration: 120,
|
||||||
|
tracks: [
|
||||||
|
{ title: `Lied ${id}`, duration: 60, analysis: null },
|
||||||
|
{ title: "Zweites Lied", duration: 60, analysis: null },
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALBUMS: Album[] = [
|
||||||
|
album("a"),
|
||||||
|
album("b", { title: "Hörbuch Conni", artist: "Conni", kind: "book", series: "Conni", category: "Conni" }),
|
||||||
|
album("c", { artist: "Rolf", category: "Rolf" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) => ({
|
||||||
|
key: k,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultsFor = (ui: UiState) =>
|
||||||
|
computeResults({
|
||||||
|
albums: ALBUMS,
|
||||||
|
search: ui.search,
|
||||||
|
mode: ui.mode,
|
||||||
|
filter: ui.filter,
|
||||||
|
category: ui.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
const press = (k: string, ui: UiState = initialUiState, over = {}) =>
|
||||||
|
handleKey(key(k, over), ui, resultsFor(ui));
|
||||||
|
|
||||||
|
describe("normalize", () => {
|
||||||
|
it("strips case, punctuation and diacritics so a child's letters match", () => {
|
||||||
|
expect(normalize("Hörbücher, Folge 2!")).toBe("horbucherfolge2");
|
||||||
|
expect(normalize("Käpt'n Krabbe")).toBe("kaptnkrabbe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("search", () => {
|
||||||
|
it("shows categories with no query, albums once there is one", () => {
|
||||||
|
expect(resultsFor(initialUiState).categories).toHaveLength(3);
|
||||||
|
expect(resultsFor(initialUiState).albums).toHaveLength(0);
|
||||||
|
|
||||||
|
const searching = { ...initialUiState, search: "conni" };
|
||||||
|
expect(resultsFor(searching).albums.map((a) => a.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters books and music apart", () => {
|
||||||
|
const books = resultsFor({ ...initialUiState, filter: "book", search: "a" });
|
||||||
|
expect(books.albums.every((a) => a.kind === "book")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches title and artist together", () => {
|
||||||
|
expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keyboard", () => {
|
||||||
|
it("types letters into the search", () => {
|
||||||
|
expect(press("k")).toEqual([
|
||||||
|
{ type: "ui", patch: { search: "k", view: "browse", selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts umlauts, which the mockup's a-z0-9 test rejected", () => {
|
||||||
|
expect(press("ö")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores keys that mean nothing, so the browser keeps its own", () => {
|
||||||
|
expect(press("F5")).toEqual([]);
|
||||||
|
expect(press("r", { ...initialUiState }, { ctrlKey: true })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the ctrl chords to transport and volume", () => {
|
||||||
|
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([{ type: "next" }]);
|
||||||
|
expect(press("h", initialUiState, { ctrlKey: true })).toEqual([{ type: "previous" }]);
|
||||||
|
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([
|
||||||
|
{ type: "volume", delta: 10 },
|
||||||
|
]);
|
||||||
|
expect(press("j", initialUiState, { ctrlKey: true })).toEqual([
|
||||||
|
{ type: "volume", delta: -10 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cycles the filter with TAB", () => {
|
||||||
|
expect(press("Tab")).toContainEqual({
|
||||||
|
type: "ui",
|
||||||
|
patch: { filter: "music", selIndex: 0, view: "browse", category: null },
|
||||||
|
});
|
||||||
|
expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({
|
||||||
|
type: "ui",
|
||||||
|
patch: { filter: "all", selIndex: 0, view: "browse", category: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives arrows to navigation while browsing and to transport while playing", () => {
|
||||||
|
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
|
||||||
|
|
||||||
|
const playing: UiState = { ...initialUiState, view: "play" };
|
||||||
|
expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]);
|
||||||
|
expect(press("ArrowUp", playing)).toEqual([{ type: "volume", delta: 10 }]);
|
||||||
|
expect(press("ArrowDown", playing)).toEqual([{ type: "volume", delta: -10 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeks with shift+arrows in either view", () => {
|
||||||
|
expect(press("ArrowRight", initialUiState, { shiftKey: true })).toEqual([
|
||||||
|
{ type: "seek", delta: 15 },
|
||||||
|
]);
|
||||||
|
expect(press("ArrowLeft", { ...initialUiState, view: "play" }, { shiftKey: true })).toEqual([
|
||||||
|
{ type: "seek", delta: -15 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a whole row at a time in the grid", () => {
|
||||||
|
const ui = { ...initialUiState, cols: 2, selIndex: 0 };
|
||||||
|
expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([
|
||||||
|
{ type: "ui", patch: { selIndex: 2 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the selection to what is on screen", () => {
|
||||||
|
const ui = { ...initialUiState, selIndex: 2 };
|
||||||
|
expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([
|
||||||
|
{ type: "ui", patch: { selIndex: 2 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("peels one layer at a time with ESC", () => {
|
||||||
|
const deep: UiState = {
|
||||||
|
...initialUiState,
|
||||||
|
showHelp: true,
|
||||||
|
openAlbumId: "a",
|
||||||
|
search: "x",
|
||||||
|
mode: "tracks",
|
||||||
|
category: "Conni",
|
||||||
|
};
|
||||||
|
expect(press("Escape", deep)).toEqual([
|
||||||
|
{ type: "ui", patch: { showHelp: false, openAlbumId: null } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const searching = { ...deep, showHelp: false, openAlbumId: null };
|
||||||
|
expect(press("Escape", searching)).toEqual([
|
||||||
|
{ type: "ui", patch: { search: "", selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inTracks = { ...searching, search: "" };
|
||||||
|
expect(press("Escape", inTracks)).toEqual([
|
||||||
|
{ type: "ui", patch: { mode: "albums", selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inCategory = { ...inTracks, mode: "albums" as const };
|
||||||
|
expect(press("Escape", inCategory)).toEqual([
|
||||||
|
{ type: "ui", patch: { category: null, selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not swallow backspace when there is nothing to delete", () => {
|
||||||
|
expect(press("Backspace")).toEqual([]);
|
||||||
|
expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([
|
||||||
|
{ type: "ui", patch: { search: "a", selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to track search with ?", () => {
|
||||||
|
expect(press("?")).toContainEqual({
|
||||||
|
type: "ui",
|
||||||
|
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays the open album from the top on ENTER", () => {
|
||||||
|
expect(press("Enter", { ...initialUiState, openAlbumId: "b" })).toEqual([
|
||||||
|
{ type: "play", albumId: "b", trackIndex: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectionAt", () => {
|
||||||
|
it("walks songs, then categories, then albums in one flat index space", () => {
|
||||||
|
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };
|
||||||
|
const found = resultsFor(ui);
|
||||||
|
expect(found.songs.length).toBeGreaterThan(0);
|
||||||
|
expect(selectionAt(found, 0)).toEqual({
|
||||||
|
type: "play",
|
||||||
|
albumId: found.songs[0]!.album.id,
|
||||||
|
trackIndex: found.songs[0]!.index,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a category rather than playing it", () => {
|
||||||
|
const found = resultsFor(initialUiState);
|
||||||
|
expect(selectionAt(found, 0)).toEqual({
|
||||||
|
type: "ui",
|
||||||
|
patch: { category: found.categories[0]!.key, selIndex: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing when there is nothing to select", () => {
|
||||||
|
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
|
||||||
|
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
web/src/lib/covers.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/** How an album is painted when there is no artwork, and how a book is made to look
|
||||||
|
* like a book.
|
||||||
|
*
|
||||||
|
* The mockup generated everything from a single per-album `hue`. The backend now sends
|
||||||
|
* three colours pulled out of the real cover art, so the generated fallback and the
|
||||||
|
* artwork agree - and so do the LED strips, which run the same primary colour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Album, AlbumKind } from "../api/types";
|
||||||
|
|
||||||
|
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||||
|
|
||||||
|
/** Shape is how you tell the two apart without reading anything: albums are square,
|
||||||
|
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
|
||||||
|
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
|
||||||
|
export const ALBUM_ASPECT = 1;
|
||||||
|
export const BOOK_ASPECT = 0.82;
|
||||||
|
|
||||||
|
export const aspectOfKind = (kind: AlbumKind): number =>
|
||||||
|
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
|
||||||
|
|
||||||
|
export const aspectOf = (album: Album): number => aspectOfKind(album.kind);
|
||||||
|
|
||||||
|
const colours = (album: Album): [string, string, string] => [
|
||||||
|
album.colors[0] ?? "#4a6fa5",
|
||||||
|
album.colors[1] ?? "#6a8fc5",
|
||||||
|
album.colors[2] ?? "#a5804a",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
|
||||||
|
export function stripes(album: Album, width: number): string {
|
||||||
|
const [primary, secondary] = colours(album);
|
||||||
|
return (
|
||||||
|
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
|
||||||
|
`${secondary} ${width}px, ${secondary} ${width * 2}px)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A book spine: page edges on the right, a darker board on the left. */
|
||||||
|
export function spine(album: Album): string {
|
||||||
|
const [primary, secondary] = colours(album);
|
||||||
|
return [
|
||||||
|
"linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%," +
|
||||||
|
" oklch(90% 0.03 88) 97.5% 100%)",
|
||||||
|
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
|
||||||
|
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
|
||||||
|
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
|
||||||
|
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
|
||||||
|
].join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverBackground(album: Album, stripeWidth: number): string {
|
||||||
|
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card's own tint: warm paper for books, cool glass for music. */
|
||||||
|
export const cardBackground = (album: Album): string =>
|
||||||
|
isBook(album) ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)";
|
||||||
|
|
||||||
|
export const cardShadow = (album: Album): string =>
|
||||||
|
isBook(album)
|
||||||
|
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7)," +
|
||||||
|
" 0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||||
|
: "0 6px 18px oklch(15% 0.05 210 / .35)";
|
||||||
|
|
||||||
|
export const unitLabel = (album: Album, count: number): string =>
|
||||||
|
isBook(album)
|
||||||
|
? `${count} ${count === 1 ? "Kapitel" : "Kapitel"}`
|
||||||
|
: `${count} ${count === 1 ? "Song" : "Songs"}`;
|
||||||
|
|
||||||
|
/** "Album · Artist", unless a podcast makes those the same string. */
|
||||||
|
export const albumLine = (album: Album): string => {
|
||||||
|
const prefix = isBook(album) ? "📖 " : "";
|
||||||
|
return album.artist && album.artist !== album.title
|
||||||
|
? `${prefix}${album.title} · ${album.artist}`
|
||||||
|
: `${prefix}${album.title}`;
|
||||||
|
};
|
||||||
25
web/src/lib/format.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** Time formatting, ported from the design mockup. */
|
||||||
|
|
||||||
|
/** Seconds as `m:ss`, or `h:mm:ss` once an audiobook runs past the hour. */
|
||||||
|
export function clock(totalSeconds: number): string {
|
||||||
|
const total = Math.max(0, Math.round(totalSeconds));
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return hours ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much of the album is left after the current position. */
|
||||||
|
export function remainingInAlbum(
|
||||||
|
trackDurations: number[],
|
||||||
|
trackIndex: number,
|
||||||
|
position: number,
|
||||||
|
): number {
|
||||||
|
const current = trackDurations[trackIndex] ?? 0;
|
||||||
|
let rest = Math.max(0, current - position);
|
||||||
|
for (let i = trackIndex + 1; i < trackDurations.length; i++) {
|
||||||
|
rest += trackDurations[i] ?? 0;
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
187
web/src/lib/keyboard.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/** The keyboard state machine, ported from the design mockup.
|
||||||
|
|
||||||
|
Kept pure - it takes a key and the current view state and returns what should happen -
|
||||||
|
so the whole interaction model can be tested without a DOM, and so the component that
|
||||||
|
mounts it stays a thin adapter.
|
||||||
|
|
||||||
|
The mockup's key map is followed exactly, with one addition: SHIFT+arrows seek. Plain
|
||||||
|
arrows were already taken (selection while browsing, transport and volume while
|
||||||
|
playing), and seeking is the one thing a real player can do that the mockup could not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Album } from "../api/types";
|
||||||
|
import type { Filter, Mode, Results } from "./search";
|
||||||
|
|
||||||
|
export interface UiState {
|
||||||
|
search: string;
|
||||||
|
mode: Mode;
|
||||||
|
filter: Filter;
|
||||||
|
category: string | null;
|
||||||
|
selIndex: number;
|
||||||
|
view: "browse" | "play";
|
||||||
|
openAlbumId: string | null;
|
||||||
|
showHelp: boolean;
|
||||||
|
cols: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialUiState: UiState = {
|
||||||
|
search: "",
|
||||||
|
mode: "albums",
|
||||||
|
filter: "all",
|
||||||
|
category: null,
|
||||||
|
selIndex: 0,
|
||||||
|
view: "browse",
|
||||||
|
openAlbumId: null,
|
||||||
|
showHelp: false,
|
||||||
|
cols: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| { type: "ui"; patch: Partial<UiState> }
|
||||||
|
| { type: "play"; albumId: string; trackIndex: number }
|
||||||
|
| { type: "toggle" }
|
||||||
|
| { type: "next" }
|
||||||
|
| { type: "previous" }
|
||||||
|
| { type: "volume"; delta: number }
|
||||||
|
| { type: "seek"; delta: number }
|
||||||
|
| { type: "pop"; freq: number };
|
||||||
|
|
||||||
|
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||||
|
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||||
|
|
||||||
|
const FILTER_ORDER: Filter[] = ["all", "music", "book"];
|
||||||
|
|
||||||
|
export const VOLUME_STEP = 10;
|
||||||
|
export const SEEK_STEP = 15;
|
||||||
|
|
||||||
|
/** What the flat selection index currently points at. */
|
||||||
|
export function selectionAt(results: Results, selIndex: number): Action | null {
|
||||||
|
const { songs, categories, albums } = results;
|
||||||
|
const index = Math.min(selIndex, results.total - 1);
|
||||||
|
if (index < 0) return null;
|
||||||
|
|
||||||
|
if (index < songs.length) {
|
||||||
|
const hit = songs[index];
|
||||||
|
return hit ? { type: "play", albumId: hit.album.id, trackIndex: hit.index } : null;
|
||||||
|
}
|
||||||
|
const afterSongs = index - songs.length;
|
||||||
|
if (afterSongs < categories.length) {
|
||||||
|
const category = categories[afterSongs];
|
||||||
|
return category ? { type: "ui", patch: { category: category.key, selIndex: 0 } } : null;
|
||||||
|
}
|
||||||
|
const album: Album | undefined = albums[afterSongs - categories.length];
|
||||||
|
return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
|
||||||
|
if (!results.total) return [];
|
||||||
|
const cols = Math.max(1, state.cols);
|
||||||
|
let index = Math.min(state.selIndex, results.total - 1);
|
||||||
|
if (dx) index += dx;
|
||||||
|
if (dy) {
|
||||||
|
// Song hits are a single-column list; everything below them is a grid.
|
||||||
|
index += index < results.songs.length ? dy : dy * cols;
|
||||||
|
}
|
||||||
|
index = Math.max(0, Math.min(index, results.total - 1));
|
||||||
|
return [{ type: "ui", patch: { selIndex: index } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||||
|
function escape(state: UiState): Action[] {
|
||||||
|
if (state.showHelp || state.openAlbumId !== null) {
|
||||||
|
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||||
|
}
|
||||||
|
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
||||||
|
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
||||||
|
return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyEvent {
|
||||||
|
key: string;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate one keypress. Returns the actions to run, or nothing when the key means
|
||||||
|
* nothing here - the caller only calls `preventDefault()` when something came back.
|
||||||
|
*/
|
||||||
|
export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] {
|
||||||
|
const { key } = event;
|
||||||
|
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
switch (key.toLowerCase()) {
|
||||||
|
case "l":
|
||||||
|
return [{ type: "next" }];
|
||||||
|
case "h":
|
||||||
|
return [{ type: "previous" }];
|
||||||
|
case "k":
|
||||||
|
return [{ type: "volume", delta: VOLUME_STEP }];
|
||||||
|
case "j":
|
||||||
|
return [{ type: "volume", delta: -VOLUME_STEP }];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "Tab": {
|
||||||
|
const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!;
|
||||||
|
return [
|
||||||
|
{ type: "pop", freq: 380 },
|
||||||
|
{ type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "/":
|
||||||
|
return [{ type: "ui", patch: { view: "browse" } }];
|
||||||
|
case " ":
|
||||||
|
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
|
||||||
|
|
||||||
|
case "ArrowRight":
|
||||||
|
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||||
|
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
||||||
|
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
||||||
|
case "ArrowDown":
|
||||||
|
return browsing
|
||||||
|
? moveSelection(state, results, 0, 1)
|
||||||
|
: [{ type: "volume", delta: -VOLUME_STEP }];
|
||||||
|
case "ArrowUp":
|
||||||
|
return browsing
|
||||||
|
? moveSelection(state, results, 0, -1)
|
||||||
|
: [{ type: "volume", delta: VOLUME_STEP }];
|
||||||
|
|
||||||
|
case "?":
|
||||||
|
return [
|
||||||
|
{ type: "pop", freq: 460 },
|
||||||
|
{
|
||||||
|
type: "ui",
|
||||||
|
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "F1":
|
||||||
|
return [{ type: "ui", patch: { showHelp: !state.showHelp } }];
|
||||||
|
case "Escape":
|
||||||
|
return escape(state);
|
||||||
|
case "Backspace":
|
||||||
|
return state.search
|
||||||
|
? [{ type: "ui", patch: { search: state.search.slice(0, -1), selIndex: 0 } }]
|
||||||
|
: [];
|
||||||
|
case "Enter": {
|
||||||
|
if (state.openAlbumId !== null) {
|
||||||
|
return [{ type: "play", albumId: state.openAlbumId, trackIndex: 0 }];
|
||||||
|
}
|
||||||
|
const chosen = selectionAt(results, state.selIndex);
|
||||||
|
return chosen ? [chosen] : [];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if (SEARCHABLE.test(key)) {
|
||||||
|
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
web/src/lib/pop.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** The mockup's click feedback: a short rising blip per interaction. */
|
||||||
|
|
||||||
|
let context: AudioContext | null = null;
|
||||||
|
|
||||||
|
export function playPop(frequency: number): void {
|
||||||
|
try {
|
||||||
|
context ??= new AudioContext();
|
||||||
|
const now = context.currentTime;
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const gain = context.createGain();
|
||||||
|
oscillator.type = "sine";
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, now);
|
||||||
|
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.8, now + 0.08);
|
||||||
|
gain.gain.setValueAtTime(0.15, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||||
|
oscillator.connect(gain).connect(context.destination);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(now + 0.16);
|
||||||
|
} catch {
|
||||||
|
// No audio context before the first user gesture, and on some browsers never.
|
||||||
|
}
|
||||||
|
}
|
||||||
114
web/src/lib/search.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/** Browsing and searching the library, entirely in the browser.
|
||||||
|
|
||||||
|
The whole index arrives in one response, so type-to-search has no round trip and feels
|
||||||
|
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Album } from "../api/types";
|
||||||
|
|
||||||
|
export type Filter = "all" | "music" | "book";
|
||||||
|
export type Mode = "albums" | "tracks";
|
||||||
|
|
||||||
|
export interface SongHit {
|
||||||
|
album: Album;
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
key: string;
|
||||||
|
albums: Album[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diacritics and punctuation are noise when a child is hunting for letters. */
|
||||||
|
export function normalize(value: string): string {
|
||||||
|
return (value ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inFilter(album: Album, filter: Filter): boolean {
|
||||||
|
if (filter === "book") return album.kind === "book";
|
||||||
|
if (filter === "music") return album.kind === "music";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pool(albums: Album[], filter: Filter): Album[] {
|
||||||
|
return albums.filter((album) => inFilter(album, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseQuery {
|
||||||
|
albums: Album[];
|
||||||
|
search: string;
|
||||||
|
mode: Mode;
|
||||||
|
filter: Filter;
|
||||||
|
category: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which of the three lists the browse view is showing. */
|
||||||
|
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
||||||
|
if (query.mode === "tracks") return "tracks";
|
||||||
|
if (query.search || query.category) return "albums";
|
||||||
|
return "categories";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryMatches(query: BrowseQuery): Category[] {
|
||||||
|
if (listMode(query) !== "categories") return [];
|
||||||
|
const map = new Map<string, Category>();
|
||||||
|
for (const album of pool(query.albums, query.filter)) {
|
||||||
|
const key = album.category;
|
||||||
|
let entry = map.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { key, albums: [] };
|
||||||
|
map.set(key, entry);
|
||||||
|
}
|
||||||
|
entry.albums.push(album);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function albumMatches(query: BrowseQuery): Album[] {
|
||||||
|
if (query.mode === "tracks") return [];
|
||||||
|
const needle = normalize(query.search);
|
||||||
|
let candidates = pool(query.albums, query.filter);
|
||||||
|
if (!needle && !query.category) return [];
|
||||||
|
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
||||||
|
if (!needle) return candidates;
|
||||||
|
return candidates.filter((a) => normalize(a.title + a.artist).includes(needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
||||||
|
export const MAX_SONG_HITS = 40;
|
||||||
|
|
||||||
|
export function songMatches(query: BrowseQuery): SongHit[] {
|
||||||
|
if (query.mode !== "tracks") return [];
|
||||||
|
const needle = normalize(query.search);
|
||||||
|
const hits: SongHit[] = [];
|
||||||
|
for (const album of pool(query.albums, query.filter)) {
|
||||||
|
album.tracks.forEach((track, index) => {
|
||||||
|
if (!needle || normalize(track.title).includes(needle)) {
|
||||||
|
hits.push({ album, index, title: track.title, duration: track.duration });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hits.length >= MAX_SONG_HITS) break;
|
||||||
|
}
|
||||||
|
return hits.slice(0, MAX_SONG_HITS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Results {
|
||||||
|
songs: SongHit[];
|
||||||
|
categories: Category[];
|
||||||
|
albums: Album[];
|
||||||
|
/** Selection indices run flat across songs, then categories, then albums. */
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function results(query: BrowseQuery): Results {
|
||||||
|
const songs = songMatches(query);
|
||||||
|
const categories = categoryMatches(query);
|
||||||
|
const albums = albumMatches(query);
|
||||||
|
return { songs, categories, albums, total: songs.length + categories.length + albums.length };
|
||||||
|
}
|
||||||
23
web/src/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles/app.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registered after load so it never competes with the first paint. It is what makes
|
||||||
|
// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js
|
||||||
|
// for what it does and deliberately does not cache.
|
||||||
|
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
void navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||||
|
// A plain http:// origin that is not localhost cannot register one. The app
|
||||||
|
// works exactly the same, it just cannot be installed.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
188
web/src/styles/app.css
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/* Ported from claude-design/Dolphin Beats.dc.html. The palette is oklch throughout;
|
||||||
|
the tokens below are the handful of values that repeat. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ink: oklch(30% 0.04 210);
|
||||||
|
--paper: oklch(97% 0.01 210);
|
||||||
|
--accent: oklch(70% 0.16 340);
|
||||||
|
--accent-dim: oklch(78% 0.14 340);
|
||||||
|
--sea-deep: oklch(20% 0.045 210);
|
||||||
|
--shadow: oklch(15% 0.05 210 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Nunito", system-ui, sans-serif;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
/* Installed on a phone this is an app, not a document: no rubber-banding, no
|
||||||
|
long-press callout, no accidental text selection when a small hand misses a
|
||||||
|
button. Text inside the parent-mode inputs opts back in below. */
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bubbleRise {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-120vh) scale(1.3);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dolphinBob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) rotate(-2deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px) rotate(2deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dolphinSwim {
|
||||||
|
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
||||||
|
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
||||||
|
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||||
|
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
|
||||||
|
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||||
|
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
|
||||||
|
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
|
||||||
|
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
|
||||||
|
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
|
||||||
|
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
/* dvh follows the iOS toolbar as it collapses; vh is the fallback for older
|
||||||
|
browsers, where it would otherwise leave a strip of white at the bottom. */
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
oklch(55% 0.07 210) 0%,
|
||||||
|
oklch(38% 0.06 210) 45%,
|
||||||
|
var(--sea-deep) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation-name: bubbleRise;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 9px 15px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: oklch(97% 0.01 210 / 0.16);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill[data-active="true"] {
|
||||||
|
background: var(--paper);
|
||||||
|
color: oklch(28% 0.04 210);
|
||||||
|
box-shadow: 0 4px 14px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card[data-selected="true"] {
|
||||||
|
outline: 5px solid var(--paper);
|
||||||
|
outline-offset: 3px;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card[data-current="true"]:not([data-selected="true"]) {
|
||||||
|
outline: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round {
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-cap {
|
||||||
|
font: 800 12px ui-monospace, Menlo, monospace;
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 50px oklch(10% 0.04 210 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: oklch(15% 0.03 210 / 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"],
|
||||||
|
input[type="range"] {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
20
web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
16
web/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// In development the frontend runs on 5173 and the backend on 8081; everything under
|
||||||
|
// /api - including the websocket upgrade - is proxied there, so the browser sees one
|
||||||
|
// origin and there is no CORS to configure on either side.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": { target: "http://127.0.0.1:8081", changeOrigin: true, ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: { outDir: "dist", emptyOutDir: true },
|
||||||
|
});
|
||||||