Compare commits
22 Commits
e243862769
...
deploy/mus
| Author | SHA1 | Date | |
|---|---|---|---|
| ec9d617ad8 | |||
| 6ccb3e458f | |||
| 83d16e0058 | |||
| ebab03b700 | |||
| 3fea56d4e1 | |||
| e1c10f5408 | |||
| 0ef5a04cb9 | |||
| 3cdad1e714 | |||
| 2514ecbc35 | |||
| cc3db44c4e | |||
| a7fb56c9fe | |||
| fa3d92189c | |||
| dd14f5d901 | |||
| 274085b92e | |||
| f3337975c2 | |||
| e6f6b15cc7 | |||
| f3f7082fb8 | |||
| 36e93be79b | |||
| b6342cc117 | |||
| fd6283718c | |||
| 75b0eed080 | |||
| a69db98241 |
@@ -123,3 +123,7 @@ reported at once.
|
|||||||
and size, so **a change to how the scanner derives a title, artist or series is
|
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
|
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||||
`musicmouse/library/cache.py` when you touch that logic.
|
`musicmouse/library/cache.py` when you touch that logic.
|
||||||
|
- Track analysis (librosa) runs in a pool of worker *processes* - see
|
||||||
|
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
|
||||||
|
picklable, and an analyzer that records state in its own instance (a test double
|
||||||
|
counting calls) only behaves as written with `analysis_workers=1`.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 567 KiB |
@@ -23,6 +23,12 @@ general:
|
|||||||
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||||
# is expensive to recompute.
|
# is expensive to recompute.
|
||||||
cache: .musicmouse-cache
|
cache: .musicmouse-cache
|
||||||
|
# How many tracks the background analyzer may work on at once, each in its own
|
||||||
|
# worker process. Omitted means one per core bar one (capped at 8), which is what
|
||||||
|
# turns a first-time pass over a whole library from an overnight job into a coffee
|
||||||
|
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
|
||||||
|
# a specific number to cap how much of it analysis may take.
|
||||||
|
# analysis_workers: 4
|
||||||
|
|
||||||
# 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
|
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||||
@@ -55,6 +61,19 @@ general:
|
|||||||
# a half-finished .tmp - is ignored.
|
# 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"]
|
||||||
|
|
||||||
|
# How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
|
||||||
|
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
|
||||||
|
#
|
||||||
|
# The same number caps what gets downloaded, which is what makes the folder settle.
|
||||||
|
# Prune to the newest N but fetch everything the feed offers, and every poll would
|
||||||
|
# re-download the episodes the last one deleted.
|
||||||
|
#
|
||||||
|
# Lowering this DELETES the episodes that fall outside the window on the next poll,
|
||||||
|
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
|
||||||
|
# keep every episode and mind the free space yourself.
|
||||||
|
podcast_episode_limit: 50
|
||||||
|
|
||||||
# The web front-end. Omit the whole section to run without it.
|
# 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
|
# There is no authentication: this is a device on a home network. The settings panel
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
from collections.abc import Awaitable, Callable, Coroutine
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ from musicmouse.devices.mouse import MusicMouseDevice
|
|||||||
from musicmouse.devices.null_transport import NullTransport
|
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.library import MusicLibrary, default_worker_count
|
||||||
from musicmouse.library.analysis import build_analyzer
|
from musicmouse.library.analysis import build_analyzer
|
||||||
from musicmouse.reactions import register_all
|
from musicmouse.reactions import register_all
|
||||||
from musicmouse.services.base import Service
|
from musicmouse.services.base import Service
|
||||||
@@ -283,11 +283,16 @@ def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) ->
|
|||||||
|
|
||||||
async def build_library(config: Config) -> MusicLibrary:
|
async def build_library(config: Config) -> MusicLibrary:
|
||||||
library_config = config.general.library
|
library_config = config.general.library
|
||||||
|
workers = library_config.analysis_workers
|
||||||
return await MusicLibrary.build(
|
return await MusicLibrary.build(
|
||||||
library_config.root,
|
library_config.root,
|
||||||
library_config.cache,
|
library_config.cache,
|
||||||
frozenset(config.general.audio_extensions),
|
frozenset(config.general.audio_extensions),
|
||||||
analyzer=build_analyzer(),
|
analyzer=build_analyzer(),
|
||||||
|
# Unset in the config means "use the machine": a first pass over an unanalyzed
|
||||||
|
# library is hours of DSP, and there is no reason for a desktop to do it one
|
||||||
|
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
|
||||||
|
analysis_workers=default_worker_count() if workers is None else workers,
|
||||||
figure_kinds=config.figure_kinds,
|
figure_kinds=config.figure_kinds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,7 +324,10 @@ def _build_app(
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
|
T = TypeVar("T", bound=Service)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_of_type(services: list[Service], kind: type[T]) -> T | None:
|
||||||
return next((s for s in services if isinstance(s, kind)), None)
|
return next((s for s in services if isinstance(s, kind)), None)
|
||||||
|
|
||||||
|
|
||||||
@@ -373,6 +381,7 @@ def _build_services(
|
|||||||
on_change=lambda: app.rescan_library(
|
on_change=lambda: app.rescan_library(
|
||||||
broadcast=web_service.hub.broadcast_library if web_service else None
|
broadcast=web_service.hub.broadcast_library if web_service else None
|
||||||
),
|
),
|
||||||
|
episode_limit=app.config.general.podcast_episode_limit,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import contextlib
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import Any
|
from typing import Any, TypeAlias, TypeVar
|
||||||
|
|
||||||
from musicmouse.events import Event
|
from musicmouse.events import Event
|
||||||
|
|
||||||
@@ -24,8 +24,12 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
||||||
|
|
||||||
type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None]
|
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
|
||||||
type Unsubscribe = Callable[[], None]
|
#: parameterises it the way the PEP 695 form did.
|
||||||
|
E = TypeVar("E", bound=Event)
|
||||||
|
|
||||||
|
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
|
||||||
|
Unsubscribe: TypeAlias = Callable[[], None]
|
||||||
|
|
||||||
|
|
||||||
class EventBus:
|
class EventBus:
|
||||||
@@ -63,7 +67,7 @@ class EventBus:
|
|||||||
|
|
||||||
# --------------------------------------------------------------- subscription
|
# --------------------------------------------------------------- subscription
|
||||||
|
|
||||||
def subscribe[E: Event](self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
|
def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
|
||||||
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
||||||
self._handlers.setdefault(event_type, []).append(handler)
|
self._handlers.setdefault(event_type, []).append(handler)
|
||||||
self._resolved.clear()
|
self._resolved.clear()
|
||||||
|
|||||||
@@ -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, Final, Literal, Self
|
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
@@ -26,6 +26,7 @@ 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.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
||||||
type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
||||||
|
|
||||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||||
|
|
||||||
@@ -172,6 +173,11 @@ class LibraryConfig(_Strict):
|
|||||||
root: Path
|
root: Path
|
||||||
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
||||||
cache: Path = Path(".musicmouse-cache")
|
cache: Path = Path(".musicmouse-cache")
|
||||||
|
#: How many tracks background analysis may work on at once, each in its own worker
|
||||||
|
#: process. Omit for one per core bar one (see
|
||||||
|
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
|
||||||
|
#: analysis to a single process on a machine that has other work to do.
|
||||||
|
analysis_workers: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
@field_validator("root")
|
@field_validator("root")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -283,6 +289,14 @@ class GeneralConfig(_Strict):
|
|||||||
|
|
||||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
|
#: How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
#: published for years grows without bound and will eventually fill the device's SD
|
||||||
|
#: card. ``null`` keeps every episode, and minding the free space is then on you.
|
||||||
|
#:
|
||||||
|
#: Lowering this *deletes* the episodes that fall outside the window on the next
|
||||||
|
#: poll, and an episode that has aged out of its feed cannot be fetched again.
|
||||||
|
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serial_simulated(self) -> bool:
|
def serial_simulated(self) -> bool:
|
||||||
return self.serial_port == SIMULATE
|
return self.serial_port == SIMULATE
|
||||||
|
|||||||
@@ -15,7 +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 typing import TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import (
|
from musicmouse.effects import (
|
||||||
EffectAlexaSwipeConfig,
|
EffectAlexaSwipeConfig,
|
||||||
@@ -162,7 +162,7 @@ class FirmwareLog:
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
type Decoded = InputEvent | FirmwareLog
|
Decoded: TypeAlias = InputEvent | FirmwareLog
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------- encoding
|
# ------------------------------------------------------------------------- encoding
|
||||||
@@ -367,12 +367,11 @@ 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}"
|
||||||
|
|
||||||
|
|
||||||
type HostCommand = SetEffect | SetButtonBrightness
|
HostCommand: TypeAlias = SetEffect | SetButtonBrightness
|
||||||
|
|
||||||
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
|
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
|
||||||
message: (zone, effect_cls)
|
message: (zone, effect_cls)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal, TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import LedEffect
|
from musicmouse.effects import LedEffect
|
||||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||||
@@ -53,7 +53,7 @@ __all__ = [
|
|||||||
"VolumeChanged",
|
"VolumeChanged",
|
||||||
]
|
]
|
||||||
|
|
||||||
type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
|
EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ plain immutable data.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Awaitable, Callable, Collection, Mapping
|
from collections.abc import Awaitable, Callable, Collection, Mapping
|
||||||
|
from concurrent.futures import BrokenExecutor, Executor
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from musicmouse.library.analysis import (
|
from musicmouse.library.analysis import (
|
||||||
ANALYZER_VERSION,
|
ANALYZER_VERSION,
|
||||||
@@ -29,6 +29,7 @@ from musicmouse.library.cache import Fingerprint, LibraryCache
|
|||||||
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
|
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
|
||||||
from musicmouse.library.scanner import scan_library
|
from musicmouse.library.scanner import scan_library
|
||||||
from musicmouse.library.sections import SECTIONS, AlbumKind
|
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||||
|
from musicmouse.library.workers import analysis_pool, analyze_one, default_worker_count
|
||||||
from musicmouse.media import Playlist
|
from musicmouse.media import Playlist
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -45,6 +46,7 @@ __all__ = [
|
|||||||
"NullAnalyzer",
|
"NullAnalyzer",
|
||||||
"TrackCurves",
|
"TrackCurves",
|
||||||
"album_id",
|
"album_id",
|
||||||
|
"default_worker_count",
|
||||||
"track_key",
|
"track_key",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,19 +71,12 @@ _PUBLISH_BATCH_SIZE: Final = 25
|
|||||||
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
|
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
|
||||||
|
|
||||||
|
|
||||||
def _analyze_one(
|
def _discard(future: asyncio.Future[Any]) -> None:
|
||||||
analyzer: Analyzer, path: Path
|
"""Drop a result nobody is going to read, without leaving a warning behind."""
|
||||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
if not future.done():
|
||||||
"""Runs in a worker thread. Lowers this thread's own scheduling priority first.
|
future.cancel()
|
||||||
|
elif not future.cancelled():
|
||||||
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so
|
future.exception()
|
||||||
this makes idle-time analysis yield CPU to anything else without touching threads
|
|
||||||
used for other work. Niceness only ever increases and clamps at the OS maximum
|
|
||||||
(19), so calling this repeatedly on a reused pool thread is harmless.
|
|
||||||
"""
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.nice(1)
|
|
||||||
return analyzer.analyze(path)
|
|
||||||
|
|
||||||
|
|
||||||
class MusicLibrary:
|
class MusicLibrary:
|
||||||
@@ -94,12 +89,19 @@ class MusicLibrary:
|
|||||||
extensions: frozenset[str],
|
extensions: frozenset[str],
|
||||||
*,
|
*,
|
||||||
analyzer: Analyzer | None = None,
|
analyzer: Analyzer | None = None,
|
||||||
|
analysis_workers: int = 1,
|
||||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.root = root
|
self.root = root
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.extensions = extensions
|
self.extensions = extensions
|
||||||
self.analyzer: Analyzer = analyzer or NullAnalyzer()
|
self.analyzer: Analyzer = analyzer or NullAnalyzer()
|
||||||
|
#: How many tracks background analysis may work on at once. The default of 1
|
||||||
|
#: keeps the analyzer in this process, where an analyzer that holds state
|
||||||
|
#: still behaves as written; the app passes `default_worker_count()`, which is
|
||||||
|
#: what makes a first-time pass finish in hours rather than days on a desktop.
|
||||||
|
#: See `musicmouse.library.workers`.
|
||||||
|
self.analysis_workers = analysis_workers
|
||||||
#: What each figure holds. The only thing a folder name cannot say.
|
#: What each figure holds. The only thing a folder name cannot say.
|
||||||
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
|
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
|
||||||
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
|
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
@@ -146,9 +148,7 @@ class MusicLibrary:
|
|||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
return max(
|
return max(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
|
||||||
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
||||||
album = self.get(identifier)
|
album = self.get(identifier)
|
||||||
@@ -219,10 +219,18 @@ class MusicLibrary:
|
|||||||
extensions: frozenset[str],
|
extensions: frozenset[str],
|
||||||
*,
|
*,
|
||||||
analyzer: Analyzer | None = None,
|
analyzer: Analyzer | None = None,
|
||||||
|
analysis_workers: int = 1,
|
||||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
) -> MusicLibrary:
|
) -> MusicLibrary:
|
||||||
cache = LibraryCache(cache_dir)
|
cache = LibraryCache(cache_dir)
|
||||||
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds)
|
library = cls(
|
||||||
|
root,
|
||||||
|
cache,
|
||||||
|
extensions,
|
||||||
|
analyzer=analyzer,
|
||||||
|
analysis_workers=analysis_workers,
|
||||||
|
figure_kinds=figure_kinds,
|
||||||
|
)
|
||||||
library._entries = await asyncio.to_thread(cache.load_index)
|
library._entries = await asyncio.to_thread(cache.load_index)
|
||||||
await library.refresh()
|
await library.refresh()
|
||||||
return library
|
return library
|
||||||
@@ -261,64 +269,145 @@ class MusicLibrary:
|
|||||||
is_busy: Callable[[], bool] = lambda: False,
|
is_busy: Callable[[], bool] = lambda: False,
|
||||||
on_batch: Callable[[], Awaitable[None]] | None = None,
|
on_batch: Callable[[], Awaitable[None]] | None = None,
|
||||||
batch_size: int = _PUBLISH_BATCH_SIZE,
|
batch_size: int = _PUBLISH_BATCH_SIZE,
|
||||||
|
workers: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run the analyzer over tracks of `kinds` that have no current result.
|
"""Run the analyzer over tracks of `kinds` that have no current result.
|
||||||
|
|
||||||
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every
|
Restricted to music by default - see `_ANALYZED_KINDS`. Up to `workers` tracks
|
||||||
track, `is_busy()` pauses the whole pass rather than one file: analysis must
|
are analyzed at once (`self.analysis_workers` when not given), each in its own
|
||||||
never compete with audio decoding for CPU, and a children's player is idle most
|
worker process - see `musicmouse.library.workers` for why processes and how the
|
||||||
of the day, so the pass simply resumes next time it is. A track the analyzer
|
machine is kept usable while they run. Checked before every track is handed out,
|
||||||
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with
|
`is_busy()` pauses the whole pass rather than one file: analysis must never
|
||||||
every scalar left `None` - so it is never retried forever and the frontend falls
|
compete with audio decoding for CPU, and a children's player is idle most of the
|
||||||
back to the un-analyzed baseline for it. Results are folded into the live index
|
day, so the pass simply resumes next time it is. Tracks already in flight when
|
||||||
and persisted every `batch_size` tracks, so a long first run is visible in open
|
it goes busy are allowed to finish - their results are already paid for - and
|
||||||
browser tabs as it goes rather than only once it finishes.
|
the workers are then shut down for the duration rather than sitting idle with
|
||||||
|
a librosa apiece resident on a machine that is now playing music.
|
||||||
|
|
||||||
|
A track the analyzer fails on (corrupt file, DRM, zero length) is still recorded
|
||||||
|
as attempted - with every scalar left `None` - so it is never retried forever and
|
||||||
|
the frontend falls back to the un-analyzed baseline for it. A worker *process*
|
||||||
|
dying, though, says nothing about the track it was on, so that ends the pass
|
||||||
|
without recording anything: the next one picks the same tracks up again.
|
||||||
|
|
||||||
|
Results are folded into the live index and persisted every `batch_size` tracks,
|
||||||
|
so a long first run is visible in open browser tabs as it goes rather than only
|
||||||
|
once it finishes.
|
||||||
"""
|
"""
|
||||||
analyzer = self.analyzer
|
analyzer = self.analyzer
|
||||||
if analyzer.version < ANALYZER_VERSION:
|
if analyzer.version < ANALYZER_VERSION:
|
||||||
return 0
|
return 0
|
||||||
|
pending = deque(self._pending_tracks(kinds, analyzer.version))
|
||||||
|
if not pending:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
workers = self.analysis_workers if workers is None else max(1, workers)
|
||||||
|
total = len(pending)
|
||||||
|
_log.info("Analyzing %d tracks with %d worker(s)", total, workers)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
in_flight: dict[asyncio.Future[Any], tuple[str, Path]] = {}
|
||||||
done = 0
|
done = 0
|
||||||
last_report = time.monotonic()
|
last_report = time.monotonic()
|
||||||
for album in self.albums:
|
try:
|
||||||
if album.kind not in kinds:
|
while pending:
|
||||||
continue
|
|
||||||
for track in album.tracks:
|
|
||||||
while is_busy():
|
while is_busy():
|
||||||
|
# Waited out *between* pools, so a half-hour album is not played
|
||||||
|
# with a houseful of idle worker processes holding onto librosa.
|
||||||
await asyncio.sleep(_BUSY_POLL_SECONDS)
|
await asyncio.sleep(_BUSY_POLL_SECONDS)
|
||||||
key = track_key(track.path)
|
with analysis_pool(workers) as pool:
|
||||||
cached = self.cache.load_analysis(key)
|
try:
|
||||||
if cached is not None and cached.version >= analyzer.version:
|
while in_flight or (pending and not is_busy()):
|
||||||
continue
|
while pending and len(in_flight) < workers and not is_busy():
|
||||||
now = time.monotonic()
|
key, path = pending.popleft()
|
||||||
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
in_flight[self._submit(pool, loop, path)] = (key, path)
|
||||||
_log.info("Analyzing library: %d tracks done so far, now on %s", done, track.path)
|
if not in_flight:
|
||||||
last_report = now
|
break # gone busy: drop the pool and wait above
|
||||||
try:
|
finished, _ = await asyncio.wait(
|
||||||
analysis, grid, curve = await asyncio.to_thread(
|
in_flight, return_when=asyncio.FIRST_COMPLETED
|
||||||
_analyze_one, analyzer, track.path
|
)
|
||||||
)
|
for future in finished:
|
||||||
except Exception:
|
key, path = in_flight.pop(future)
|
||||||
_log.warning(
|
self._store_result(key, path, future)
|
||||||
"Analyzer raised on %s; marking it attempted so it is not retried forever",
|
done += 1
|
||||||
track.path,
|
if done % batch_size == 0:
|
||||||
exc_info=True,
|
await self._publish_analysis(kinds, on_batch)
|
||||||
)
|
now = time.monotonic()
|
||||||
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None
|
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
||||||
if grid is not None:
|
_log.info("Analyzing library: %d/%d tracks done", done, total)
|
||||||
self.cache.store_beats(key, grid)
|
last_report = now
|
||||||
if curve is not None:
|
finally:
|
||||||
self.cache.store_curve(key, curve)
|
# Nothing is left to read these - without this, a pass that ends
|
||||||
self.cache.store_analysis(key, analysis)
|
# early (cancelled at shutdown, or a dead pool) leaves "exception
|
||||||
done += 1
|
# was never retrieved" behind for every track still in flight.
|
||||||
if done % batch_size == 0:
|
for future in in_flight:
|
||||||
await self._publish_analysis(kinds, on_batch)
|
_discard(future)
|
||||||
|
in_flight.clear()
|
||||||
|
except BrokenExecutor:
|
||||||
|
_log.error(
|
||||||
|
"An analysis worker process died (out of memory?) after %d of %d tracks; "
|
||||||
|
"stopping this pass. The rest are retried on the next one.",
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
)
|
||||||
if done % batch_size:
|
if done % batch_size:
|
||||||
await self._publish_analysis(kinds, on_batch)
|
await self._publish_analysis(kinds, on_batch)
|
||||||
if done:
|
if done:
|
||||||
_log.info("Analyzed %d tracks", done)
|
_log.info("Analyzed %d tracks", done)
|
||||||
return done
|
return done
|
||||||
|
|
||||||
|
def _pending_tracks(self, kinds: Collection[AlbumKind], version: int) -> list[tuple[str, Path]]:
|
||||||
|
"""The whole pass's worklist, as (cache key, path), worked out up front.
|
||||||
|
|
||||||
|
Up front rather than per track, because a pool has to have the next file ready
|
||||||
|
the moment a worker frees up, and because it is what lets the progress line say
|
||||||
|
"37/412". Deduplicated by cache key: the same file can sit in two albums, and
|
||||||
|
two workers analyzing it at once would be pure waste.
|
||||||
|
"""
|
||||||
|
worklist: list[tuple[str, Path]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for album in self.albums:
|
||||||
|
if album.kind not in kinds:
|
||||||
|
continue
|
||||||
|
for track in album.tracks:
|
||||||
|
key = track_key(track.path)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
cached = self.cache.load_analysis(key)
|
||||||
|
if cached is not None and cached.version >= version:
|
||||||
|
continue
|
||||||
|
worklist.append((key, track.path))
|
||||||
|
return worklist
|
||||||
|
|
||||||
|
def _submit(
|
||||||
|
self, pool: Executor | None, loop: asyncio.AbstractEventLoop, path: Path
|
||||||
|
) -> asyncio.Future[Any]:
|
||||||
|
"""Start one track, in a worker process or - with no pool - on a thread here."""
|
||||||
|
if pool is None:
|
||||||
|
return asyncio.ensure_future(asyncio.to_thread(analyze_one, self.analyzer, path))
|
||||||
|
return loop.run_in_executor(pool, analyze_one, self.analyzer, path)
|
||||||
|
|
||||||
|
def _store_result(self, key: str, path: Path, future: asyncio.Future[Any]) -> None:
|
||||||
|
"""Persist one finished track. Re-raises only what ends the whole pass."""
|
||||||
|
error = future.exception()
|
||||||
|
if isinstance(error, BrokenExecutor):
|
||||||
|
raise error
|
||||||
|
if error is not None:
|
||||||
|
_log.warning(
|
||||||
|
"Analyzer raised on %s; marking it attempted so it is not retried forever",
|
||||||
|
path,
|
||||||
|
exc_info=error,
|
||||||
|
)
|
||||||
|
analysis, grid, curve = TrackAnalysis(version=self.analyzer.version), None, None
|
||||||
|
else:
|
||||||
|
analysis, grid, curve = future.result()
|
||||||
|
if grid is not None:
|
||||||
|
self.cache.store_beats(key, grid)
|
||||||
|
if curve is not None:
|
||||||
|
self.cache.store_curve(key, curve)
|
||||||
|
self.cache.store_analysis(key, analysis)
|
||||||
|
|
||||||
async def _publish_analysis(
|
async def _publish_analysis(
|
||||||
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
|
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ different amounts to produce::
|
|||||||
|
|
||||||
<cache_dir>/
|
<cache_dir>/
|
||||||
├── index.json cheap: tags and structure. Thrown away freely.
|
├── index.json cheap: tags and structure. Thrown away freely.
|
||||||
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame
|
├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
|
||||||
|
│ frame, or copied from a cover.jpg in the
|
||||||
|
│ folder - downscaled to MAX_COVER_PX on
|
||||||
|
│ the way in. An album's `cover` always
|
||||||
|
│ points here and never into the library,
|
||||||
|
│ so nothing can serve full-size art by
|
||||||
|
│ accident.
|
||||||
└── analysis/<track_key>.json expensive: minutes of DSP per track
|
└── analysis/<track_key>.json expensive: minutes of DSP per track
|
||||||
analysis/<track_key>.beats.json
|
analysis/<track_key>.beats.json
|
||||||
analysis/<track_key>.curve.json
|
analysis/<track_key>.curve.json
|
||||||
@@ -18,12 +24,15 @@ folder or re-sorting a section then costs nothing.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, Final, cast
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
|
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
|
||||||
from musicmouse.library.models import Album, LibraryTrack
|
from musicmouse.library.models import Album, LibraryTrack
|
||||||
@@ -37,7 +46,16 @@ __all__ = ["Fingerprint", "LibraryCache"]
|
|||||||
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
|
#: 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
|
#: files are untouched, so otherwise a change to that logic is invisible until somebody
|
||||||
#: edits their music folder.
|
#: edits their music folder.
|
||||||
_INDEX_VERSION = 4
|
# 5: every album's `cover` now points into this cache, downscaled, including the ones
|
||||||
|
# that used to point straight at a `cover.jpg` in the library folder.
|
||||||
|
# 6: MAX_COVER_PX dropped from 640 to 384. This is what applies it. `shrink_cover` never
|
||||||
|
# scales art *up*, so a cover already written at some size is only ever rewritten
|
||||||
|
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
|
||||||
|
# re-reading them, only by going back to the original art. Discarding the index does
|
||||||
|
# exactly that: every album is scanned again and `store_cover` runs on the real art.
|
||||||
|
#
|
||||||
|
# Bumping this is cheap by design - see the module docstring.
|
||||||
|
_INDEX_VERSION = 6
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -86,9 +104,37 @@ class LibraryCache:
|
|||||||
|
|
||||||
def store_cover(self, album_id: str, data: bytes) -> Path:
|
def store_cover(self, album_id: str, data: bytes) -> Path:
|
||||||
path = self.cover_path(album_id)
|
path = self.cover_path(album_id)
|
||||||
path.write_bytes(data)
|
path.write_bytes(shrink_cover(data))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def shrink_stored_covers(self) -> int:
|
||||||
|
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
|
||||||
|
|
||||||
|
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
|
||||||
|
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
|
||||||
|
by an older version would otherwise never be touched again. Reading a JPEG's
|
||||||
|
dimensions only parses its header, so once every file is within the limit this
|
||||||
|
costs one small read per album and nothing else.
|
||||||
|
|
||||||
|
Returns the number of files actually rewritten.
|
||||||
|
"""
|
||||||
|
rewritten = 0
|
||||||
|
for path in sorted(self.covers.glob("*.jpg")):
|
||||||
|
try:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
oversized = max(image.size) > MAX_COVER_PX
|
||||||
|
except (OSError, UnidentifiedImageError):
|
||||||
|
continue
|
||||||
|
if not oversized:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shrunk = shrink_cover(path.read_bytes())
|
||||||
|
path.write_bytes(shrunk)
|
||||||
|
except OSError: # pragma: no cover - a cache we cannot write is not fatal
|
||||||
|
continue
|
||||||
|
rewritten += 1
|
||||||
|
return rewritten
|
||||||
|
|
||||||
# ------------------------------------------------------------------ analysis
|
# ------------------------------------------------------------------ analysis
|
||||||
|
|
||||||
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
||||||
@@ -213,3 +259,54 @@ def _album_from_json(data: dict[str, Any]) -> Album:
|
|||||||
for track in data["tracks"]
|
for track in data["tracks"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- covers
|
||||||
|
|
||||||
|
#: Longest edge kept for cached album art, in pixels.
|
||||||
|
#:
|
||||||
|
#: The art that comes out of an ID3 APIC frame is sized for a record sleeve, not for a
|
||||||
|
#: screen: a real library here averaged 3000x3000 and 580 kB per cover, 140 MB for 284
|
||||||
|
#: albums. The browser was decoding nine megapixels - about 36 MB of bitmap - for every
|
||||||
|
#: cover it painted, to show it in a 185 px card on a Pi with 2 GB of RAM.
|
||||||
|
#:
|
||||||
|
#: 340 is the largest any of this app's screens asks for (the play view; the browse grid
|
||||||
|
#: asks for 180 and the player bar for 56), so this only has to beat 340 to be lossless
|
||||||
|
#: where it shows.
|
||||||
|
#:
|
||||||
|
#: It was 640 first, for headroom on a tablet at devicePixelRatio 2, and that headroom
|
||||||
|
#: turned out to be expensive on the device that actually runs this. Measured on
|
||||||
|
#: musicdolphin, typing "conni" over a 343-album library, keydown to painted, worst
|
||||||
|
#: keystroke of three runs: 3.4-9.1 s at 640 px against 0.2 s at 384 px. Decode cost
|
||||||
|
#: goes with pixel count, and a browse grid paints a card 132 px wide.
|
||||||
|
#:
|
||||||
|
#: A tablet at devicePixelRatio 2 therefore gets a slightly soft cover on the play view
|
||||||
|
#: and nowhere else. That is the trade: a visible sharpness margin nobody asked for, for
|
||||||
|
#: a search box that answers a keystroke in a frame.
|
||||||
|
MAX_COVER_PX: Final = 384
|
||||||
|
|
||||||
|
#: Re-encode quality. At these dimensions the difference from 95 is invisible and the
|
||||||
|
#: file is a third of the size.
|
||||||
|
_COVER_JPEG_QUALITY: Final = 85
|
||||||
|
|
||||||
|
|
||||||
|
def shrink_cover(data: bytes) -> bytes:
|
||||||
|
"""Downscale cover art to :data:`MAX_COVER_PX` on its longest edge.
|
||||||
|
|
||||||
|
Art that is already small enough is returned untouched rather than re-encoded, so
|
||||||
|
repeated scans never degrade it. Anything Pillow cannot read is passed through
|
||||||
|
unchanged: a cover that is too big is a performance problem, a cover that is missing
|
||||||
|
is a visible one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(data)) as image:
|
||||||
|
if max(image.size) <= MAX_COVER_PX:
|
||||||
|
return data
|
||||||
|
# `thumbnail` keeps the aspect ratio and never scales up.
|
||||||
|
image = image.convert("RGB")
|
||||||
|
image.thumbnail((MAX_COVER_PX, MAX_COVER_PX), Image.Resampling.LANCZOS)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(buffer, format="JPEG", quality=_COVER_JPEG_QUALITY, optimize=True)
|
||||||
|
return buffer.getvalue()
|
||||||
|
except (OSError, UnidentifiedImageError, ValueError):
|
||||||
|
return data
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
from musicmouse.library.analysis import TrackAnalysis
|
from musicmouse.library.analysis import TrackAnalysis
|
||||||
from musicmouse.library.sections import AlbumKind
|
from musicmouse.library.sections import AlbumKind
|
||||||
@@ -15,7 +16,7 @@ __all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
|
|||||||
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
||||||
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
||||||
#: parsing and the frontend gets CSS colours for free.
|
#: parsing and the frontend gets CSS colours for free.
|
||||||
type AlbumColors = tuple[str, str, str]
|
AlbumColors: TypeAlias = tuple[str, str, str]
|
||||||
|
|
||||||
|
|
||||||
def album_id(root: Path, folder: Path) -> str:
|
def album_id(root: Path, folder: Path) -> str:
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ a feed offers real per-episode artwork, it's saved alongside as a same-named sid
|
|||||||
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
||||||
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
||||||
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
||||||
|
A show folder is also kept to a fixed number of the newest episodes
|
||||||
|
(:data:`DEFAULT_EPISODE_LIMIT`), so a long-running feed cannot fill the device's disk.
|
||||||
|
The same limit caps what is downloaded, which is what stops the two halves fighting:
|
||||||
|
prune what is older than the newest N, download only the newest N, and the folder
|
||||||
|
settles instead of re-fetching every episode it just deleted.
|
||||||
|
|
||||||
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
||||||
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
||||||
"""
|
"""
|
||||||
@@ -37,6 +43,7 @@ from musicmouse.library.sections import SECTIONS
|
|||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DEFAULT_EPISODE_LIMIT",
|
||||||
"FEED_MARKER_NAME",
|
"FEED_MARKER_NAME",
|
||||||
"AudioExtractionError",
|
"AudioExtractionError",
|
||||||
"Episode",
|
"Episode",
|
||||||
@@ -46,7 +53,9 @@ __all__ = [
|
|||||||
"episode_filename",
|
"episode_filename",
|
||||||
"find_feed_shows",
|
"find_feed_shows",
|
||||||
"missing_episodes",
|
"missing_episodes",
|
||||||
|
"newest_episodes",
|
||||||
"parse_feed",
|
"parse_feed",
|
||||||
|
"prune_show",
|
||||||
"resolve_episode_cover",
|
"resolve_episode_cover",
|
||||||
"sync_all_shows",
|
"sync_all_shows",
|
||||||
"sync_show",
|
"sync_show",
|
||||||
@@ -54,6 +63,15 @@ __all__ = [
|
|||||||
|
|
||||||
FEED_MARKER_NAME: Final = "feed.txt"
|
FEED_MARKER_NAME: Final = "feed.txt"
|
||||||
|
|
||||||
|
#: How many episodes of one show to keep, newest first. A show that has published for
|
||||||
|
#: years is otherwise unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, which
|
||||||
|
#: does not fit next to the rest of the library on a Pi's SD card.
|
||||||
|
#:
|
||||||
|
#: This caps downloads as well as deletions, and it has to be one number for both. Prune
|
||||||
|
#: to the newest N but download everything the feed offers, and every poll would
|
||||||
|
#: re-fetch the episodes the last one deleted, forever.
|
||||||
|
DEFAULT_EPISODE_LIMIT: Final = 50
|
||||||
|
|
||||||
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
||||||
#: not a real extension (tracking-redirect URLs are common in the wild).
|
#: not a real extension (tracking-redirect URLs are common in the wild).
|
||||||
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
||||||
@@ -287,6 +305,80 @@ def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episod
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def newest_episodes(episodes: list[Episode], keep: int | None) -> list[Episode]:
|
||||||
|
"""The ``keep`` most recently published episodes, newest first.
|
||||||
|
|
||||||
|
Applied to the *feed* before anything is downloaded. Without it, capping the folder
|
||||||
|
would be pointless: the next poll would see every pruned episode as missing again.
|
||||||
|
"""
|
||||||
|
ordered = sorted(episodes, key=lambda episode: episode.published, reverse=True)
|
||||||
|
return ordered if keep is None else ordered[:keep]
|
||||||
|
|
||||||
|
|
||||||
|
def _episode_date(name: str) -> datetime | None:
|
||||||
|
"""The date out of a ``YYYYMMDD - Title.ext`` filename, or ``None`` if it has none.
|
||||||
|
|
||||||
|
Deliberately strict. A file that does not follow the convention is one this module
|
||||||
|
did not write - something dropped in by hand under another name - and it is left
|
||||||
|
alone rather than guessed at, because the alternative is deleting somebody's file
|
||||||
|
on a parse that happened to fail.
|
||||||
|
"""
|
||||||
|
stem = Path(name).stem
|
||||||
|
if len(stem) < 8 or not stem[:8].isdigit():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(stem[:8], "%Y%m%d").replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None = None) -> int:
|
||||||
|
"""Delete all but the ``keep`` newest episodes in ``folder``. Returns how many went.
|
||||||
|
|
||||||
|
Only touches files named the way :func:`episode_filename` names them, so a
|
||||||
|
``feed.txt``, a ``folder.jpg``, the failed-download record and any hand-named file
|
||||||
|
are all safe. An episode's sidecar cover image goes with it - it shares the stem,
|
||||||
|
and leaving it behind would strand art for a track that no longer exists.
|
||||||
|
|
||||||
|
``keep`` of ``None`` disables pruning entirely.
|
||||||
|
"""
|
||||||
|
if keep is None:
|
||||||
|
return 0
|
||||||
|
audio_extensions = extensions if extensions is not None else _KNOWN_EXTENSIONS
|
||||||
|
|
||||||
|
dated: list[tuple[datetime, Path]] = []
|
||||||
|
for path in folder.iterdir():
|
||||||
|
if not path.is_file() or path.suffix.lower() not in audio_extensions:
|
||||||
|
continue
|
||||||
|
published = _episode_date(path.name)
|
||||||
|
if published is not None:
|
||||||
|
dated.append((published, path))
|
||||||
|
|
||||||
|
if len(dated) <= keep:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
dated.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
||||||
|
removed = 0
|
||||||
|
for _published, path in dated[keep:]:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
_log.warning("Could not delete old episode %s: %s", path, exc)
|
||||||
|
continue
|
||||||
|
removed += 1
|
||||||
|
for image_extension in _IMAGE_EXTENSIONS:
|
||||||
|
sidecar = path.with_suffix(image_extension)
|
||||||
|
try:
|
||||||
|
sidecar.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
_log.debug("Could not delete cover %s: %s", sidecar, exc)
|
||||||
|
if removed:
|
||||||
|
_log.info(
|
||||||
|
"Pruned %d old episode(s) from %s, keeping the newest %d", removed, folder.name, keep
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
||||||
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
||||||
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
||||||
@@ -437,14 +529,28 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
|
|||||||
return _extract_og_image(response.text)
|
return _extract_og_image(response.text)
|
||||||
|
|
||||||
|
|
||||||
async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool:
|
async def sync_show(
|
||||||
"""Download every episode in ``feed_url`` that ``folder`` doesn't have yet.
|
client: httpx2.AsyncClient,
|
||||||
|
folder: Path,
|
||||||
|
feed_url: str,
|
||||||
|
*,
|
||||||
|
keep: int | None = DEFAULT_EPISODE_LIMIT,
|
||||||
|
) -> bool:
|
||||||
|
"""Bring ``folder`` up to date with the newest ``keep`` episodes of ``feed_url``.
|
||||||
|
|
||||||
Returns whether anything changed. Errors - an unreachable feed, a malformed one, a
|
Downloads what is missing from that window and deletes what has fallen out of it.
|
||||||
single broken enclosure - are logged and swallowed here so one bad show never stops
|
Both halves use the same ``keep``, which is what makes the folder settle - see
|
||||||
the others or takes down the poll loop. An episode that fails is remembered and left
|
:data:`DEFAULT_EPISODE_LIMIT`.
|
||||||
alone for :data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead
|
|
||||||
enclosure doesn't get hammered on every poll.
|
Returns whether anything changed, deletions included: a pruned folder needs a
|
||||||
|
rescan just as much as a downloaded episode does, or the library keeps offering
|
||||||
|
tracks whose files are gone.
|
||||||
|
|
||||||
|
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
||||||
|
logged and swallowed here so one bad show never stops the others or takes down the
|
||||||
|
poll loop. An episode that fails is remembered and left alone for
|
||||||
|
:data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead enclosure
|
||||||
|
doesn't get hammered on every poll.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||||
@@ -453,7 +559,7 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
|
|||||||
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pending = missing_episodes(folder, parse_feed(response.content))
|
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
|
||||||
failed = _load_failed_downloads(folder)
|
failed = _load_failed_downloads(folder)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
changed = False
|
changed = False
|
||||||
@@ -490,13 +596,20 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
|
|||||||
pending_filenames = {filename for _episode, filename in pending}
|
pending_filenames = {filename for _episode, filename in pending}
|
||||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
||||||
_save_failed_downloads(folder, failed)
|
_save_failed_downloads(folder, failed)
|
||||||
|
|
||||||
|
# After downloading, not before: an episode that just arrived is one of the newest
|
||||||
|
# and must be counted when deciding what falls off the end.
|
||||||
|
if prune_show(folder, keep):
|
||||||
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = DEFAULT_EPISODE_LIMIT
|
||||||
|
) -> bool:
|
||||||
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
||||||
changed = False
|
changed = False
|
||||||
for _section_name, folder, feed_url in find_feed_shows(root):
|
for _section_name, folder, feed_url in find_feed_shows(root):
|
||||||
if await sync_show(client, folder, feed_url):
|
if await sync_show(client, folder, feed_url, keep=keep):
|
||||||
changed = True
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|||||||
@@ -119,10 +119,20 @@ def _most_common(values: list[str]) -> str:
|
|||||||
def _cover_for(
|
def _cover_for(
|
||||||
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
|
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
|
||||||
) -> tuple[Path | None, bytes | None]:
|
) -> tuple[Path | None, bytes | None]:
|
||||||
|
"""The album's art, as a path *into the cache* plus the original bytes.
|
||||||
|
|
||||||
|
Every branch goes through :meth:`LibraryCache.store_cover`, including the one that
|
||||||
|
finds a ``cover.jpg`` already sitting in the folder. Returning that file directly
|
||||||
|
would be the obvious thing and was the original behaviour, and it quietly undid the
|
||||||
|
downscaling: a folder cover is whatever the internet gave it, often 1920px or more,
|
||||||
|
and the browser then decoded all of it to fill a 185px card. The bytes come back
|
||||||
|
undownscaled either way, because `colors_from_cover` wants the real art.
|
||||||
|
"""
|
||||||
for name in _COVER_NAMES:
|
for name in _COVER_NAMES:
|
||||||
candidate = folder / name
|
candidate = folder / name
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return candidate, candidate.read_bytes()
|
art = candidate.read_bytes()
|
||||||
|
return cache.store_cover(identifier, art), art
|
||||||
for path in paths[:3]:
|
for path in paths[:3]:
|
||||||
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
||||||
art = _embedded_art(path)
|
art = _embedded_art(path)
|
||||||
@@ -143,14 +153,16 @@ def _cover_for_episode(
|
|||||||
for extension in _SIDECAR_COVER_EXTENSIONS:
|
for extension in _SIDECAR_COVER_EXTENSIONS:
|
||||||
candidate = path.with_suffix(extension)
|
candidate = path.with_suffix(extension)
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return candidate, candidate.read_bytes()
|
art = candidate.read_bytes()
|
||||||
|
return cache.store_cover(identifier, art), art
|
||||||
art = _embedded_art(path)
|
art = _embedded_art(path)
|
||||||
if art is not None:
|
if art is not None:
|
||||||
return cache.store_cover(identifier, art), art
|
return cache.store_cover(identifier, art), art
|
||||||
for name in _COVER_NAMES:
|
for name in _COVER_NAMES:
|
||||||
candidate = folder / name
|
candidate = folder / name
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return candidate, candidate.read_bytes()
|
shared = candidate.read_bytes()
|
||||||
|
return cache.store_cover(identifier, shared), shared
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -295,6 +307,13 @@ def scan_library(
|
|||||||
figure name to what it holds, which is the one thing the folders cannot say.
|
figure name to what it holds, which is the one thing the folders cannot say.
|
||||||
"""
|
"""
|
||||||
cache.prepare()
|
cache.prepare()
|
||||||
|
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
|
||||||
|
# and the reuse path below means an unchanged album never rewrites its own. One
|
||||||
|
# pass here catches them; after the first run every file is already small and this
|
||||||
|
# is 300-odd header reads.
|
||||||
|
shrunk = cache.shrink_stored_covers()
|
||||||
|
if shrunk:
|
||||||
|
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
|
||||||
known = known or {}
|
known = known or {}
|
||||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
last_report = time.monotonic()
|
last_report = time.monotonic()
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ quirks are not understood yet.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Final, Literal
|
from typing import Final, Literal, TypeAlias
|
||||||
|
|
||||||
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
||||||
|
|
||||||
type AlbumKind = Literal["music", "book"]
|
AlbumKind: TypeAlias = Literal["music", "book"]
|
||||||
type TrackOrder = Literal["filename", "newest_first"]
|
TrackOrder: TypeAlias = Literal["filename", "newest_first"]
|
||||||
type TitleSource = Literal["tags", "folder"]
|
TitleSource: TypeAlias = Literal["tags", "folder"]
|
||||||
type ArtistSource = Literal["tags", "folder"]
|
ArtistSource: TypeAlias = Literal["tags", "folder"]
|
||||||
type AlbumUnit = Literal["folder", "episode"]
|
AlbumUnit: TypeAlias = Literal["folder", "episode"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
143
python-backend/musicmouse/library/workers.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Where analysis actually burns CPU, and how it is kept from taking the machine over.
|
||||||
|
|
||||||
|
Analyzing one track is a few seconds of single-threaded DSP that the GIL will not let
|
||||||
|
another Python thread overlap with - librosa's work is numba-jitted and numpy glue, not
|
||||||
|
long C calls that release it. So a library-sized pass parallelizes across *processes*:
|
||||||
|
:func:`analysis_pool` hands :meth:`~musicmouse.library.MusicLibrary.analyze_pending` an
|
||||||
|
executor whose workers are separate interpreters, and a 12-core desktop chews through a
|
||||||
|
first-time scan roughly an order of magnitude faster than the Raspberry Pi this also
|
||||||
|
has to stay polite on.
|
||||||
|
|
||||||
|
Polite means three things, all of them set here rather than at the call site:
|
||||||
|
|
||||||
|
* **One core stays free** (:func:`default_worker_count`), so the audio thread and the
|
||||||
|
web server never have to fight a full house of analyzers for a timeslice.
|
||||||
|
* **Workers run niced**, so even the cores they do own yield to playback instantly.
|
||||||
|
* **Each worker stays single-threaded** - numpy's BLAS and numba would each happily
|
||||||
|
start one thread per core *inside* every worker, and N x N threads on a 4-core Pi is
|
||||||
|
slower than N, not faster.
|
||||||
|
|
||||||
|
Whatever an analyzer returns has to survive the trip back from a worker process, which
|
||||||
|
is what keeps :class:`~musicmouse.library.analysis.TrackAnalysis` and friends plain
|
||||||
|
frozen dataclasses of floats. An analyzer that records state in its own instance -
|
||||||
|
a test double counting calls, say - only sees that state in the worker, so such an
|
||||||
|
analyzer must be run with ``workers=1``, where everything stays in this process on a
|
||||||
|
thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from concurrent.futures import Executor, ProcessPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from musicmouse.library.analysis import Analyzer, BeatGrid, TrackAnalysis, TrackCurves
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["analysis_pool", "analyze_one", "default_worker_count"]
|
||||||
|
|
||||||
|
#: Ceiling on the automatic worker count. Every worker is a fresh interpreter with its
|
||||||
|
#: own librosa, numpy and a decoded track in memory - a few hundred MB each - so on a
|
||||||
|
#: big machine the limit that bites first is RAM, not cores. An explicit
|
||||||
|
#: ``analysis_workers`` in the config overrides this; the default stays conservative.
|
||||||
|
_MAX_AUTO_WORKERS: Final = 8
|
||||||
|
|
||||||
|
#: How much worse than everything else analysis schedules. Niceness clamps at the OS
|
||||||
|
#: maximum (19) and only ever increases, so re-applying it to a reused worker is
|
||||||
|
#: harmless.
|
||||||
|
_NICENESS: Final = 5
|
||||||
|
|
||||||
|
#: Forced into every worker *before* it imports numpy, which reads these once at import
|
||||||
|
#: time. Without them each worker opens its own BLAS thread pool sized for the whole
|
||||||
|
#: machine and the pool oversubscribes every core several times over.
|
||||||
|
_SINGLE_THREADED: Final = {
|
||||||
|
"OMP_NUM_THREADS": "1",
|
||||||
|
"OPENBLAS_NUM_THREADS": "1",
|
||||||
|
"MKL_NUM_THREADS": "1",
|
||||||
|
"NUMEXPR_NUM_THREADS": "1",
|
||||||
|
"NUMBA_NUM_THREADS": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_worker_count() -> int:
|
||||||
|
"""One worker per core bar one, capped at :data:`_MAX_AUTO_WORKERS`.
|
||||||
|
|
||||||
|
The core left over is for the rest of the app: audio decoding, the web server and
|
||||||
|
the serial link all have to stay responsive while a first-time pass runs for hours.
|
||||||
|
Single-core machines get 1, which :func:`analysis_pool` turns into the in-process
|
||||||
|
path rather than a pool of one.
|
||||||
|
"""
|
||||||
|
return max(1, min(_MAX_AUTO_WORKERS, (os.cpu_count() or 1) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_one(
|
||||||
|
analyzer: Analyzer, path: Path
|
||||||
|
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||||
|
"""Analyze one file off the event loop - in a worker process, or on a thread here.
|
||||||
|
|
||||||
|
Lowers the caller's own scheduling priority first. On Linux ``os.nice`` affects only
|
||||||
|
the calling *thread*, so on the in-process path this makes idle-time analysis yield
|
||||||
|
CPU without touching threads doing other work; in a worker process there is nothing
|
||||||
|
else in the process to slow down anyway.
|
||||||
|
"""
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.nice(_NICENESS)
|
||||||
|
return analyzer.analyze(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_worker() -> None:
|
||||||
|
"""Runs once per worker process, before it imports librosa or numpy.
|
||||||
|
|
||||||
|
A spawned worker starts from a bare interpreter and pulls the analyzer in when it
|
||||||
|
unpickles its first task, so setting the thread-count variables here still lands
|
||||||
|
ahead of numpy reading them.
|
||||||
|
"""
|
||||||
|
os.environ.update(_SINGLE_THREADED)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.nice(_NICENESS)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def analysis_pool(workers: int) -> Iterator[Executor | None]:
|
||||||
|
"""A pool of `workers` analyzer processes, or ``None`` for "stay in this process".
|
||||||
|
|
||||||
|
``None`` - for ``workers <= 1``, and as the fallback when a pool cannot be started
|
||||||
|
at all - means the caller should run each track on a thread instead. That path is
|
||||||
|
what the tests and single-core devices use, and it is the only one where an
|
||||||
|
analyzer holding state in its own instance behaves as written.
|
||||||
|
|
||||||
|
Workers are *spawned*, never forked: this process has an asyncio loop, a serial
|
||||||
|
reader and libVLC's own threads running, and forking that is a well-known way to
|
||||||
|
inherit a held lock and deadlock in a child. The price is one librosa import per
|
||||||
|
worker, a few seconds paid once per pass - nothing next to the hours of DSP a
|
||||||
|
first-time pass over a real library costs.
|
||||||
|
"""
|
||||||
|
if workers <= 1:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
executor = ProcessPoolExecutor(
|
||||||
|
max_workers=workers,
|
||||||
|
mp_context=multiprocessing.get_context("spawn"),
|
||||||
|
initializer=_init_worker,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
_log.warning(
|
||||||
|
"Could not start %d analysis workers; analyzing in this process instead",
|
||||||
|
workers,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
yield executor
|
||||||
|
finally:
|
||||||
|
# `wait=False`: shutdown happens on cancellation too (the app is stopping), and
|
||||||
|
# waiting there would hold it up for however long the tracks in flight take.
|
||||||
|
executor.shutdown(wait=False, cancel_futures=True)
|
||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
|
||||||
|
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.events import Event
|
from musicmouse.events import Event
|
||||||
@@ -20,12 +20,14 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["Reaction", "on", "register_all", "registered"]
|
__all__ = ["Reaction", "on", "register_all", "registered"]
|
||||||
|
|
||||||
type Reaction[E: Event] = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
|
E = TypeVar("E", bound=Event)
|
||||||
|
|
||||||
|
Reaction: TypeAlias = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
|
||||||
|
|
||||||
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
|
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
|
||||||
|
|
||||||
|
|
||||||
def on[E: Event](event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
|
def on(event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
|
||||||
"""Register a reaction for ``event_type`` (and any subclass of it)."""
|
"""Register a reaction for ``event_type`` (and any subclass of it)."""
|
||||||
|
|
||||||
def decorator(reaction: Reaction[E]) -> Reaction[E]:
|
def decorator(reaction: Reaction[E]) -> Reaction[E]:
|
||||||
@@ -46,7 +48,7 @@ def register_all(bus: EventBus, app: App) -> None:
|
|||||||
_log.debug("Registered %d reactions", len(_REGISTRY))
|
_log.debug("Registered %d reactions", len(_REGISTRY))
|
||||||
|
|
||||||
|
|
||||||
def _bind[E: Event](reaction: Reaction[E], app: App) -> Callable[[E], Any]:
|
def _bind(reaction: Reaction[E], app: App) -> Callable[[E], Any]:
|
||||||
def handler(event: E) -> Any:
|
def handler(event: E) -> Any:
|
||||||
return reaction(event, app)
|
return reaction(event, app)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from typing import Final
|
|||||||
import httpx2
|
import httpx2
|
||||||
|
|
||||||
from musicmouse.library import MusicLibrary
|
from musicmouse.library import MusicLibrary
|
||||||
from musicmouse.library.podcast_feeds import sync_all_shows
|
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT, sync_all_shows
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,11 +34,13 @@ class PodcastFeedService:
|
|||||||
client: httpx2.AsyncClient,
|
client: httpx2.AsyncClient,
|
||||||
on_change: Callable[[], Awaitable[None]],
|
on_change: Callable[[], Awaitable[None]],
|
||||||
interval: float = _CHECK_INTERVAL_SECONDS,
|
interval: float = _CHECK_INTERVAL_SECONDS,
|
||||||
|
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.library = library
|
self.library = library
|
||||||
self.client = client
|
self.client = client
|
||||||
self.on_change = on_change
|
self.on_change = on_change
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
|
self.episode_limit = episode_limit
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Checks immediately at startup, then every `interval` seconds.
|
"""Checks immediately at startup, then every `interval` seconds.
|
||||||
@@ -51,7 +53,8 @@ class PodcastFeedService:
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if await sync_all_shows(self.client, self.library.root):
|
keep = self.episode_limit
|
||||||
|
if await sync_all_shows(self.client, self.library.root, keep=keep):
|
||||||
await self.on_change()
|
await self.on_change()
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("Podcast feed check failed; will retry next interval")
|
_log.exception("Podcast feed check failed; will retry next interval")
|
||||||
|
|||||||
@@ -104,9 +104,20 @@ def build_router(
|
|||||||
raise HTTPException(status_code=404, detail="no cover")
|
raise HTTPException(status_code=404, detail="no cover")
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
album.cover,
|
album.cover,
|
||||||
# Cover files are content-addressed by album id and rewritten only by a
|
# This used to send `public, max-age=604800`, on the reasoning that a cover
|
||||||
# rescan, so a long cache is safe and saves 20 requests per page load.
|
# is "content-addressed by album id". It is not: the id addresses *which
|
||||||
headers={"Cache-Control": "public, max-age=604800"},
|
# album* the art belongs to, and the bytes behind that URL change whenever
|
||||||
|
# the art is reprocessed. When `MAX_COVER_PX` arrived and every cover was
|
||||||
|
# rewritten smaller, every browser that had loaded the page in the previous
|
||||||
|
# week went on decoding the old 3000px file out of its own disk cache, and
|
||||||
|
# a trace was the only way to see it.
|
||||||
|
#
|
||||||
|
# `no-cache` does not mean "do not store", it means "revalidate before
|
||||||
|
# reusing" - so the browser keeps the file and usually gets a 304. The cost
|
||||||
|
# is one conditional request per cover, and it is not on the paint path at
|
||||||
|
# all: the service worker answers covers from its own cache first and does
|
||||||
|
# the revalidation behind the page (see web/public/sw.js).
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/tracks/{album_id}/{index}/analysis")
|
@router.get("/tracks/{album_id}/{index}/analysis")
|
||||||
|
|||||||
@@ -372,7 +372,6 @@ class TippenSettingsIn(BaseModel):
|
|||||||
class TippenProgressOut(BaseModel):
|
class TippenProgressOut(BaseModel):
|
||||||
lessons: dict[str, TippenLessonProgressOut]
|
lessons: dict[str, TippenLessonProgressOut]
|
||||||
key_stats: dict[str, TippenKeyStatOut]
|
key_stats: dict[str, TippenKeyStatOut]
|
||||||
pearls: int
|
|
||||||
aquarium: list[str]
|
aquarium: list[str]
|
||||||
streak: TippenStreakOut
|
streak: TippenStreakOut
|
||||||
settings: TippenSettingsOut
|
settings: TippenSettingsOut
|
||||||
@@ -395,7 +394,6 @@ class TippenRunIn(BaseModel):
|
|||||||
animal: AnimalId
|
animal: AnimalId
|
||||||
points: float
|
points: float
|
||||||
passed: bool
|
passed: bool
|
||||||
pearls: int = Field(ge=0)
|
|
||||||
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ def progress_out(progress: TypingProgress) -> TippenProgressOut:
|
|||||||
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
|
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
|
||||||
for key, stat in progress.key_stats.items()
|
for key, stat in progress.key_stats.items()
|
||||||
},
|
},
|
||||||
pearls=progress.pearls,
|
|
||||||
aquarium=list(progress.aquarium),
|
aquarium=list(progress.aquarium),
|
||||||
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
|
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
|
||||||
settings=TippenSettingsOut(
|
settings=TippenSettingsOut(
|
||||||
@@ -159,7 +158,6 @@ def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
|
|||||||
animal=body.animal,
|
animal=body.animal,
|
||||||
points=body.points,
|
points=body.points,
|
||||||
passed=body.passed,
|
passed=body.passed,
|
||||||
pearls=body.pearls,
|
|
||||||
strokes=tuple(
|
strokes=tuple(
|
||||||
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
|
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.clock import Clock, FakeClock
|
from musicmouse.clock import Clock, FakeClock
|
||||||
@@ -329,9 +330,10 @@ def _int(text: str) -> int:
|
|||||||
raise ScriptError(f"{text!r} is not a whole number") from None
|
raise ScriptError(f"{text!r} is not a whole number") from None
|
||||||
|
|
||||||
|
|
||||||
def _enum_by_name[T: (Button, ButtonAction, TouchButton)](
|
_EnumT = TypeVar("_EnumT", Button, ButtonAction, TouchButton)
|
||||||
enum: type[T], name: str, what: str
|
|
||||||
) -> T:
|
|
||||||
|
def _enum_by_name(enum: type[_EnumT], name: str, what: str) -> _EnumT:
|
||||||
try:
|
try:
|
||||||
return enum[name.upper()]
|
return enum[name.upper()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final, Literal
|
from typing import Final, Literal, TypeAlias
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
@@ -37,9 +37,9 @@ __all__ = [
|
|||||||
"world_reward",
|
"world_reward",
|
||||||
]
|
]
|
||||||
|
|
||||||
type LessonKind = Literal["letters", "fragments", "words", "sentences"]
|
LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"]
|
||||||
type ModeId = Literal["dive", "bubbles", "jellyfish", "feed", "race"]
|
ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"]
|
||||||
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
||||||
|
|
||||||
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
||||||
#: allowed to grow, since each id names a drawing under ``public/aquarium/``.
|
#: allowed to grow, since each id names a drawing under ``public/aquarium/``.
|
||||||
@@ -52,9 +52,9 @@ CREATURE_IDS: Final[tuple[CreatureId, ...]] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
#: Which modes make sense for a kind - letters rounds are single keys, so only the
|
#: Which modes make sense for a kind - letters rounds are single keys, so only the
|
||||||
#: arcade modes fit; only words and sentences are long enough for a race.
|
#: arcade mode fits; only words and sentences are long enough for a race.
|
||||||
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
|
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
|
||||||
"letters": ("bubbles", "jellyfish"),
|
"letters": ("bubbles",),
|
||||||
"fragments": ("dive", "feed"),
|
"fragments": ("dive", "feed"),
|
||||||
"words": ("dive", "feed", "race"),
|
"words": ("dive", "feed", "race"),
|
||||||
"sentences": ("dive", "race"),
|
"sentences": ("dive", "race"),
|
||||||
@@ -104,6 +104,10 @@ class _YamlRoot(_Strict):
|
|||||||
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
|
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
|
||||||
|
|
||||||
seen_rewards: set[CreatureId] = set()
|
seen_rewards: set[CreatureId] = set()
|
||||||
|
# A lesson's `keys` is its spotlight, not only what is new: a round may list
|
||||||
|
# every key learned so far to drill them evenly. What must stay small is how
|
||||||
|
# much is *new* in one round - two keys, the mirrored finger pair.
|
||||||
|
seen_keys: set[str] = set()
|
||||||
for world in self.worlds:
|
for world in self.worlds:
|
||||||
if world.reward in seen_rewards:
|
if world.reward in seen_rewards:
|
||||||
problems.append(f"world {world.number} reuses reward {world.reward!r}")
|
problems.append(f"world {world.number} reuses reward {world.reward!r}")
|
||||||
@@ -121,8 +125,10 @@ class _YamlRoot(_Strict):
|
|||||||
problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
|
problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
|
||||||
if lesson.drill and lesson.keys:
|
if lesson.drill and lesson.keys:
|
||||||
problems.append(f"{where}: a drill cannot also introduce keys")
|
problems.append(f"{where}: a drill cannot also introduce keys")
|
||||||
if lesson.keys and len(lesson.keys) > 2:
|
new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys]
|
||||||
problems.append(f"{where}: at most two keys per lesson")
|
if len(new_keys) > 2:
|
||||||
|
problems.append(f"{where}: at most two new keys per lesson")
|
||||||
|
seen_keys.update(lesson.keys or ())
|
||||||
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
|
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
|
||||||
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
|
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
|
||||||
|
|
||||||
@@ -230,9 +236,6 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
|
|||||||
lessons: list[Lesson] = []
|
lessons: list[Lesson] = []
|
||||||
active: set[str] = set()
|
active: set[str] = set()
|
||||||
seen_before: set[str] = set()
|
seen_before: set[str] = set()
|
||||||
# Letters rounds alternate bubbles/jellyfish across the whole course, so the arcade
|
|
||||||
# game never repeats twice in a row even across a fragments/words lesson in between.
|
|
||||||
last_arcade: ModeId = "jellyfish" # so lesson 1 opens on bubbles
|
|
||||||
|
|
||||||
for world in worlds:
|
for world in worlds:
|
||||||
for entry in world.lessons:
|
for entry in world.lessons:
|
||||||
@@ -260,16 +263,10 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
|
|||||||
else:
|
else:
|
||||||
emphasis = "mixed"
|
emphasis = "mixed"
|
||||||
|
|
||||||
primary_mode: ModeId
|
primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive")
|
||||||
if kind == "letters":
|
|
||||||
default_arcade: ModeId = "jellyfish" if last_arcade == "bubbles" else "bubbles"
|
|
||||||
primary_mode = entry.mode or default_arcade
|
|
||||||
last_arcade = primary_mode
|
|
||||||
else:
|
|
||||||
primary_mode = entry.mode or "dive"
|
|
||||||
|
|
||||||
# Bonus replays are only ever feed/race - dive is the plain default, and
|
# Bonus replays are only ever feed/race - dive is the plain default, and
|
||||||
# bubbles/jellyfish already alternate on their own.
|
# bubbles is the only letters-round arcade mode.
|
||||||
bonus_modes = tuple(
|
bonus_modes = tuple(
|
||||||
mode
|
mode
|
||||||
for mode in ELIGIBLE_MODES[kind]
|
for mode in ELIGIBLE_MODES[kind]
|
||||||
@@ -287,7 +284,13 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
|
|||||||
subtitle=entry.subtitle,
|
subtitle=entry.subtitle,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
new_keys=new_keys,
|
new_keys=new_keys,
|
||||||
spotlight_keys=keys if kind == "letters" and not is_drill else (),
|
# A round that lists every key learned so far still spotlights only
|
||||||
|
# what it introduces - a lesson titled "K" must drill K, not spread
|
||||||
|
# itself evenly over all five keys it happens to name. With nothing
|
||||||
|
# new, the whole list is the spotlight: that is the "mixed" replay.
|
||||||
|
spotlight_keys=(
|
||||||
|
(new_keys or keys) if kind == "letters" and not is_drill else ()
|
||||||
|
),
|
||||||
emphasis=emphasis,
|
emphasis=emphasis,
|
||||||
active_keys=active_keys,
|
active_keys=active_keys,
|
||||||
primary_mode=primary_mode,
|
primary_mode=primary_mode,
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ class RunResult:
|
|||||||
animal: AnimalId
|
animal: AnimalId
|
||||||
points: float
|
points: float
|
||||||
passed: bool
|
passed: bool
|
||||||
pearls: int
|
|
||||||
strokes: tuple[Stroke, ...]
|
strokes: tuple[Stroke, ...]
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +142,6 @@ class TypingProgress(BaseModel):
|
|||||||
version: Literal[1] = 1
|
version: Literal[1] = 1
|
||||||
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
|
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
|
||||||
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
|
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
|
||||||
pearls: int = 0
|
|
||||||
#: Pets that have moved into the aquarium, in the order they arrived.
|
#: Pets that have moved into the aquarium, in the order they arrived.
|
||||||
aquarium: list[CreatureId] = Field(default_factory=list)
|
aquarium: list[CreatureId] = Field(default_factory=list)
|
||||||
streak: Streak = Field(default_factory=Streak)
|
streak: Streak = Field(default_factory=Streak)
|
||||||
@@ -265,7 +263,7 @@ def record_run(
|
|||||||
curriculum: Curriculum,
|
curriculum: Curriculum,
|
||||||
day: str | None = None,
|
day: str | None = None,
|
||||||
) -> RecordOutcome:
|
) -> RecordOutcome:
|
||||||
"""Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
|
"""Record a finished run: stars, animal, key stats, streak, and the unlock.
|
||||||
|
|
||||||
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
||||||
fifth attempt whatever the score. Speed is nowhere in it.
|
fifth attempt whatever the score. Speed is nowhere in it.
|
||||||
@@ -317,7 +315,6 @@ def record_run(
|
|||||||
"lessons": lessons,
|
"lessons": lessons,
|
||||||
"aquarium": aquarium,
|
"aquarium": aquarium,
|
||||||
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
|
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
|
||||||
"pearls": progress.pearls + result.pearls,
|
|
||||||
"streak": _bump_streak(progress.streak, day),
|
"streak": _bump_streak(progress.streak, day),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
name = "musicmouse"
|
name = "musicmouse"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
description = "Host backend for the MusicMouse RFID music player"
|
description = "Host backend for the MusicMouse RFID music player"
|
||||||
requires-python = ">=3.13"
|
# Raspberry Pi OS (Bookworm) ships Python 3.11, and its system interpreter is what the
|
||||||
|
# device runs. Staying on it means apt and piwheels supply prebuilt armhf wheels for
|
||||||
|
# the native dependencies (pydantic-core, Pillow), instead of needing a separate
|
||||||
|
# toolchain to fetch a newer interpreter and compile them on the Pi.
|
||||||
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
@@ -50,7 +54,7 @@ filterwarnings = ["error"]
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py311"
|
||||||
# Course material and scratch work, not part of the backend. See notebooks/README.md.
|
# Course material and scratch work, not part of the backend. See notebooks/README.md.
|
||||||
extend-exclude = ["notebooks"]
|
extend-exclude = ["notebooks"]
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ select = ["ARG", "B", "C4", "E", "F", "I", "N", "PTH", "RUF", "SIM", "UP", "W"]
|
|||||||
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
|
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.13"
|
python_version = "3.11"
|
||||||
strict = true
|
strict = true
|
||||||
files = ["musicmouse"]
|
files = ["musicmouse"]
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||||
from musicmouse.library import Album, Analyzer, MusicLibrary
|
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
|
||||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||||
from musicmouse.library.cache import LibraryCache
|
from musicmouse.library.cache import LibraryCache
|
||||||
from musicmouse.library.colors import colors_from_id
|
from musicmouse.library.colors import colors_from_id
|
||||||
@@ -52,6 +55,19 @@ class FakeAnalyzer:
|
|||||||
return analysis, BeatGrid((0.1,), (1.0,)), curves
|
return analysis, BeatGrid((0.1,), (1.0,)), curves
|
||||||
|
|
||||||
|
|
||||||
|
class PidAnalyzer(FakeAnalyzer):
|
||||||
|
"""Reports *which process* analyzed each track, as `tempo`.
|
||||||
|
|
||||||
|
The point of a pool is that this is never the process running the test - and a
|
||||||
|
class at module scope is also the only kind of analyzer a worker can be handed,
|
||||||
|
since it has to survive being pickled over to one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||||
|
analysis, grid, curves = super().analyze(path)
|
||||||
|
return replace(analysis, tempo=float(os.getpid())), grid, curves
|
||||||
|
|
||||||
|
|
||||||
def album_named(library: MusicLibrary, title: str) -> Album:
|
def album_named(library: MusicLibrary, title: str) -> Album:
|
||||||
return next(album for album in library.albums if album.title == title)
|
return next(album for album in library.albums if album.title == title)
|
||||||
|
|
||||||
@@ -231,9 +247,14 @@ async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None:
|
|||||||
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
|
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
|
||||||
|
|
||||||
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
||||||
assert album.cover == folder / "cover.jpg"
|
# Into the cache, not at the library file: a folder cover is whatever size it came
|
||||||
|
# in at, and serving it directly is what used to put 1920px art in a 185px card.
|
||||||
|
assert album.cover is not None
|
||||||
|
assert album.cover.parent == config_dir / ".cache" / "covers"
|
||||||
|
assert album.cover.stem == album.id
|
||||||
# A solid red cover has one usable colour; the rest fall back to the synthesised
|
# A solid red cover has one usable colour; the rest fall back to the synthesised
|
||||||
# palette rather than repeating it.
|
# palette rather than repeating it - and that still reads the real art, not the
|
||||||
|
# downscaled copy.
|
||||||
assert album.colors[0] != colors_from_id(album.id)[0]
|
assert album.colors[0] != colors_from_id(album.id)[0]
|
||||||
|
|
||||||
|
|
||||||
@@ -248,9 +269,19 @@ async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_d
|
|||||||
|
|
||||||
library = await build(config_dir)
|
library = await build(config_dir)
|
||||||
|
|
||||||
assert album_named(library, "Neu").cover == podcast / "20260101 - Neu.jpg"
|
# Both covers now live in the cache under their own album id, so which file won is
|
||||||
|
# no longer visible in the path - read the colour back out instead.
|
||||||
|
def cover_colour(title: str) -> tuple[int, int, int]:
|
||||||
|
cover = album_named(library, title).cover
|
||||||
|
assert cover is not None
|
||||||
|
with Image.open(cover) as image:
|
||||||
|
return image.convert("RGB").getpixel((0, 0)) # type: ignore[return-value]
|
||||||
|
|
||||||
|
red, green, blue = cover_colour("Neu")
|
||||||
|
assert red > 150 and green < 90, "the episode's own sidecar art should win"
|
||||||
# No sidecar for this one, so it still falls back to the show's shared cover.
|
# No sidecar for this one, so it still falls back to the show's shared cover.
|
||||||
assert album_named(library, "Alt").cover == podcast / "cover.jpg"
|
red, green, blue = cover_colour("Alt")
|
||||||
|
assert red < 60 and blue > red, "falls back to the show's shared cover"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- cache
|
# --------------------------------------------------------------------------- cache
|
||||||
@@ -641,3 +672,172 @@ async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
|
|||||||
|
|
||||||
assert pass_count == 2
|
assert pass_count == 2
|
||||||
assert len(analyzer.calls) == 5
|
assert len(analyzer.calls) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ worker processes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_parallel_pass_analyzes_every_track_in_worker_processes(
|
||||||
|
config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The whole point of `analysis_workers`: a machine with cores gets to use them.
|
||||||
|
|
||||||
|
Two workers rather than the real default, because what is being checked is that
|
||||||
|
the work left this process at all - not how fast a five-track fixture goes.
|
||||||
|
"""
|
||||||
|
library = await build(config_dir, analyzer=PidAnalyzer(fails={"00 - lied.mp3"}))
|
||||||
|
|
||||||
|
done = await library.analyze_pending(workers=2)
|
||||||
|
|
||||||
|
assert done == 5
|
||||||
|
analyzed = [
|
||||||
|
track.analysis
|
||||||
|
for album in library.albums
|
||||||
|
if album.kind == "music"
|
||||||
|
for track in album.tracks
|
||||||
|
]
|
||||||
|
assert all(a is not None and a.version == ANALYZER_VERSION for a in analyzed)
|
||||||
|
# The one file the analyzer chokes on is recorded as attempted, exactly as on the
|
||||||
|
# in-process path - a raise in a worker must not abort the other four.
|
||||||
|
failed = [a for a in analyzed if a is not None and a.tempo is None]
|
||||||
|
assert len(failed) == 1
|
||||||
|
workers = {a.tempo for a in analyzed if a is not None and a.tempo is not None}
|
||||||
|
assert workers
|
||||||
|
assert os.getpid() not in workers
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_parallel_pass_starts_no_workers_while_playback_is_busy(
|
||||||
|
config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Being busy holds the pass *before* the pool exists, so a device playing an album
|
||||||
|
is not also hosting a set of idle analyzer processes."""
|
||||||
|
import musicmouse.library as library_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
|
||||||
|
library = await build(config_dir, analyzer=PidAnalyzer())
|
||||||
|
|
||||||
|
busy = True
|
||||||
|
task = asyncio.create_task(library.analyze_pending(workers=2, is_busy=lambda: busy))
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
assert list(library.cache.analysis.glob("*.json")) == []
|
||||||
|
|
||||||
|
busy = False
|
||||||
|
assert await asyncio.wait_for(task, timeout=30) == 5
|
||||||
|
finally:
|
||||||
|
if not task.done():
|
||||||
|
await _cancel(task)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> None:
|
||||||
|
"""Shutdown cancels this task like any other, and it must not hang waiting for
|
||||||
|
worker processes to finish the tracks they are on."""
|
||||||
|
library = await build(config_dir, analyzer=PidAnalyzer())
|
||||||
|
|
||||||
|
task = asyncio.create_task(library.analyze_pending(workers=2))
|
||||||
|
await asyncio.sleep(0.1) # long enough to have workers starting up
|
||||||
|
await asyncio.wait_for(_cancel(task), timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
|
||||||
|
count = default_worker_count()
|
||||||
|
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCoverDownscaling:
|
||||||
|
"""Album art arrives sized for a record sleeve; screens want a fraction of that."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _jpeg(size: tuple[int, int]) -> bytes:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", size, (120, 40, 200)).save(buffer, format="JPEG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def test_oversized_art_is_shrunk_to_the_long_edge(self) -> None:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, shrink_cover
|
||||||
|
|
||||||
|
original = self._jpeg((3000, 2000))
|
||||||
|
shrunk = shrink_cover(original)
|
||||||
|
with Image.open(io.BytesIO(shrunk)) as image:
|
||||||
|
width, height = image.size
|
||||||
|
assert max(width, height) == MAX_COVER_PX
|
||||||
|
# Aspect ratio survives, to within the rounding of a whole pixel.
|
||||||
|
assert abs(width / height - 3000 / 2000) < 0.01
|
||||||
|
assert len(shrunk) < len(original)
|
||||||
|
|
||||||
|
def test_art_already_small_enough_is_returned_untouched(self) -> None:
|
||||||
|
"""Byte-identical, not merely similar: repeated scans must not re-encode art
|
||||||
|
over and over, each pass losing a little more to JPEG."""
|
||||||
|
from musicmouse.library.cache import shrink_cover
|
||||||
|
|
||||||
|
original = self._jpeg((300, 300))
|
||||||
|
assert shrink_cover(original) is original
|
||||||
|
|
||||||
|
def test_unreadable_art_is_passed_through_rather_than_dropped(self) -> None:
|
||||||
|
"""A cover too big is a performance problem; a cover missing is a visible one."""
|
||||||
|
from musicmouse.library.cache import shrink_cover
|
||||||
|
|
||||||
|
assert shrink_cover(b"not an image at all") == b"not an image at all"
|
||||||
|
|
||||||
|
def test_store_cover_shrinks_on_the_way_in(self, tmp_path: Path) -> None:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
|
||||||
|
|
||||||
|
cache = LibraryCache(tmp_path)
|
||||||
|
cache.prepare()
|
||||||
|
path = cache.store_cover("abc123", self._jpeg((2400, 2400)))
|
||||||
|
with Image.open(path) as image:
|
||||||
|
assert max(image.size) == MAX_COVER_PX
|
||||||
|
|
||||||
|
def test_a_cover_jpg_in_the_library_folder_is_cached_downscaled_too(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""The branch that quietly undid all of this: an album with art already sitting
|
||||||
|
beside it used to have `cover` point straight at that file, so it was served at
|
||||||
|
whatever size the internet gave it - often 1920px into a 185px card."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
|
||||||
|
from musicmouse.library.scanner import _cover_for
|
||||||
|
|
||||||
|
cache = LibraryCache(tmp_path / "cache")
|
||||||
|
cache.prepare()
|
||||||
|
folder = tmp_path / "album"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "cover.jpg").write_bytes(self._jpeg((1920, 1920)))
|
||||||
|
|
||||||
|
path, art = _cover_for(folder, [], "album1", cache)
|
||||||
|
|
||||||
|
assert path is not None
|
||||||
|
assert path.parent == cache.covers, "cover must point into the cache, not the library"
|
||||||
|
with Image.open(path) as image:
|
||||||
|
assert max(image.size) == MAX_COVER_PX
|
||||||
|
# The untouched original still comes back, because colour extraction wants it.
|
||||||
|
assert art == (folder / "cover.jpg").read_bytes()
|
||||||
|
|
||||||
|
def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None:
|
||||||
|
"""The scanner reuses an unchanged album without re-reading its tags, so a
|
||||||
|
cover stored before the limit existed would otherwise never be rewritten."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
|
||||||
|
|
||||||
|
cache = LibraryCache(tmp_path)
|
||||||
|
cache.prepare()
|
||||||
|
stale = cache.cover_path("old")
|
||||||
|
stale.write_bytes(self._jpeg((3000, 3000)))
|
||||||
|
fresh = cache.cover_path("new")
|
||||||
|
fresh.write_bytes(self._jpeg((320, 320)))
|
||||||
|
fresh_before = fresh.read_bytes()
|
||||||
|
|
||||||
|
assert cache.shrink_stored_covers() == 1
|
||||||
|
with Image.open(stale) as image:
|
||||||
|
assert max(image.size) == MAX_COVER_PX
|
||||||
|
assert fresh.read_bytes() == fresh_before
|
||||||
|
# Second pass has nothing left to do - the cheap steady state.
|
||||||
|
assert cache.shrink_stored_covers() == 0
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class ScriptedLircd:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._writer: asyncio.StreamWriter | None = None
|
self._writer: asyncio.StreamWriter | None = None
|
||||||
self._connected = asyncio.Event()
|
self._connected = asyncio.Event()
|
||||||
|
self._closing = asyncio.Event()
|
||||||
self.connection_count = 0
|
self.connection_count = 0
|
||||||
|
|
||||||
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
@@ -77,7 +78,14 @@ class ScriptedLircd:
|
|||||||
self.connection_count += 1
|
self.connection_count += 1
|
||||||
self._connected.set()
|
self._connected.set()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await asyncio.Event().wait() # held open until the test drops it
|
await self._closing.wait() # held open until the test drops it
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(OSError, asyncio.CancelledError):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Let go of every connection still being held open, so the server can close."""
|
||||||
|
self._closing.set()
|
||||||
|
|
||||||
async def wait_connected(self) -> None:
|
async def wait_connected(self) -> None:
|
||||||
await self._connected.wait()
|
await self._connected.wait()
|
||||||
@@ -108,8 +116,10 @@ async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Serv
|
|||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
# The scripted connection handler holds its connection open forever (it does
|
# The scripted connection handler holds its connection open forever (it does
|
||||||
# not know the test is done), so `wait_closed()` alone would hang here.
|
# not know the test is done), so `wait_closed()` alone would hang here. 3.13
|
||||||
server.close_clients()
|
# has Server.close_clients() for exactly this; ask the handlers to let go
|
||||||
|
# instead, because the device runs the Pi's Python 3.11.
|
||||||
|
station.shutdown()
|
||||||
await server.wait_closed()
|
await server.wait_closed()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -58,7 +59,10 @@ def seen(bus: EventBus) -> list[Event]:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
return [e for e in events if isinstance(e, event_type)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -55,7 +56,10 @@ def seen(bus: EventBus) -> list[Event]:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
return [e for e in events if isinstance(e, event_type)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from musicmouse.library.podcast_feeds import (
|
|||||||
episode_filename,
|
episode_filename,
|
||||||
find_feed_shows,
|
find_feed_shows,
|
||||||
missing_episodes,
|
missing_episodes,
|
||||||
|
newest_episodes,
|
||||||
parse_feed,
|
parse_feed,
|
||||||
|
prune_show,
|
||||||
resolve_episode_cover,
|
resolve_episode_cover,
|
||||||
sync_all_shows,
|
sync_all_shows,
|
||||||
sync_show,
|
sync_show,
|
||||||
@@ -630,3 +632,148 @@ async def test_sync_show_survives_a_corrupt_failure_record(tmp_path: Path) -> No
|
|||||||
|
|
||||||
assert changed is True
|
assert changed is True
|
||||||
assert (folder / "20250101 - Episode One.mp3").exists()
|
assert (folder / "20250101 - Episode One.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- episode retention
|
||||||
|
|
||||||
|
|
||||||
|
def _episode(day: int, *, title: str | None = None) -> Episode:
|
||||||
|
return Episode(
|
||||||
|
title=title or f"Episode {day}",
|
||||||
|
published=datetime(2025, 1, day, tzinfo=UTC),
|
||||||
|
enclosure_url=f"http://example.com/ep{day}.mp3",
|
||||||
|
enclosure_type="audio/mpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_episode(folder: Path, name: str) -> Path:
|
||||||
|
path = folder / name
|
||||||
|
path.write_bytes(b"audio")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_episodes_takes_the_most_recent_and_orders_them_newest_first() -> None:
|
||||||
|
episodes = [_episode(1), _episode(5), _episode(3)]
|
||||||
|
assert [e.published.day for e in newest_episodes(episodes, 2)] == [5, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_episodes_keeps_everything_when_the_limit_is_none() -> None:
|
||||||
|
episodes = [_episode(1), _episode(5), _episode(3)]
|
||||||
|
assert len(newest_episodes(episodes, None)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_deletes_only_the_oldest_beyond_the_limit(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
for day in range(1, 6):
|
||||||
|
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
|
||||||
|
|
||||||
|
assert prune_show(folder, 2) == 3
|
||||||
|
|
||||||
|
remaining = sorted(path.name for path in folder.iterdir())
|
||||||
|
assert remaining == ["20250104 - Episode 4.mp3", "20250105 - Episode 5.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_is_a_noop_below_the_limit(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Episode 1.mp3")
|
||||||
|
assert prune_show(folder, 50) == 0
|
||||||
|
assert prune_show(folder, None) == 0
|
||||||
|
assert (folder / "20250101 - Episode 1.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_takes_the_sidecar_cover_with_the_episode(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Old.mp3")
|
||||||
|
(folder / "20250101 - Old.jpg").write_bytes(b"art")
|
||||||
|
_write_episode(folder, "20250102 - New.mp3")
|
||||||
|
(folder / "20250102 - New.jpg").write_bytes(b"art")
|
||||||
|
|
||||||
|
assert prune_show(folder, 1) == 1
|
||||||
|
|
||||||
|
assert not (folder / "20250101 - Old.jpg").exists()
|
||||||
|
assert (folder / "20250102 - New.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_leaves_everything_it_did_not_name(tmp_path: Path) -> None:
|
||||||
|
"""A folder holds more than episodes, and a hand-placed file is not ours to delete."""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
for day in range(1, 4):
|
||||||
|
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
|
||||||
|
(folder / "feed.txt").write_text("http://example.com/feed.xml\n")
|
||||||
|
(folder / "folder.jpg").write_bytes(b"art")
|
||||||
|
(folder / ".failed-downloads.json").write_text("{}")
|
||||||
|
_write_episode(folder, "Grandpa's tape.mp3")
|
||||||
|
|
||||||
|
prune_show(folder, 1)
|
||||||
|
|
||||||
|
assert (folder / "feed.txt").exists()
|
||||||
|
assert (folder / "folder.jpg").exists()
|
||||||
|
assert (folder / ".failed-downloads.json").exists()
|
||||||
|
assert (folder / "Grandpa's tape.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_prunes_to_the_limit_after_downloading(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
if str(request.url) == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
return httpx2.Response(200, content=b"audio-bytes")
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
episodes = sorted(path.name for path in folder.iterdir() if path.suffix == ".mp3")
|
||||||
|
assert episodes == ["20250103 - Episode Two_ Special_Chars_.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_does_not_redownload_what_it_pruned(tmp_path: Path) -> None:
|
||||||
|
"""The whole point of capping downloads as well as deletions.
|
||||||
|
|
||||||
|
Prune to the newest N but fetch everything the feed offers, and each poll would
|
||||||
|
re-download the episodes the previous one deleted - forever, at full size.
|
||||||
|
"""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
downloads: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
if str(request.url) == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
downloads.append(str(request.url))
|
||||||
|
return httpx2.Response(200, content=b"audio-bytes")
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
first_pass = len(downloads)
|
||||||
|
changed_again = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed_again is False
|
||||||
|
assert len(downloads) == first_pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_reports_a_change_when_it_only_pruned(tmp_path: Path) -> None:
|
||||||
|
"""A deletion needs a rescan as much as a download does, or the library goes on
|
||||||
|
offering tracks whose files are gone."""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Episode One.mp3")
|
||||||
|
_write_episode(folder, "20250103 - Episode Two_ Special_Chars_.mp3")
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert not (folder / "20250101 - Episode One.mp3").exists()
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ async def test_run_checks_immediately_and_calls_on_change_only_when_something_ch
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def fake_sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
|
||||||
|
) -> bool:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
return calls == 1 # only the very first pass finds something new
|
return calls == 1 # only the very first pass finds something new
|
||||||
@@ -63,7 +65,9 @@ async def test_run_survives_a_failing_pass(
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def fake_sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
|
||||||
|
) -> bool:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
if calls == 1:
|
if calls == 1:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -49,7 +50,10 @@ def seen(sim: Simulation) -> list[Event]:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
return [e for e in events if isinstance(e, event_type)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ def _run_body(lesson_id: str, *, stars: int = 3, passed: bool = True) -> dict:
|
|||||||
"animal": "fish",
|
"animal": "fish",
|
||||||
"points": 42.0,
|
"points": 42.0,
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
"pearls": 5,
|
|
||||||
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
|
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +170,6 @@ async def test_get_progress_is_fresh_with_only_the_first_lesson_unlocked(
|
|||||||
body = (await client.get("/api/tippen/progress")).json()
|
body = (await client.get("/api/tippen/progress")).json()
|
||||||
assert body["lessons"]["l01"]["unlocked"] is True
|
assert body["lessons"]["l01"]["unlocked"] is True
|
||||||
assert body["lessons"]["l02"]["unlocked"] is False
|
assert body["lessons"]["l02"]["unlocked"] is False
|
||||||
assert body["pearls"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:
|
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:
|
||||||
|
|||||||
@@ -117,19 +117,21 @@ def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
|
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
|
||||||
worlds = [dict(w) for w in VALID["worlds"]]
|
worlds = [dict(w) for w in VALID["worlds"]]
|
||||||
# Two independent problems in one file: a reused reward, and a lesson with too many
|
# Two independent problems in one file: a reused reward, and a lesson introducing
|
||||||
# keys - both should show up in a single error, not just the first one found.
|
# too many keys at once - both should show up in a single error, not just the first
|
||||||
|
# one found. ("a" and "s" are already taught in world 1, so only the three unseen
|
||||||
|
# keys count against the cap.)
|
||||||
worlds[1] = {**worlds[1], "reward": "clownfish"}
|
worlds[1] = {**worlds[1], "reward": "clownfish"}
|
||||||
worlds[2] = {
|
worlds[2] = {
|
||||||
**worlds[2],
|
**worlds[2],
|
||||||
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}],
|
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "q", "w", "e"]}],
|
||||||
}
|
}
|
||||||
with pytest.raises(CurriculumError) as excinfo:
|
with pytest.raises(CurriculumError) as excinfo:
|
||||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||||
message = str(excinfo.value)
|
message = str(excinfo.value)
|
||||||
assert "2 problems" in message
|
assert "2 problems" in message
|
||||||
assert "reuses reward" in message
|
assert "reuses reward" in message
|
||||||
assert "at most two keys" in message
|
assert "at most two new keys" in message
|
||||||
|
|
||||||
|
|
||||||
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
|
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
|
||||||
@@ -244,11 +246,10 @@ def test_plays_a_mode_that_fits_its_kind(curriculum) -> None:
|
|||||||
assert mode in ELIGIBLE_MODES[lesson.kind], where
|
assert mode in ELIGIBLE_MODES[lesson.kind], where
|
||||||
|
|
||||||
|
|
||||||
def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None:
|
def test_letters_rounds_always_play_bubbles(curriculum) -> None:
|
||||||
arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"]
|
for lesson in curriculum.lessons:
|
||||||
for i in range(1, len(arcade)):
|
if lesson.kind == "letters":
|
||||||
where = f"{arcade[i].number}: {arcade[i].title}"
|
assert lesson.primary_mode == "bubbles", f"{lesson.number}: {lesson.title}"
|
||||||
assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where
|
|
||||||
|
|
||||||
|
|
||||||
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:
|
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:
|
||||||
|
|||||||
@@ -53,13 +53,12 @@ def _curriculum() -> Curriculum:
|
|||||||
return Curriculum(worlds=worlds, lessons=lessons)
|
return Curriculum(worlds=worlds, lessons=lessons)
|
||||||
|
|
||||||
|
|
||||||
def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult:
|
def _result(*, stars: int, passed: bool, points: float = 10.0) -> RunResult:
|
||||||
return RunResult(
|
return RunResult(
|
||||||
stars=stars, # type: ignore[arg-type]
|
stars=stars, # type: ignore[arg-type]
|
||||||
animal="fish",
|
animal="fish",
|
||||||
points=points,
|
points=points,
|
||||||
passed=passed,
|
passed=passed,
|
||||||
pearls=pearls,
|
|
||||||
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
|
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,7 +98,6 @@ def test_save_then_load_round_trips(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert reloaded.lessons["l01"].best_stars == 3
|
assert reloaded.lessons["l01"].best_stars == 3
|
||||||
assert reloaded.lessons["l02"].unlocked is True
|
assert reloaded.lessons["l02"].unlocked is True
|
||||||
assert reloaded.pearls == 3
|
|
||||||
# Atomic write leaves no temp file behind.
|
# Atomic write leaves no temp file behind.
|
||||||
assert list(tmp_path.glob("*.tmp*")) == []
|
assert list(tmp_path.glob("*.tmp*")) == []
|
||||||
|
|
||||||
@@ -209,16 +207,15 @@ def test_finishing_a_world_awards_its_creature_exactly_once() -> None:
|
|||||||
assert step3.progress.aquarium == ["clownfish"]
|
assert step3.progress.aquarium == ["clownfish"]
|
||||||
|
|
||||||
|
|
||||||
def test_pearls_and_streak_accumulate() -> None:
|
def test_streak_accumulates() -> None:
|
||||||
curriculum = _curriculum()
|
curriculum = _curriculum()
|
||||||
progress = fresh_progress(curriculum)
|
progress = fresh_progress(curriculum)
|
||||||
day1 = record_run(
|
day1 = record_run(
|
||||||
progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01"
|
progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
|
||||||
)
|
)
|
||||||
day2 = record_run(
|
day2 = record_run(
|
||||||
day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02"
|
day1.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-02"
|
||||||
)
|
)
|
||||||
assert day2.progress.pearls == 7
|
|
||||||
assert day2.progress.streak.days == 2
|
assert day2.progress.streak.days == 2
|
||||||
assert day2.progress.streak.last_played == "2026-01-02"
|
assert day2.progress.streak.last_played == "2026-01-02"
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,7 +28,7 @@ from tests.websocket_harness import WebSocketSession, websocket_connect
|
|||||||
TRACK_SECONDS = 10.0
|
TRACK_SECONDS = 10.0
|
||||||
|
|
||||||
#: Shorthand: every test takes the same client type.
|
#: Shorthand: every test takes the same client type.
|
||||||
type Client = httpx2.AsyncClient
|
Client: TypeAlias = httpx2.AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file.
|
# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file.
|
||||||
#
|
#
|
||||||
# Every lesson: title, subtitle (read aloud). Then either:
|
# Every lesson: title, subtitle (read aloud). Then either:
|
||||||
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
|
# - keys: the letters-kind keys this round has on the table. At most two of them may
|
||||||
# already active: the first time a key appears its lesson is "isolated" (heavy
|
# be new; the rest are keys already taught, written out to say "these are in play
|
||||||
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
|
# too". The loader tracks which keys were already active and spotlights only what is
|
||||||
# blended with everything learned so far). Two lessons with the same `keys` back to
|
# new, so `keys: [f, j, a, d, k]` right after `[f, j, a, d]` is a K lesson with the
|
||||||
# back is exactly how you write "isolated, then mixed" - no separate flag needed.
|
# other four as review ("isolated" - heavy weight on K). A round that introduces
|
||||||
|
# nothing new spotlights its whole list instead ("mixed" - lighter weight, blended
|
||||||
|
# with everything learned so far), so two lessons with the same `keys` back to back
|
||||||
|
# is exactly how you write "isolated, then mixed" - no separate flag needed.
|
||||||
# - drill: true - a pure review round, no new content, whatever is active so far.
|
# - drill: true - a pure review round, no new content, whatever is active so far.
|
||||||
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
|
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
|
||||||
# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which
|
# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which
|
||||||
@@ -18,8 +21,11 @@
|
|||||||
#
|
#
|
||||||
# unlocks: <path>
|
# unlocks: <path>
|
||||||
#
|
#
|
||||||
# A path into the music library (see config.yml's general.library.root - absolute, or
|
# A path into the music library, written relative to config.yml's
|
||||||
# relative to it; "~" is expanded). Passing this lesson (two stars, or five attempts
|
# general.library.root - "Musik/Klaviersammlung/Lied.mp3", not a path from "/" or "~".
|
||||||
|
# (An absolute path, or one starting with "~", still resolves, so an old file keeps
|
||||||
|
# working - but relative is the form to write, since it survives moving the library.)
|
||||||
|
# Passing this lesson (two stars, or five attempts
|
||||||
# regardless of score) unlocks that track and everything before it in the same album,
|
# regardless of score) unlocks that track and everything before it in the same album,
|
||||||
# inclusive - so pointing at an album's last track unlocks the whole album, and
|
# inclusive - so pointing at an album's last track unlocks the whole album, and
|
||||||
# pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end
|
# pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end
|
||||||
@@ -59,7 +65,7 @@ worlds:
|
|||||||
subtitle: "Die Ringfinger"
|
subtitle: "Die Ringfinger"
|
||||||
keys: [s, l]
|
keys: [s, l]
|
||||||
# Example - unlocks tracks 1 through 5 (inclusive) of this album:
|
# Example - unlocks tracks 1 through 5 (inclusive) of this album:
|
||||||
# unlocks: ~/Music/Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3
|
# unlocks: "Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3"
|
||||||
- title: "S und L üben"
|
- title: "S und L üben"
|
||||||
subtitle: "Die neuen Tasten festigen"
|
subtitle: "Die neuen Tasten festigen"
|
||||||
keys: [s, l]
|
keys: [s, l]
|
||||||
@@ -195,7 +201,7 @@ worlds:
|
|||||||
drill: true
|
drill: true
|
||||||
# Example - unlocks a whole audiobook (its last chapter, inclusive of every
|
# Example - unlocks a whole audiobook (its last chapter, inclusive of every
|
||||||
# chapter before it):
|
# chapter before it):
|
||||||
# unlocks: ~/Music/Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3
|
# unlocks: "Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3"
|
||||||
|
|
||||||
- number: 3
|
- number: 3
|
||||||
title: "Nach unten"
|
title: "Nach unten"
|
||||||
@@ -295,7 +301,7 @@ worlds:
|
|||||||
drill: true
|
drill: true
|
||||||
# Example - unlocks a whole podcast show (its most recent episode, and every
|
# Example - unlocks a whole podcast show (its most recent episode, and every
|
||||||
# earlier one of the same show, chronologically):
|
# earlier one of the same show, chronologically):
|
||||||
# unlocks: ~/Music/Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3
|
# unlocks: "Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3"
|
||||||
|
|
||||||
- number: 4
|
- number: 4
|
||||||
title: "Große Buchstaben"
|
title: "Große Buchstaben"
|
||||||
|
|||||||
244
scripts/sync-library.sh
Executable file
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Copy a local music library, and the expensive parts of its scan cache, onto a
|
||||||
|
# MusicMouse device.
|
||||||
|
#
|
||||||
|
# scripts/sync-library.sh --host musicdolphin
|
||||||
|
#
|
||||||
|
# The cache is the reason this is a script rather than one rsync line. Its three kinds
|
||||||
|
# of content behave completely differently when they move between machines (see
|
||||||
|
# python-backend/musicmouse/library/cache.py):
|
||||||
|
#
|
||||||
|
# covers/<album_id>.jpg album_id = sha1(folder path relative to the library
|
||||||
|
# root). Portable, as long as the shelf layout under the
|
||||||
|
# target root matches the source - which it does, because
|
||||||
|
# this script syncs the shelves verbatim.
|
||||||
|
#
|
||||||
|
# analysis/<track_key>.json track_key = sha1("<name>:<size>:<int mtime>"). Portable
|
||||||
|
# *only if mtimes survive the copy*. That is why every
|
||||||
|
# rsync here is -a and why you must never reach for scp
|
||||||
|
# without -p: losing mtimes silently invalidates every
|
||||||
|
# analysis file, each of which costs minutes of DSP to
|
||||||
|
# rebuild.
|
||||||
|
#
|
||||||
|
# index.json NOT copied. It holds absolute source-machine paths, and
|
||||||
|
# scan_library() reuses a cached album whenever its
|
||||||
|
# (name, size, mtime) fingerprint matches, without
|
||||||
|
# re-checking the path. Copying it makes every album come
|
||||||
|
# back pointing at /home/<you>/Music/..., and playback
|
||||||
|
# fails on files that are sitting right there. It is the
|
||||||
|
# cheap part of the cache - the device rebuilds it on its
|
||||||
|
# first scan, reusing every cover and analysis file we did
|
||||||
|
# copy.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="musicdolphin"
|
||||||
|
SSH_USER="root"
|
||||||
|
SRC="${HOME}/Music"
|
||||||
|
CACHE=""
|
||||||
|
DEST="/media/musicmouse"
|
||||||
|
DRY_RUN=0
|
||||||
|
COPY_CACHE=1
|
||||||
|
|
||||||
|
# Shelves the scanner knows about (musicmouse/library/sections.py). Syncing these by
|
||||||
|
# name rather than the whole directory is what makes --delete safe: config.yml,
|
||||||
|
# tippen-curriculum.yml, tippen-progress.json and .musicmouse-cache all live in $DEST
|
||||||
|
# on the device and must survive.
|
||||||
|
SECTIONS=("Figuren" "Musik" "Hörbücher" "Kinderpodcasts")
|
||||||
|
|
||||||
|
# Leave this much room on the target after the copy. These devices are SD cards with
|
||||||
|
# little to spare, and a full root filesystem breaks far more than the music player.
|
||||||
|
HEADROOM_BYTES=$((1024 * 1024 * 1024))
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: scripts/sync-library.sh [options]
|
||||||
|
|
||||||
|
--host HOST Target device (default: musicdolphin)
|
||||||
|
--user USER SSH user (default: root)
|
||||||
|
--src DIR Local library (default: ~/Music)
|
||||||
|
--dest DIR Library root on target (default: /media/musicmouse)
|
||||||
|
--cache DIR Local cache directory (default: <src>/.musicmouse-cache, falling
|
||||||
|
back to python-backend/.musicmouse-cache next to this script)
|
||||||
|
--no-cache Skip the cache; the device recomputes everything from scratch
|
||||||
|
--dry-run Show what would be transferred, change nothing
|
||||||
|
-h, --help This message
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--host) HOST="$2"; shift 2 ;;
|
||||||
|
--user) SSH_USER="$2"; shift 2 ;;
|
||||||
|
--src) SRC="$2"; shift 2 ;;
|
||||||
|
--dest) DEST="$2"; shift 2 ;;
|
||||||
|
--cache) CACHE="$2"; shift 2 ;;
|
||||||
|
--no-cache) COPY_CACHE=0; shift ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SSH_TARGET="${SSH_USER}@${HOST}"
|
||||||
|
SRC="${SRC%/}"
|
||||||
|
DEST="${DEST%/}"
|
||||||
|
|
||||||
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
|
step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
human() {
|
||||||
|
# Bytes -> something a person can judge at a glance.
|
||||||
|
numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1 bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- cache location
|
||||||
|
|
||||||
|
if [[ -z "$CACHE" ]]; then
|
||||||
|
if [[ -d "${SRC}/.musicmouse-cache" ]]; then
|
||||||
|
CACHE="${SRC}/.musicmouse-cache"
|
||||||
|
else
|
||||||
|
# The dev setup keeps config.yml (and therefore the cache, which resolves
|
||||||
|
# relative to it) in python-backend/ rather than beside the music.
|
||||||
|
CACHE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/python-backend/.musicmouse-cache"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- preflight
|
||||||
|
|
||||||
|
step "Preflight"
|
||||||
|
|
||||||
|
[[ -d "$SRC" ]] || die "source library not found: $SRC"
|
||||||
|
|
||||||
|
present_sections=()
|
||||||
|
for section in "${SECTIONS[@]}"; do
|
||||||
|
[[ -d "${SRC}/${section}" ]] && present_sections+=("$section")
|
||||||
|
done
|
||||||
|
[[ ${#present_sections[@]} -gt 0 ]] \
|
||||||
|
|| die "no known shelves under $SRC (looked for: ${SECTIONS[*]})"
|
||||||
|
|
||||||
|
echo "source: $SRC"
|
||||||
|
echo "shelves: ${present_sections[*]}"
|
||||||
|
|
||||||
|
if [[ $COPY_CACHE -eq 1 ]]; then
|
||||||
|
if [[ -d "$CACHE" ]]; then
|
||||||
|
echo "cache: $CACHE"
|
||||||
|
else
|
||||||
|
echo "cache: none found at $CACHE - continuing without it"
|
||||||
|
COPY_CACHE=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "cache: skipped (--no-cache)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_TARGET" true 2>/dev/null \
|
||||||
|
|| die "cannot ssh to $SSH_TARGET (needs key auth, no password prompt)"
|
||||||
|
echo "target: ${SSH_TARGET}:${DEST}"
|
||||||
|
|
||||||
|
# Size the transfer. This is an upper bound: rsync skips files already present and
|
||||||
|
# identical, so a repeat run moves far less than this.
|
||||||
|
src_bytes=0
|
||||||
|
for section in "${present_sections[@]}"; do
|
||||||
|
section_bytes=$(du -sb "${SRC}/${section}" | cut -f1)
|
||||||
|
src_bytes=$((src_bytes + section_bytes))
|
||||||
|
printf ' %-18s %s\n' "$section" "$(human "$section_bytes")"
|
||||||
|
done
|
||||||
|
if [[ $COPY_CACHE -eq 1 ]]; then
|
||||||
|
for part in covers analysis; do
|
||||||
|
if [[ -d "${CACHE}/${part}" ]]; then
|
||||||
|
part_bytes=$(du -sb "${CACHE}/${part}" | cut -f1)
|
||||||
|
src_bytes=$((src_bytes + part_bytes))
|
||||||
|
printf ' %-18s %s\n' "cache/${part}" "$(human "$part_bytes")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# df on the parent: $DEST may not exist yet on a first run.
|
||||||
|
avail_kb=$(ssh "$SSH_TARGET" "df -Pk '$(dirname "$DEST")' | awk 'NR==2 {print \$4}'")
|
||||||
|
[[ -n "$avail_kb" ]] || die "could not read free space on $HOST"
|
||||||
|
avail_bytes=$((avail_kb * 1024))
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "to transfer (upper bound): $(human "$src_bytes")"
|
||||||
|
echo "free on target: $(human "$avail_bytes")"
|
||||||
|
|
||||||
|
if (( src_bytes + HEADROOM_BYTES > avail_bytes )); then
|
||||||
|
# Already-synced content counts against src_bytes but costs nothing, so this is a
|
||||||
|
# warning on a repeat run and a hard stop only when nothing is there yet.
|
||||||
|
remote_used=$(ssh "$SSH_TARGET" "du -sb '$DEST' 2>/dev/null | cut -f1" || echo 0)
|
||||||
|
remote_used=${remote_used:-0}
|
||||||
|
if (( remote_used > 0 )); then
|
||||||
|
echo
|
||||||
|
echo "warning: the full library would not fit in the free space, but $(human "$remote_used")"
|
||||||
|
echo " is already at ${DEST}. rsync only moves the difference; watch the"
|
||||||
|
echo " free space as it runs."
|
||||||
|
else
|
||||||
|
die "not enough space: need $(human $((src_bytes + HEADROOM_BYTES))) including $(human "$HEADROOM_BYTES") headroom, have $(human "$avail_bytes")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- transfer
|
||||||
|
|
||||||
|
RSYNC_OPTS=(-aH --info=progress2 --human-readable)
|
||||||
|
[[ $DRY_RUN -eq 1 ]] && RSYNC_OPTS+=(-n)
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
echo
|
||||||
|
echo "(dry run - nothing will be written)"
|
||||||
|
else
|
||||||
|
step "Creating ${DEST} on ${HOST}"
|
||||||
|
ssh "$SSH_TARGET" "mkdir -p '$DEST'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Library"
|
||||||
|
for section in "${present_sections[@]}"; do
|
||||||
|
echo "--- ${section}"
|
||||||
|
# Per-shelf, with --delete scoped to that shelf. A --delete on $DEST as a whole
|
||||||
|
# would take out config.yml and the cache, which live in the same directory on the
|
||||||
|
# device but have no counterpart here.
|
||||||
|
rsync "${RSYNC_OPTS[@]}" --delete \
|
||||||
|
"${SRC}/${section}/" "${SSH_TARGET}:${DEST}/${section}/"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $COPY_CACHE -eq 1 ]]; then
|
||||||
|
step "Cache (covers and analysis; index.json deliberately not copied)"
|
||||||
|
for part in covers analysis; do
|
||||||
|
[[ -d "${CACHE}/${part}" ]] || continue
|
||||||
|
echo "--- ${part}"
|
||||||
|
# rsync creates the last path component but not a missing one above it, and on a
|
||||||
|
# fresh device neither .musicmouse-cache nor its subdirectory exists yet.
|
||||||
|
[[ $DRY_RUN -eq 1 ]] || ssh "$SSH_TARGET" "mkdir -p '${DEST}/.musicmouse-cache/${part}'"
|
||||||
|
# No --delete: the device may have analysed tracks this machine never saw.
|
||||||
|
rsync "${RSYNC_OPTS[@]}" \
|
||||||
|
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- report
|
||||||
|
|
||||||
|
step "Done"
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
echo "Dry run only - nothing was written to ${HOST}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh "$SSH_TARGET" "
|
||||||
|
echo 'library: '\$(du -sh '$DEST' 2>/dev/null | cut -f1)
|
||||||
|
echo 'free: '\$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')
|
||||||
|
if [ -d '${DEST}/.musicmouse-cache' ]; then
|
||||||
|
echo 'covers: '\$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l)' files'
|
||||||
|
echo 'analysis: '\$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l)' files'
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
The backend rescans on its next start - a few minutes for a large library - and writes
|
||||||
|
a fresh index.json with this device's own paths. Every cover and analysis file copied
|
||||||
|
above is reused, so the expensive work does not happen again.
|
||||||
|
|
||||||
|
ssh ${SSH_TARGET} systemctl restart musicmouse
|
||||||
|
ssh ${SSH_TARGET} journalctl -u musicmouse -f
|
||||||
|
EOF
|
||||||
1
slint-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
5908
slint-frontend/Cargo.lock
generated
Normal file
65
slint-frontend/Cargo.toml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
[package]
|
||||||
|
name = "musicmouse-slint"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Native Slint front-end for the MusicMouse player"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# default-features off so the renderer is an explicit choice below, never an
|
||||||
|
# accident: on a Pi 5 (Mesa V3D) the default FemtoVG renderer draws every runtime
|
||||||
|
# loaded Image as solid black - see slint-ui/slint#11785 - which for this app means
|
||||||
|
# every album cover. The `skia` feature is the device's way out.
|
||||||
|
slint = { version = "1.15", default-features = false, features = [
|
||||||
|
"compat-1-2",
|
||||||
|
"std",
|
||||||
|
] }
|
||||||
|
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
# Blocking HTTP and blocking websockets, each on its own thread, rather than an async
|
||||||
|
# runtime. The whole client makes one library request, one websocket connection and a
|
||||||
|
# trickle of covers; tokio would be more machinery than the job has work.
|
||||||
|
ureq = { version = "2.12", default-features = false, features = ["json", "gzip"] }
|
||||||
|
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
|
dirs = "5"
|
||||||
|
# Only for `normalize()`: folding "Grüffelo" to "gruffelo" needs a real NFD pass, which
|
||||||
|
# is what the web client gets from String.prototype.normalize for free.
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
slint-build = "1.15"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["desktop"]
|
||||||
|
|
||||||
|
# Development on a normal desktop session. FemtoVG is fine on x86/Mesa and builds in
|
||||||
|
# seconds, which is what makes it the right default *here* and the wrong one on the Pi.
|
||||||
|
# `renderer-software` rides along so `SLINT_BACKEND=winit-software` works without a
|
||||||
|
# rebuild: it is how the UI can be rendered and screenshotted on a machine with no GPU
|
||||||
|
# (or no session), and it is the fallback the Pi 5 FemtoVG bug leaves standing.
|
||||||
|
desktop = ["slint/backend-winit", "slint/renderer-femtovg", "slint/renderer-software"]
|
||||||
|
|
||||||
|
# Same window, Skia instead - the combination the device runs, so visual checks here
|
||||||
|
# match what ships. Costs a long first build while rust-skia bootstraps.
|
||||||
|
skia = ["slint/backend-winit", "slint/renderer-skia"]
|
||||||
|
|
||||||
|
# The device: straight onto KMS/DRM with no X, no Wayland and no compositor, input
|
||||||
|
# via libinput. (`backend-linuxkms-noseat` is deprecated upstream in favour of this.)
|
||||||
|
#
|
||||||
|
# Build it with `--no-default-features --features kiosk`: cargo features are additive,
|
||||||
|
# so leaving `desktop` enabled links winit and linuxkms into the same binary.
|
||||||
|
kiosk = ["slint/backend-linuxkms-libinput", "slint/renderer-skia"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# The UI is laid out and hit-tested in debug builds too; an unoptimised image resize
|
||||||
|
# on a 343-album first run is otherwise the slowest thing in the app by far.
|
||||||
|
[profile.dev.package.image]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.slint]
|
||||||
|
opt-level = 2
|
||||||
168
slint-frontend/README.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Musik Delfin — the Slint front-end
|
||||||
|
|
||||||
|
A native front-end for the music player, next to `../web/`. Same backend, same layout,
|
||||||
|
same German copy; no browser. It exists because the React UI is sluggish on the Pi 5
|
||||||
|
kiosk, and the things making it sluggish are browser-shaped: a compositor deciding what
|
||||||
|
gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a
|
||||||
|
JPEG decode per album card per cold start.
|
||||||
|
|
||||||
|
Only the music player is here. The typing game (`Tippen`), the room-lights page, parent
|
||||||
|
mode and the IR-remote assignment are all still web-only.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
Start the backend, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix-shell # cargo, the GL/X11/Wayland libs, and Nunito
|
||||||
|
cargo run # windowed, FemtoVG
|
||||||
|
cargo run -- --server http://musicdolphin:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test && cargo clippy --all-targets && cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools/headless-shots.sh` renders the UI and screenshots it inside a nested headless
|
||||||
|
compositor, for checking a layout change on a machine with no display (or a locked one).
|
||||||
|
A Slint window needs a real compositor - under bare Xvfb nothing maps at all.
|
||||||
|
|
||||||
|
## Build profiles
|
||||||
|
|
||||||
|
| | Backend | Renderer | For |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cargo run` | winit | FemtoVG | development here |
|
||||||
|
| `cargo run --features skia` | winit | Skia | checking what the device will show |
|
||||||
|
| `cargo build --release --no-default-features --features kiosk` | linuxkms + libinput | Skia | the Pi |
|
||||||
|
|
||||||
|
**The device must not use FemtoVG.** On a Pi 5 (Mesa V3D), FemtoVG draws every
|
||||||
|
runtime-loaded `Image` as solid black - [slint#11785][bug], open as of this writing -
|
||||||
|
which for this app means every album cover. Skia and the software renderer are both
|
||||||
|
unaffected. FemtoVG stays the default *here* because it builds in seconds and is fine on
|
||||||
|
desktop Mesa; `kiosk` is the profile that ships.
|
||||||
|
|
||||||
|
`--no-default-features` is not optional on that last one: the features are additive, so
|
||||||
|
leaving `desktop` on would link winit *and* linuxkms into the same binary.
|
||||||
|
|
||||||
|
`kiosk` takes the framebuffer directly through KMS/DRM: no X, no Wayland, no compositor.
|
||||||
|
(`backend-linuxkms-noseat` is deprecated upstream in favour of the libinput variant used
|
||||||
|
here.) `SLINT_BACKEND=winit-software` forces the software renderer without a rebuild.
|
||||||
|
|
||||||
|
[bug]: https://github.com/slint-ui/slint/issues/11785
|
||||||
|
|
||||||
|
## Where the speed comes from
|
||||||
|
|
||||||
|
Four things, in rough order of how much they matter on the device:
|
||||||
|
|
||||||
|
**Covers are downscaled once, ever.** The backend serves one size - 640 px on the long
|
||||||
|
edge - and has no `?size=`. A grid card is 180 px. `src/covers.rs` fetches each cover
|
||||||
|
once on a worker thread, resizes it to the tier that was asked for, and writes a JPEG
|
||||||
|
thumbnail to `~/.cache/musicmouse-slint/covers/<album-id>@<px>.jpg`. Every later start
|
||||||
|
reads that instead. There are two tiers and only two, which is what makes the cache
|
||||||
|
worth having: a per-widget pixel size would give each album a dozen near-identical files
|
||||||
|
and a fresh decode for each.
|
||||||
|
|
||||||
|
The cache is dropped wholesale when the websocket announces a rescan
|
||||||
|
(`{"type": "library"}`), because that is the one moment the backend rewrites the art
|
||||||
|
behind an unchanged cover URL. That is the bug the web client had to learn about the
|
||||||
|
hard way - see the comment at `python-backend/musicmouse/services/web/api.py:106`.
|
||||||
|
|
||||||
|
**The grid is virtualized.** Rust hands the UI a `[GridRow]` - the album list
|
||||||
|
pre-chunked into rows - and the view puts those in a `ListView`, which only instantiates
|
||||||
|
the rows on screen. A flat list of 660 cards gives it no rows to skip, hence the
|
||||||
|
chunking. This is the counterpart of `content-visibility: auto` on `.grid > .card` in
|
||||||
|
the web UI, which exists for exactly the same measurement: 340 live cards saturated the
|
||||||
|
Pi's renderer so completely that the main thread stopped scheduling anything.
|
||||||
|
|
||||||
|
**The progress bar animates instead of ticking.** Position arrives twice a second. The
|
||||||
|
web client interpolates it onto animation frames with a JS clock and a render budget;
|
||||||
|
here the fill is a Slint `animate width` between the pushed values, so interpolation
|
||||||
|
costs a property evaluation per frame on the render side and there is no clock at all.
|
||||||
|
It is suppressed for the one frame a track changes on, so a new track jumps rather than
|
||||||
|
sliding across two unrelated positions.
|
||||||
|
|
||||||
|
**Nothing on screen animates by itself.** The web UI's per-track ambient canvas is not
|
||||||
|
here. Halving its resolution and its frame rate took it from 53.5% of a core to 25.0%
|
||||||
|
and 25% was still too much: a loop repainting the viewport is a floor you cannot get
|
||||||
|
under while it runs at all. The stage keeps its gradient, which is what `?pi=1` already
|
||||||
|
does on the device this replaces.
|
||||||
|
|
||||||
|
## What is deliberately different from the web UI
|
||||||
|
|
||||||
|
**No glass blur.** Slint has no `backdrop-filter`, so the panels use a flat translucent
|
||||||
|
fill - exactly the web app's own `data-blur="off"` fallback. Some depth is genuinely
|
||||||
|
lost. Faking it by compositing a pre-blurred copy would reintroduce the per-frame cost
|
||||||
|
that dropping the blur was meant to avoid.
|
||||||
|
|
||||||
|
Worth knowing if you ever port the blur back: on the Pi, `backdrop-filter` measured five
|
||||||
|
times *better* than no blur (25% of a core against 118%), because it is what promotes
|
||||||
|
each panel to its own compositing layer. That is a browser-compositor artifact and does
|
||||||
|
not transfer here - but it is why the web UI keeps the panel blur under `?pi=1`.
|
||||||
|
|
||||||
|
**No dolphin.** The mascot and its swim/bob loops are not ported.
|
||||||
|
|
||||||
|
**Layout** is Slint's, not a transcription of the CSS: fixed row heights instead of
|
||||||
|
`clamp()` and `vh`, a 1080p-shaped fixed measure for the track list rather than
|
||||||
|
`max-width`. The colours are the same - `ui/theme.slint` carries the web UI's oklch
|
||||||
|
tokens converted to sRGB, with the oklch source kept beside each one, because oklch is
|
||||||
|
the form the design is actually maintained in.
|
||||||
|
|
||||||
|
## How it is put together
|
||||||
|
|
||||||
|
The split that matters is the web UI's, unchanged: **what is playing** comes from the
|
||||||
|
backend, **what you are looking at** lives here. The buttons on the mouse, a figurine on
|
||||||
|
the reader and Home Assistant all move playback too, so this follows it rather than
|
||||||
|
assuming it is in charge.
|
||||||
|
|
||||||
|
Three worker threads and no async runtime - the whole client makes one library request,
|
||||||
|
one websocket connection and a trickle of covers.
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `src/api.rs` | The wire types and the HTTP calls. Mirrors `services/web/schemas.py`. |
|
||||||
|
| `src/ws.rs` | The state websocket, with reconnect-and-reseed. |
|
||||||
|
| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. |
|
||||||
|
| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. |
|
||||||
|
| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. |
|
||||||
|
| `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. |
|
||||||
|
| `src/main.rs` | Wiring: worker channels in, Slint models out. |
|
||||||
|
| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. |
|
||||||
|
|
||||||
|
`src/keymap.rs` and `src/library.rs` are ports of modules that were already pure
|
||||||
|
functions with their own tests, and they keep them: 43 tests, no window required.
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
The same map as the web UI.
|
||||||
|
|
||||||
|
| Key | |
|
||||||
|
|---|---|
|
||||||
|
| `SPACE` | play / pause (types a space mid-query) |
|
||||||
|
| `SHIFT+H` / `SHIFT+L` | previous / next |
|
||||||
|
| `SHIFT+J` / `SHIFT+K` | quieter / louder |
|
||||||
|
| `SHIFT+M` | mute |
|
||||||
|
| `SHIFT+←` / `SHIFT+→` | seek 15 s |
|
||||||
|
| `A-Z`, `0-9` | search albums as you type |
|
||||||
|
| `?` | search individual tracks |
|
||||||
|
| `TAB` | cycle Musik / Hörbücher / Podcasts |
|
||||||
|
| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing |
|
||||||
|
| `CTRL+H/J/K/L` | the same, without leaving the home row |
|
||||||
|
| `CTRL+D` / `CTRL+U` | half a page |
|
||||||
|
| `ENTER` | play the selection |
|
||||||
|
| `SHIFT+ENTER` | open the track list |
|
||||||
|
| `ESC` | back out one layer at a time |
|
||||||
|
| `/` | back to browsing |
|
||||||
|
|
||||||
|
Everything is clickable too.
|
||||||
|
|
||||||
|
The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and
|
||||||
|
reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable
|
||||||
|
without a window and without hardcoding keysym values.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
Nunito, via `SLINT_DEFAULT_FONT`, which `shell.nix` sets. Slint cannot read the woff2
|
||||||
|
the web app self-hosts, so this wants the TTF; a deployment can drop one at
|
||||||
|
`fonts/Nunito.ttf` instead. With neither, everything still renders in the system sans.
|
||||||
3
slint-frontend/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
slint_build::compile("ui/app.slint").expect("compiling ui/app.slint");
|
||||||
|
}
|
||||||
BIN
slint-frontend/fonts/Nunito.ttf
Normal file
9
slint-frontend/fonts/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Nunito, the face the design is drawn in, as a variable TTF.
|
||||||
|
|
||||||
|
`web/public/fonts/` has the same family as woff2, which is right for a browser and
|
||||||
|
useless here: Slint cannot read woff2. This is the upstream variable TTF (weights
|
||||||
|
200-1000), which `SLINT_DEFAULT_FONT` is pointed at - by `shell.nix` for development,
|
||||||
|
and by the launcher `roles/pi_slint_frontend` installs on the device.
|
||||||
|
|
||||||
|
Nunito is licensed under the SIL Open Font License 1.1:
|
||||||
|
https://github.com/googlefonts/nunito
|
||||||
62
slint-frontend/shell.nix
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Dev shell for the Slint front-end.
|
||||||
|
#
|
||||||
|
# NixOS has no rustup-installed toolchain here, and Slint's renderers link against
|
||||||
|
# system GL/X11/Wayland libraries that a plain `cargo build` cannot find on its own -
|
||||||
|
# hence LD_LIBRARY_PATH below rather than just a list of buildInputs.
|
||||||
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
|
|
||||||
|
let
|
||||||
|
# Everything the winit backend dlopen()s at runtime. Missing one of these shows up
|
||||||
|
# as a panic inside winit, not as a link error, so they are easy to misdiagnose.
|
||||||
|
runtimeLibs = with pkgs; [
|
||||||
|
libGL
|
||||||
|
libxkbcommon
|
||||||
|
wayland
|
||||||
|
xorg.libX11
|
||||||
|
xorg.libXcursor
|
||||||
|
xorg.libXi
|
||||||
|
xorg.libXrandr
|
||||||
|
fontconfig
|
||||||
|
freetype
|
||||||
|
];
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
cargo
|
||||||
|
rustc
|
||||||
|
rustfmt
|
||||||
|
clippy
|
||||||
|
pkg-config
|
||||||
|
# Only needed when building with --features skia: rust-skia compiles its own
|
||||||
|
# bindings shim and wants a C++ toolchain plus python for the GN build.
|
||||||
|
clang
|
||||||
|
python3
|
||||||
|
];
|
||||||
|
|
||||||
|
# Nunito is the face the design is drawn in. `SLINT_DEFAULT_FONT` takes one file and
|
||||||
|
# makes it the primary font. This points at the copy vendored in `fonts/` rather than
|
||||||
|
# at nixpkgs, so development and the device render from the identical file.
|
||||||
|
SLINT_DEFAULT_FONT = toString ./fonts/Nunito.ttf;
|
||||||
|
|
||||||
|
buildInputs = runtimeLibs ++ (with pkgs; [
|
||||||
|
fontconfig
|
||||||
|
# libinput/libdrm/libseat are the linuxkms (kiosk) backend's dependencies. They
|
||||||
|
# cost nothing to have present on a desktop build.
|
||||||
|
libinput
|
||||||
|
libdrm
|
||||||
|
seatd
|
||||||
|
udev
|
||||||
|
]);
|
||||||
|
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||||
|
|
||||||
|
# rust-skia downloads a prebuilt libskia rather than building it, when it can.
|
||||||
|
SKIA_BINARIES_URL_TEMPLATE = null;
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "musicmouse slint-frontend — cargo $(cargo --version | cut -d' ' -f2)"
|
||||||
|
echo " cargo run # windowed, femtovg"
|
||||||
|
echo " cargo run --features skia # windowed, skia (what the Pi uses)"
|
||||||
|
echo " cargo build --features kiosk # linuxkms + skia, for the device"
|
||||||
|
'';
|
||||||
|
}
|
||||||
312
slint-frontend/src/api.rs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
//! The shapes the backend serves, and the calls that fetch them.
|
||||||
|
//!
|
||||||
|
//! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of
|
||||||
|
//! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's
|
||||||
|
//! share of the API is here; settings, the IR remote, Home Assistant and the typing
|
||||||
|
//! game are all reachable and all deliberately absent.
|
||||||
|
//!
|
||||||
|
//! Every field the backend can omit is modelled with `#[serde(default)]` rather than a
|
||||||
|
//! required `Option`, so a backend that grows a field does not break this client and
|
||||||
|
//! one that loses a field fails at the one call site that cared.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Default for a development run; `--server` overrides it.
|
||||||
|
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AlbumKind {
|
||||||
|
#[default]
|
||||||
|
Music,
|
||||||
|
Book,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumKind {
|
||||||
|
/// Shape is how you tell the two apart without reading anything: albums square,
|
||||||
|
/// audiobooks taller than wide, everywhere they appear. Nothing else in this app
|
||||||
|
/// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`.
|
||||||
|
pub fn aspect(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Music => 1.0,
|
||||||
|
Self::Book => 0.82,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Track {
|
||||||
|
pub title: String,
|
||||||
|
/// Seconds, read from the file's tags at scan time. `0.0` when it carries none.
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: f64,
|
||||||
|
/// A typing-reward track not yet earned. This front-end does not host the typing
|
||||||
|
/// game, but it still has to not show what the game is withholding.
|
||||||
|
#[serde(default)]
|
||||||
|
pub locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// `series`, and the parts of `PlayerState` the UI does not read, are kept because these
|
||||||
|
// structs are the record of what the backend serves - dropping a field would make this
|
||||||
|
// file a list of what this front-end happens to use today, which is a much less useful
|
||||||
|
// thing to read next to `schemas.py`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Album {
|
||||||
|
pub id: String,
|
||||||
|
/// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`,
|
||||||
|
/// `Kinderpodcasts`. What `Group::of` reads to spot a podcast.
|
||||||
|
pub section: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: AlbumKind,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub series: Option<String>,
|
||||||
|
/// The figurine whose folder this is, when it has one.
|
||||||
|
#[serde(default)]
|
||||||
|
pub figure: Option<String>,
|
||||||
|
/// Series for audiobooks and podcasts, artist for music. What browsing groups by.
|
||||||
|
#[serde(default)]
|
||||||
|
pub category: String,
|
||||||
|
/// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art
|
||||||
|
/// or synthesised from the id. The generated stand-in cover is painted from these.
|
||||||
|
#[serde(default)]
|
||||||
|
pub colors: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_cover: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Album {
|
||||||
|
pub fn is_book(&self) -> bool {
|
||||||
|
self.kind == AlbumKind::Book
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"Album · Artist"`, unless a podcast makes those the same string.
|
||||||
|
pub fn line(&self) -> String {
|
||||||
|
let prefix = if self.is_book() { "📖 " } else { "" };
|
||||||
|
if !self.artist.is_empty() && self.artist != self.title {
|
||||||
|
format!("{prefix}{} · {}", self.title, self.artist)
|
||||||
|
} else {
|
||||||
|
format!("{prefix}{}", self.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `colors` padded out to the three the painter wants, with the same fallbacks the
|
||||||
|
/// web client uses, so a library entry missing them looks the same in both.
|
||||||
|
pub fn palette(&self) -> [&str; 3] {
|
||||||
|
[
|
||||||
|
self.colors.first().map_or("#4a6fa5", String::as_str),
|
||||||
|
self.colors.get(1).map_or("#6a8fc5", String::as_str),
|
||||||
|
self.colors.get(2).map_or("#a5804a", String::as_str),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct LibraryOut {
|
||||||
|
albums: Vec<Album>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct Connection {
|
||||||
|
#[serde(default)]
|
||||||
|
pub firmware: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mqtt: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lirc: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine
|
||||||
|
/// on the reader and Home Assistant all move it too, so this client follows it rather
|
||||||
|
/// than assuming it is in charge.
|
||||||
|
#[allow(dead_code)] // see the note above `Album`
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct PlayerState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub playing: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub album_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<AlbumKind>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track_index: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track_count: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub position: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: f64,
|
||||||
|
/// Percent, 0..100. The device's configured range never leaves the backend.
|
||||||
|
#[serde(default)]
|
||||||
|
pub volume: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_figure: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub connected: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The push-only websocket's frames. `library` carries no payload - it is a bare "your
|
||||||
|
/// index is stale, fetch it again" after a rescan.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
State {
|
||||||
|
state: PlayerState,
|
||||||
|
},
|
||||||
|
Position {
|
||||||
|
position: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
duration: f64,
|
||||||
|
},
|
||||||
|
Library,
|
||||||
|
/// A frame this build does not know. Ignored rather than fatal, so the backend can
|
||||||
|
/// grow a message type without this client falling over on it.
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- client --
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Client {
|
||||||
|
base: String,
|
||||||
|
agent: ureq::Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enough of an error type to put a cause on screen. Nothing here is recoverable in a
|
||||||
|
/// way the caller could branch on - the UI either has a library or says it cannot reach
|
||||||
|
/// the mouse - so the variants are not worth splitting.
|
||||||
|
pub type Error = String;
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new(base_url: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
base: base_url.trim_end_matches('/').to_string(),
|
||||||
|
agent: ureq::AgentBuilder::new()
|
||||||
|
.timeout_connect(std::time::Duration::from_secs(4))
|
||||||
|
.timeout_read(std::time::Duration::from_secs(30))
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but
|
||||||
|
/// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working.
|
||||||
|
pub fn websocket_url(&self) -> String {
|
||||||
|
let ws = if let Some(rest) = self.base.strip_prefix("https://") {
|
||||||
|
format!("wss://{rest}")
|
||||||
|
} else if let Some(rest) = self.base.strip_prefix("http://") {
|
||||||
|
format!("ws://{rest}")
|
||||||
|
} else {
|
||||||
|
format!("ws://{}", self.base)
|
||||||
|
};
|
||||||
|
format!("{ws}/api/ws")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cover_url(&self, album_id: &str) -> String {
|
||||||
|
format!("{}/api/albums/{album_id}/cover", self.base)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn library(&self) -> Result<Vec<Album>, Error> {
|
||||||
|
let out: LibraryOut = self
|
||||||
|
.agent
|
||||||
|
.get(&format!("{}/api/library", self.base))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| format!("GET /api/library: {e}"))?
|
||||||
|
.into_json()
|
||||||
|
.map_err(|e| format!("GET /api/library: bad JSON: {e}"))?;
|
||||||
|
Ok(out.albums)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> Result<PlayerState, Error> {
|
||||||
|
self.agent
|
||||||
|
.get(&format!("{}/api/state", self.base))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| format!("GET /api/state: {e}"))?
|
||||||
|
.into_json()
|
||||||
|
.map_err(|e| format!("GET /api/state: bad JSON: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a
|
||||||
|
/// failure: `has_cover` says so in advance and the client paints the album's own
|
||||||
|
/// colours instead.
|
||||||
|
pub fn cover(&self, album_id: &str) -> Result<Option<Vec<u8>>, Error> {
|
||||||
|
let response = match self.agent.get(&self.cover_url(album_id)).call() {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||||
|
Err(e) => return Err(format!("GET cover {album_id}: {e}")),
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)
|
||||||
|
.map_err(|e| format!("GET cover {album_id}: {e}"))?;
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- commands --
|
||||||
|
//
|
||||||
|
// Every one of these emits the same *intent* the physical buttons emit, so this UI
|
||||||
|
// has no privileged path and no way to get out of step with a figure someone puts
|
||||||
|
// on the reader. They all answer 204 with no body - nothing to parse.
|
||||||
|
|
||||||
|
fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
||||||
|
let request = self.agent.post(&format!("{}/api{path}", self.base));
|
||||||
|
let result = if body.is_null() {
|
||||||
|
request.call()
|
||||||
|
} else {
|
||||||
|
request.send_json(body)
|
||||||
|
};
|
||||||
|
result.map(|_| ()).map_err(|e| format!("POST {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> {
|
||||||
|
self.command(
|
||||||
|
"/play",
|
||||||
|
serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&self) -> Result<(), Error> {
|
||||||
|
self.command("/resume", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self) -> Result<(), Error> {
|
||||||
|
self.command("/pause", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&self) -> Result<(), Error> {
|
||||||
|
self.command("/next", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous(&self) -> Result<(), Error> {
|
||||||
|
self.command("/previous", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, position: f64) -> Result<(), Error> {
|
||||||
|
self.command(
|
||||||
|
"/seek",
|
||||||
|
serde_json::json!({ "position": position.max(0.0) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn volume(&self, percent: i32) -> Result<(), Error> {
|
||||||
|
self.command(
|
||||||
|
"/volume",
|
||||||
|
serde_json::json!({ "percent": percent.clamp(0, 100) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
406
slint-frontend/src/covers.rs
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
//! Album art: fetched once, downscaled once, then reused forever.
|
||||||
|
//!
|
||||||
|
//! The backend serves one size and only one - `MAX_COVER_PX = 640` on the longest edge,
|
||||||
|
//! JPEG, at `/api/albums/{id}/cover`, with no `?size=` to ask for less. A browse grid
|
||||||
|
//! draws that at 192 px and the play view at 384, so painting the served file directly
|
||||||
|
//! means decoding a 640x640 JPEG for every visible card on every cold start. At 343
|
||||||
|
//! albums on a Pi that is the whole of "the UI feels slow".
|
||||||
|
//!
|
||||||
|
//! So this keeps a thumbnail cache on disk, keyed by album id *and* the size that was
|
||||||
|
//! asked for, and the decode-and-resize cost is paid once ever rather than once per
|
||||||
|
//! launch. A miss goes to the network on a worker thread; the UI never blocks on one.
|
||||||
|
//!
|
||||||
|
//! **Invalidation.** The bytes behind a cover URL do change while the URL does not -
|
||||||
|
//! the id addresses which album the art belongs to, not which art. Rather than pay a
|
||||||
|
//! conditional request per cover per start, this cache is cleared wholesale when the
|
||||||
|
//! websocket announces a rescan (`{"type": "library"}`), which is the one moment the
|
||||||
|
//! backend reprocesses cover art. See the long comment at
|
||||||
|
//! `python-backend/musicmouse/services/web/api.py:106` for how the web client learned
|
||||||
|
//! this the hard way.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use image::{Rgba, RgbaImage};
|
||||||
|
|
||||||
|
use crate::api::{AlbumKind, Client};
|
||||||
|
use crate::library::Group;
|
||||||
|
use crate::oklch::{mix, oklch_to_rgb, parse_hex};
|
||||||
|
|
||||||
|
/// The two sizes anything on screen actually asks for. Keeping it to two is what makes
|
||||||
|
/// the disk cache worth having: a per-widget pixel size would give every album a dozen
|
||||||
|
/// near-identical files and a fresh decode for each.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Tier {
|
||||||
|
/// Grid cards, shelf tiles, track rows and the player bar's 56 px thumbnail, which
|
||||||
|
/// scales down from this rather than earning a tier of its own.
|
||||||
|
Thumb,
|
||||||
|
/// The play view's cover, and the album sheet's.
|
||||||
|
Hero,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tier {
|
||||||
|
pub fn px(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::Thumb => 192,
|
||||||
|
Self::Hero => 384,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Key {
|
||||||
|
pub album_id: String,
|
||||||
|
pub tier: Tier,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the worker needs about an album, flattened so it can cross a thread
|
||||||
|
/// boundary without the `Rc<Album>` the UI side holds.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Request {
|
||||||
|
pub album_id: String,
|
||||||
|
pub tier: Tier,
|
||||||
|
pub has_cover: bool,
|
||||||
|
pub is_book: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub colors: [String; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished bitmap on its way back to the UI thread. Raw RGBA rather than a
|
||||||
|
/// `slint::Image`, because the image types are built on the UI thread by whoever
|
||||||
|
/// receives this.
|
||||||
|
pub struct Ready {
|
||||||
|
pub key: Key,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ generated art --
|
||||||
|
|
||||||
|
/// Diagonal two-tone stripes: the stand-in for a music cover, and the thing that shows
|
||||||
|
/// through underneath one that has not arrived yet.
|
||||||
|
fn stripes(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
||||||
|
// `max(6, size / 13)` in `web/src/lib/covers.ts` - the stripe scales with the art so
|
||||||
|
// a 56 px thumbnail does not turn into a grey smear of forty bands.
|
||||||
|
let band = (width / 13).max(6) as f32;
|
||||||
|
RgbaImage::from_fn(width, height, |x, y| {
|
||||||
|
// 135deg in CSS points the gradient line at the bottom-right corner, so the
|
||||||
|
// position along it is the projection of (x, y) onto (1, 1)/sqrt(2).
|
||||||
|
let along = (x + y) as f32 * std::f32::consts::FRAC_1_SQRT_2;
|
||||||
|
let c = if ((along / band) as u32).is_multiple_of(2) {
|
||||||
|
primary
|
||||||
|
} else {
|
||||||
|
secondary
|
||||||
|
};
|
||||||
|
Rgba([c[0], c[1], c[2], 255])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A book: page edges down the right, a darker board down the left, over a diagonal
|
||||||
|
/// wash. Four layers in CSS, four passes here, in the same order.
|
||||||
|
fn spine(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
||||||
|
// The audiobook shelf's own hue - warm amber, not a duller yellow-brown - so a
|
||||||
|
// generated spine and the shelf it sits on are the same family by construction.
|
||||||
|
let hue = Group::Audiobooks.hue();
|
||||||
|
let page_light = oklch_to_rgb(0.97, 0.02, hue);
|
||||||
|
let page_dark = oklch_to_rgb(0.90, 0.03, hue);
|
||||||
|
|
||||||
|
RgbaImage::from_fn(width, height, |x, y| {
|
||||||
|
let fx = x as f32 / width.max(1) as f32;
|
||||||
|
let fy = y as f32 / height.max(1) as f32;
|
||||||
|
|
||||||
|
// Layer 4 (bottom): linear-gradient(155deg, primary -> secondary).
|
||||||
|
let t = ((fx * 0.42 + fy * 0.91) / 1.33).clamp(0.0, 1.0);
|
||||||
|
let mut c = mix(primary, secondary, t);
|
||||||
|
|
||||||
|
// Layer 3: the board down the left edge.
|
||||||
|
if fx < 0.11 {
|
||||||
|
c = secondary;
|
||||||
|
} else if fx < 0.13 {
|
||||||
|
c = primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: two thin highlight rules over the board.
|
||||||
|
if (0.035..0.043).contains(&fx) || (0.070..0.078).contains(&fx) {
|
||||||
|
c = mix(c, [255, 255, 255], 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 1 (top): the page block on the right.
|
||||||
|
if fx >= 0.975 {
|
||||||
|
c = page_dark;
|
||||||
|
} else if fx >= 0.95 {
|
||||||
|
c = page_light;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rgba([c[0], c[1], c[2], 255])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an album looks like before - or instead of - its real art.
|
||||||
|
pub fn generated(request: &Request) -> RgbaImage {
|
||||||
|
let primary = parse_hex(&request.colors[0], [0x4a, 0x6f, 0xa5]);
|
||||||
|
let secondary = parse_hex(&request.colors[1], [0x6a, 0x8f, 0xc5]);
|
||||||
|
let px = request.tier.px();
|
||||||
|
let (width, height) = dimensions(px, request.is_book);
|
||||||
|
if request.is_book {
|
||||||
|
spine(width, height, primary, secondary)
|
||||||
|
} else {
|
||||||
|
stripes(width, height, primary, secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape comes from the media type and from nowhere else: albums square, audiobooks
|
||||||
|
/// taller than wide. `px` fixes the longest edge either way.
|
||||||
|
fn dimensions(px: u32, is_book: bool) -> (u32, u32) {
|
||||||
|
let kind = if is_book {
|
||||||
|
AlbumKind::Book
|
||||||
|
} else {
|
||||||
|
AlbumKind::Music
|
||||||
|
};
|
||||||
|
(((px as f32) * kind.aspect()).round() as u32, px)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- worker --
|
||||||
|
|
||||||
|
pub struct Worker {
|
||||||
|
client: Client,
|
||||||
|
dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worker {
|
||||||
|
pub fn new(client: Client) -> Self {
|
||||||
|
let dir = dirs::cache_dir().map(|d| d.join("musicmouse-slint").join("covers"));
|
||||||
|
if let Some(dir) = &dir {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||||
|
// Not fatal: without a disk cache every cover is simply fetched and
|
||||||
|
// resized again next launch, which is the old behaviour, not a failure.
|
||||||
|
eprintln!("cover cache unavailable at {}: {e}", dir.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { client, dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self, key: &Key) -> Option<PathBuf> {
|
||||||
|
self.dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.join(format!("{}@{}.jpg", key.album_id, key.tier.px())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every stored thumbnail. Called when the backend says it has rescanned, and
|
||||||
|
/// therefore possibly rewritten the art behind these ids.
|
||||||
|
pub fn clear_disk(&self) {
|
||||||
|
let Some(dir) = &self.dir else { return };
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, request: &Request) -> RgbaImage {
|
||||||
|
let px = request.tier.px();
|
||||||
|
let (want_w, want_h) = dimensions(px, request.is_book);
|
||||||
|
|
||||||
|
// A locked album is one the typing game is withholding. It has real art, and
|
||||||
|
// showing it would give away the reward, so it never gets fetched at all.
|
||||||
|
if request.locked || !request.has_cover {
|
||||||
|
return generated(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = self.path(&Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
}) {
|
||||||
|
if let Ok(image) = image::open(&path) {
|
||||||
|
return image.to_rgba8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = match self.client.cover(&request.album_id) {
|
||||||
|
Ok(Some(bytes)) => bytes,
|
||||||
|
// 404 is a normal answer - `has_cover` can be stale by a rescan - and a
|
||||||
|
// network error should show the album, not a hole.
|
||||||
|
Ok(None) | Err(_) => return generated(request),
|
||||||
|
};
|
||||||
|
let Ok(decoded) = image::load_from_memory(&bytes) else {
|
||||||
|
return generated(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
// `resize_to_fill` rather than `resize`: a real cover is square but a book's
|
||||||
|
// slot is not, and letterboxing one inside the other would break the shelf
|
||||||
|
// metaphor that the whole browse screen is built on.
|
||||||
|
let thumb = decoded.resize_to_fill(want_w, want_h, FilterType::CatmullRom);
|
||||||
|
|
||||||
|
if let Some(path) = self.path(&Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
}) {
|
||||||
|
// Storing JPEG, not PNG: these are photographs, so JPEG is both smaller on
|
||||||
|
// disk and faster to decode on the next start, which is the whole point.
|
||||||
|
let _ = thumb
|
||||||
|
.to_rgb8()
|
||||||
|
.save_with_format(&path, image::ImageFormat::Jpeg);
|
||||||
|
}
|
||||||
|
thumb.to_rgba8()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the fetch/decode/resize loop off the UI thread, calling `deliver` for each
|
||||||
|
/// finished bitmap. `deliver` is expected to hop back onto the Slint event loop.
|
||||||
|
pub fn spawn(
|
||||||
|
client: Client,
|
||||||
|
jobs: Receiver<Job>,
|
||||||
|
deliver: impl Fn(Ready) + Send + 'static,
|
||||||
|
) -> std::thread::JoinHandle<()> {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("covers".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let worker = Worker::new(client);
|
||||||
|
// A cover asked for while it is already in flight - a card scrolled past
|
||||||
|
// twice, a grid rebuilt on a keystroke - must not become a second fetch.
|
||||||
|
let mut done: HashSet<Key> = HashSet::new();
|
||||||
|
for job in jobs {
|
||||||
|
match job {
|
||||||
|
Job::Clear => {
|
||||||
|
worker.clear_disk();
|
||||||
|
done.clear();
|
||||||
|
}
|
||||||
|
Job::Fetch(request) => {
|
||||||
|
let key = Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
};
|
||||||
|
if !done.insert(key.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let image = worker.render(&request);
|
||||||
|
deliver(Ready {
|
||||||
|
key,
|
||||||
|
width: image.width(),
|
||||||
|
height: image.height(),
|
||||||
|
rgba: image.into_raw(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("spawning the cover worker")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Job {
|
||||||
|
Fetch(Request),
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI thread's half: what is already decoded, and what has been asked for.
|
||||||
|
pub struct Store {
|
||||||
|
jobs: Sender<Job>,
|
||||||
|
ready: HashMap<Key, slint::Image>,
|
||||||
|
requested: HashSet<Key>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn new(jobs: Sender<Job>) -> Self {
|
||||||
|
Self {
|
||||||
|
jobs,
|
||||||
|
ready: HashMap::new(),
|
||||||
|
requested: HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, ready: Ready) {
|
||||||
|
let mut buffer =
|
||||||
|
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(ready.width, ready.height);
|
||||||
|
buffer.make_mut_bytes().copy_from_slice(&ready.rgba);
|
||||||
|
self.ready
|
||||||
|
.insert(ready.key, slint::Image::from_rgba8(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cover for an album if it is decoded, and a fetch queued if it is not. Callers
|
||||||
|
/// paint the album's own colours in the meantime, so this returning `None` is a
|
||||||
|
/// normal frame rather than a missing one.
|
||||||
|
pub fn get(&mut self, request: Request) -> Option<slint::Image> {
|
||||||
|
let key = Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
};
|
||||||
|
if let Some(image) = self.ready.get(&key) {
|
||||||
|
return Some(image.clone());
|
||||||
|
}
|
||||||
|
if self.requested.insert(key) {
|
||||||
|
let _ = self.jobs.send(Job::Fetch(request));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.ready.clear();
|
||||||
|
self.requested.clear();
|
||||||
|
let _ = self.jobs.send(Job::Clear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn request(is_book: bool, tier: Tier) -> Request {
|
||||||
|
Request {
|
||||||
|
album_id: "abc".into(),
|
||||||
|
tier,
|
||||||
|
has_cover: false,
|
||||||
|
is_book,
|
||||||
|
locked: false,
|
||||||
|
colors: ["#4a6fa5".into(), "#6a8fc5".into(), "#a5804a".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_follows_the_media_type() {
|
||||||
|
assert_eq!(dimensions(192, false), (192, 192));
|
||||||
|
assert_eq!(dimensions(192, true), (157, 192));
|
||||||
|
assert_eq!(dimensions(384, true), (315, 384));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_art_fills_the_tier_it_was_asked_for() {
|
||||||
|
let music = generated(&request(false, Tier::Thumb));
|
||||||
|
assert_eq!((music.width(), music.height()), (192, 192));
|
||||||
|
let book = generated(&request(true, Tier::Hero));
|
||||||
|
assert_eq!((book.width(), book.height()), (315, 384));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stripes_actually_alternate() {
|
||||||
|
let art = generated(&request(false, Tier::Thumb));
|
||||||
|
let colours: HashSet<[u8; 3]> = (0..192)
|
||||||
|
.map(|x| {
|
||||||
|
let p = art.get_pixel(x, 0).0;
|
||||||
|
[p[0], p[1], p[2]]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(colours.len(), 2, "expected exactly the two stripe colours");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_book_has_bright_page_edges_on_its_right() {
|
||||||
|
let art = generated(&request(true, Tier::Hero));
|
||||||
|
let (w, h) = (art.width(), art.height());
|
||||||
|
let edge = art.get_pixel(w - 2, h / 2).0;
|
||||||
|
let middle = art.get_pixel(w / 2, h / 2).0;
|
||||||
|
let brightness = |p: [u8; 4]| p[0] as u32 + p[1] as u32 + p[2] as u32;
|
||||||
|
assert!(
|
||||||
|
brightness(edge) > brightness(middle),
|
||||||
|
"page edges should be lighter than the cover"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_generated_pixel_is_opaque() {
|
||||||
|
let art = generated(&request(true, Tier::Thumb));
|
||||||
|
assert!(art.pixels().all(|p| p.0[3] == 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
61
slint-frontend/src/format.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Durations, as the screen says them. Ported from `web/src/lib/format.ts`.
|
||||||
|
|
||||||
|
/// `m:ss`, or `h:mm:ss` once there is an hour to show. Negative and non-finite inputs
|
||||||
|
/// collapse to `0:00` rather than rendering `-1:-3`, because both reach here honestly:
|
||||||
|
/// a position can briefly exceed a tag-derived duration, and a track with no duration
|
||||||
|
/// tag at all arrives as `0.0`.
|
||||||
|
pub fn clock(seconds: f64) -> String {
|
||||||
|
if !seconds.is_finite() || seconds <= 0.0 {
|
||||||
|
return "0:00".into();
|
||||||
|
}
|
||||||
|
let total = seconds.round() as u64;
|
||||||
|
let (hours, minutes, secs) = (total / 3600, (total % 3600) / 60, total % 60);
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}:{minutes:02}:{secs:02}")
|
||||||
|
} else {
|
||||||
|
format!("{minutes}:{secs:02}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the album is left: the rest of this track plus every track after it.
|
||||||
|
pub fn remaining_in_album(durations: &[f64], track_index: usize, position: f64) -> f64 {
|
||||||
|
let current = durations.get(track_index).copied().unwrap_or(0.0);
|
||||||
|
let rest: f64 = durations.iter().skip(track_index + 1).sum();
|
||||||
|
(current - position).max(0.0) + rest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_formats_minutes_and_hours() {
|
||||||
|
assert_eq!(clock(0.0), "0:00");
|
||||||
|
assert_eq!(clock(9.0), "0:09");
|
||||||
|
assert_eq!(clock(61.0), "1:01");
|
||||||
|
assert_eq!(clock(599.0), "9:59");
|
||||||
|
assert_eq!(clock(3600.0), "1:00:00");
|
||||||
|
assert_eq!(clock(12130.0), "3:22:10");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_survives_the_inputs_a_real_player_produces() {
|
||||||
|
assert_eq!(clock(-4.0), "0:00");
|
||||||
|
assert_eq!(clock(f64::NAN), "0:00");
|
||||||
|
assert_eq!(clock(f64::INFINITY), "0:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remaining_counts_this_track_and_the_ones_after_it() {
|
||||||
|
let durations = [100.0, 200.0, 300.0];
|
||||||
|
assert_eq!(remaining_in_album(&durations, 0, 0.0), 600.0);
|
||||||
|
assert_eq!(remaining_in_album(&durations, 1, 50.0), 450.0);
|
||||||
|
assert_eq!(remaining_in_album(&durations, 2, 300.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_position_past_the_tagged_duration_does_not_go_negative() {
|
||||||
|
assert_eq!(remaining_in_album(&[100.0, 50.0], 0, 130.0), 50.0);
|
||||||
|
assert_eq!(remaining_in_album(&[], 7, 10.0), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
833
slint-frontend/src/keymap.rs
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
//! What you are looking at, and what a keypress does to it.
|
||||||
|
//!
|
||||||
|
//! Ported from `web/src/lib/keyboard.ts`, which is already a pure function from a
|
||||||
|
//! keypress to a list of actions - the single most portable thing in that codebase, and
|
||||||
|
//! tested there without a DOM for the same reason it is tested here without a window.
|
||||||
|
//!
|
||||||
|
//! The split this rests on: **what is playing** comes from the backend, **what you are
|
||||||
|
//! looking at** lives here. A real player has other front-ends - the buttons on the
|
||||||
|
//! mouse, a figurine on the reader, Home Assistant - so this UI follows playback rather
|
||||||
|
//! than owning it, and owns only the browse state.
|
||||||
|
//!
|
||||||
|
//! Not ported: the typing game, the room-lights page, the in-app help overlay and the
|
||||||
|
//! IR-remote assign mode, none of which this front-end hosts.
|
||||||
|
|
||||||
|
use crate::library::{Group, Results, GROUPS};
|
||||||
|
|
||||||
|
pub const VOLUME_STEP: i32 = 10;
|
||||||
|
pub const SEEK_STEP: f64 = 15.0;
|
||||||
|
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
|
||||||
|
pub const PAGE_ROWS: i32 = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct UiState {
|
||||||
|
pub search: String,
|
||||||
|
pub tracks_mode: bool,
|
||||||
|
/// `None` is the bare root screen (three shelves); otherwise which one is open.
|
||||||
|
pub group: Option<Group>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub sel_index: usize,
|
||||||
|
/// Which of the three root shelves is focused. Only meaningful at the bare root,
|
||||||
|
/// where `sel_index` doubles as "which tile in that row".
|
||||||
|
pub shelf_row: usize,
|
||||||
|
pub play_view: bool,
|
||||||
|
pub open_album: Option<String>,
|
||||||
|
/// Which track is highlighted in the open album's list.
|
||||||
|
pub sheet_index: usize,
|
||||||
|
/// Grid columns, recomputed from the window width - `move_selection` needs it to
|
||||||
|
/// know what one row down means.
|
||||||
|
pub cols: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
search: String::new(),
|
||||||
|
tracks_mode: false,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
sel_index: 0,
|
||||||
|
shelf_row: 0,
|
||||||
|
play_view: false,
|
||||||
|
open_album: None,
|
||||||
|
sheet_index: 0,
|
||||||
|
cols: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiState {
|
||||||
|
/// The true root: nothing chosen yet, drawn as three shelves rather than a list.
|
||||||
|
pub fn is_root_shelf(&self) -> bool {
|
||||||
|
self.group.is_none()
|
||||||
|
&& self.search.is_empty()
|
||||||
|
&& !self.tracks_mode
|
||||||
|
&& self.category.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browsing(&self) -> bool {
|
||||||
|
!self.play_view && self.open_album.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step of "back": search, then track-search mode, then group and category
|
||||||
|
/// together - the order ESC and the corner button both use.
|
||||||
|
///
|
||||||
|
/// `group` and `category` clear as one step because a root shelf tile sets both at
|
||||||
|
/// once, jumping straight to one category's albums; undoing that jump should be one
|
||||||
|
/// step too, however the category was reached.
|
||||||
|
pub fn browse_back(&mut self) {
|
||||||
|
self.sel_index = 0;
|
||||||
|
if !self.search.is_empty() {
|
||||||
|
self.search.clear();
|
||||||
|
} else if self.tracks_mode {
|
||||||
|
self.tracks_mode = false;
|
||||||
|
} else {
|
||||||
|
self.group = None;
|
||||||
|
self.category = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether anything is left to back out of - what the corner button's presence is.
|
||||||
|
pub fn can_go_back(&self) -> bool {
|
||||||
|
self.play_view
|
||||||
|
|| self.open_album.is_some()
|
||||||
|
|| !self.search.is_empty()
|
||||||
|
|| self.tracks_mode
|
||||||
|
|| self.group.is_some()
|
||||||
|
|| self.category.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a keypress asks the *player* to do. Everything else a key can do is a change to
|
||||||
|
/// [`UiState`], applied in place.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Action {
|
||||||
|
Play {
|
||||||
|
album_id: String,
|
||||||
|
track_index: usize,
|
||||||
|
},
|
||||||
|
Toggle,
|
||||||
|
Next,
|
||||||
|
Previous,
|
||||||
|
/// Percentage points, positive or negative.
|
||||||
|
Volume(i32),
|
||||||
|
/// Seconds, positive or negative.
|
||||||
|
Seek(f64),
|
||||||
|
Mute,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the flat selection index is pointing. Songs, then categories, then albums.
|
||||||
|
enum Selection {
|
||||||
|
Song {
|
||||||
|
album_id: String,
|
||||||
|
track_index: usize,
|
||||||
|
},
|
||||||
|
Category(String),
|
||||||
|
Album(String),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_at(results: &Results, sel_index: usize) -> Selection {
|
||||||
|
let total = results.total();
|
||||||
|
if total == 0 {
|
||||||
|
return Selection::None;
|
||||||
|
}
|
||||||
|
let index = sel_index.min(total - 1);
|
||||||
|
if let Some(hit) = results.songs.get(index) {
|
||||||
|
return Selection::Song {
|
||||||
|
album_id: hit.album.id.clone(),
|
||||||
|
track_index: hit.index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let after_songs = index - results.songs.len();
|
||||||
|
if let Some(category) = results.categories.get(after_songs) {
|
||||||
|
return Selection::Category(category.key.clone());
|
||||||
|
}
|
||||||
|
match results.albums.get(after_songs - results.categories.len()) {
|
||||||
|
Some(album) => Selection::Album(album.id.clone()),
|
||||||
|
None => Selection::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The album behind the selection, when "show me the tracks" makes sense for it: an
|
||||||
|
/// album tile directly, or - so you can reach the rest of it - the album behind a song
|
||||||
|
/// hit. `None` for a category tile, and for a podcast episode, which is a single track
|
||||||
|
/// with nothing else to show.
|
||||||
|
pub fn selection_album(results: &Results, sel_index: usize) -> Option<String> {
|
||||||
|
let total = results.total();
|
||||||
|
if total == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let index = sel_index.min(total - 1);
|
||||||
|
let album = if let Some(hit) = results.songs.get(index) {
|
||||||
|
Some(&hit.album)
|
||||||
|
} else {
|
||||||
|
let after_songs = index - results.songs.len();
|
||||||
|
if after_songs < results.categories.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
results.albums.get(after_songs - results.categories.len())
|
||||||
|
}?;
|
||||||
|
(Group::of(album) != Group::Podcasts).then(|| album.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bare root is a genuine two-axis layout: three rows, each a differently-long row
|
||||||
|
/// of tiles. `shelf_lengths` is how long each row actually is, so a vertical move can
|
||||||
|
/// clamp the column to the row it lands on rather than guessing.
|
||||||
|
fn move_root_shelf(state: &mut UiState, dx: i32, dy: i32, shelf_lengths: &[usize; 3]) {
|
||||||
|
if dy != 0 {
|
||||||
|
state.shelf_row = (state.shelf_row as i32 + dy).clamp(0, GROUPS.len() as i32 - 1) as usize;
|
||||||
|
state.sel_index = state
|
||||||
|
.sel_index
|
||||||
|
.min(shelf_lengths[state.shelf_row].saturating_sub(1));
|
||||||
|
}
|
||||||
|
if dx != 0 {
|
||||||
|
let last = shelf_lengths[state.shelf_row].saturating_sub(1) as i32;
|
||||||
|
state.sel_index = (state.sel_index as i32 + dx).clamp(0, last.max(0)) as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_selection(state: &mut UiState, results: &Results, dx: i32, dy: i32) {
|
||||||
|
let total = results.total();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cols = state.cols.max(1) as i32;
|
||||||
|
let mut index = state.sel_index.min(total - 1) as i32;
|
||||||
|
index += dx;
|
||||||
|
if dy != 0 {
|
||||||
|
// Song hits are a single-column list; everything below them is a grid.
|
||||||
|
index += if (index as usize) < results.songs.len() {
|
||||||
|
dy
|
||||||
|
} else {
|
||||||
|
dy * cols
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state.sel_index = index.clamp(0, total as i32 - 1) as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys that mean the same thing from anywhere, matching the muscle memory of every
|
||||||
|
/// other vim-ish media app. Keyed on the exact shifted letter, so this intercepts only
|
||||||
|
/// these five - every other Shift+letter still reaches the search box. Search is
|
||||||
|
/// case-insensitive, so nothing searchable is lost.
|
||||||
|
fn shift_media(key: &str) -> Option<Action> {
|
||||||
|
match key.to_ascii_uppercase().as_str() {
|
||||||
|
"H" => Some(Action::Previous),
|
||||||
|
"L" => Some(Action::Next),
|
||||||
|
"K" => Some(Action::Volume(VOLUME_STEP)),
|
||||||
|
"J" => Some(Action::Volume(-VOLUME_STEP)),
|
||||||
|
"M" => Some(Action::Mute),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain character key's default: type it into the search box. Letters and digits
|
||||||
|
/// only - `char::is_alphanumeric` rather than `[a-z0-9]`, so the umlauts a German title
|
||||||
|
/// needs are searchable too.
|
||||||
|
fn type_into_search(state: &mut UiState, key: &str) -> bool {
|
||||||
|
let mut chars = key.chars();
|
||||||
|
match (chars.next(), chars.next()) {
|
||||||
|
(Some(c), None) if c.is_alphanumeric() => {
|
||||||
|
state.search.push(c);
|
||||||
|
state.play_view = false;
|
||||||
|
state.sel_index = 0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate one keypress: mutate `state`, and return whatever the player should be
|
||||||
|
/// asked to do.
|
||||||
|
///
|
||||||
|
/// `key` is already named by the UI layer - `"escape"`, `"left"`, `"enter"`, … - or is
|
||||||
|
/// the literal character typed. `shelf_lengths` and `sheet_tracks` are the two counts
|
||||||
|
/// that `results` does not carry but the clamping needs.
|
||||||
|
pub fn handle_key(
|
||||||
|
key: &str,
|
||||||
|
ctrl: bool,
|
||||||
|
shift: bool,
|
||||||
|
state: &mut UiState,
|
||||||
|
results: &Results,
|
||||||
|
shelf_lengths: &[usize; 3],
|
||||||
|
sheet_tracks: usize,
|
||||||
|
) -> Vec<Action> {
|
||||||
|
// Reachable from anywhere, including from the play view and with an album sheet open.
|
||||||
|
if shift && !ctrl {
|
||||||
|
if let Some(action) = shift_media(key) {
|
||||||
|
return vec![action];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "space" {
|
||||||
|
// Except mid-search, where a space is punctuation the query needs, as in
|
||||||
|
// "geolino azte".
|
||||||
|
if state.browsing() && !state.search.is_empty() {
|
||||||
|
state.search.push(' ');
|
||||||
|
state.sel_index = 0;
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
return vec![Action::Toggle];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
|
||||||
|
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
|
||||||
|
if ctrl {
|
||||||
|
if state.open_album.is_some() {
|
||||||
|
match key.to_ascii_lowercase().as_str() {
|
||||||
|
"j" => {
|
||||||
|
state.sheet_index = (state.sheet_index + 1).min(sheet_tracks.saturating_sub(1))
|
||||||
|
}
|
||||||
|
"k" => state.sheet_index = state.sheet_index.saturating_sub(1),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if state.play_view {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let lower = key.to_ascii_lowercase();
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
match lower.as_str() {
|
||||||
|
"h" => move_root_shelf(state, -1, 0, shelf_lengths),
|
||||||
|
"l" => move_root_shelf(state, 1, 0, shelf_lengths),
|
||||||
|
"j" | "d" => move_root_shelf(state, 0, 1, shelf_lengths),
|
||||||
|
"k" | "u" => move_root_shelf(state, 0, -1, shelf_lengths),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
match lower.as_str() {
|
||||||
|
"h" => move_selection(state, results, -1, 0),
|
||||||
|
"l" => move_selection(state, results, 1, 0),
|
||||||
|
"j" => move_selection(state, results, 0, 1),
|
||||||
|
"k" => move_selection(state, results, 0, -1),
|
||||||
|
"d" => move_selection(state, results, 0, PAGE_ROWS),
|
||||||
|
"u" => move_selection(state, results, 0, -PAGE_ROWS),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let browsing = state.browsing();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"tab" => {
|
||||||
|
let current = state.group.map_or(-1, |g| g.index() as i32);
|
||||||
|
state.group = Some(Group::from_index(
|
||||||
|
((current + 1) % GROUPS.len() as i32) as usize,
|
||||||
|
));
|
||||||
|
state.category = None;
|
||||||
|
state.sel_index = 0;
|
||||||
|
state.play_view = false;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"/" => {
|
||||||
|
state.play_view = false;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrows browse while browsing and drive the player while playing - the one
|
||||||
|
// pair of keys that changes meaning with the screen, because on the play view
|
||||||
|
// there is no selection for them to move.
|
||||||
|
"right" => {
|
||||||
|
if shift {
|
||||||
|
return vec![Action::Seek(SEEK_STEP)];
|
||||||
|
}
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Next];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, 1, 0, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, 1, 0);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"left" => {
|
||||||
|
if shift {
|
||||||
|
return vec![Action::Seek(-SEEK_STEP)];
|
||||||
|
}
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Previous];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, -1, 0, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, -1, 0);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"down" => {
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Volume(-VOLUME_STEP)];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, 0, 1, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, 0, 1);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"up" => {
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Volume(VOLUME_STEP)];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, 0, -1, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, 0, -1);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
"?" => {
|
||||||
|
state.tracks_mode = true;
|
||||||
|
state.search.clear();
|
||||||
|
state.sel_index = 0;
|
||||||
|
state.play_view = false;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"escape" => {
|
||||||
|
// Peel one layer at a time rather than dumping you back at the top.
|
||||||
|
if state.open_album.is_some() {
|
||||||
|
state.open_album = None;
|
||||||
|
} else if state.play_view {
|
||||||
|
state.play_view = false;
|
||||||
|
} else {
|
||||||
|
state.browse_back();
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"backspace" => {
|
||||||
|
if !state.search.is_empty() {
|
||||||
|
state.search.pop();
|
||||||
|
state.sel_index = 0;
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"enter" => {
|
||||||
|
if let Some(album_id) = state.open_album.clone() {
|
||||||
|
return vec![Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index: state.sheet_index,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if browsing && state.is_root_shelf() {
|
||||||
|
let row = state.shelf_row.min(GROUPS.len() - 1);
|
||||||
|
state.group = Some(Group::from_index(row));
|
||||||
|
// A shelf row with no tiles still opens its group; there is simply no
|
||||||
|
// category to pre-select.
|
||||||
|
state.category = None;
|
||||||
|
state.sel_index = 0;
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if shift {
|
||||||
|
if let Some(album_id) = selection_album(results, state.sel_index) {
|
||||||
|
state.open_album = Some(album_id);
|
||||||
|
state.sheet_index = 0;
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match selection_at(results, state.sel_index) {
|
||||||
|
Selection::Song {
|
||||||
|
album_id,
|
||||||
|
track_index,
|
||||||
|
} => {
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
Selection::Album(album_id) => vec![Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index: 0,
|
||||||
|
}],
|
||||||
|
Selection::Category(key) => {
|
||||||
|
state.category = Some(key);
|
||||||
|
state.sel_index = 0;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
Selection::None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"f1" => vec![],
|
||||||
|
_ => {
|
||||||
|
type_into_search(state, key);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::library::{Category, Library, SongHit};
|
||||||
|
|
||||||
|
fn empty() -> Results {
|
||||||
|
Results::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(key: &str, state: &mut UiState) -> Vec<Action> {
|
||||||
|
handle_key(key, false, false, state, &empty(), &[0, 0, 0], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_a_letter_searches_and_leaves_the_play_view() {
|
||||||
|
let mut state = UiState {
|
||||||
|
play_view: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
press("k", &mut state);
|
||||||
|
press("i", &mut state);
|
||||||
|
assert_eq!(state.search, "ki");
|
||||||
|
assert!(!state.play_view);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn umlauts_are_searchable_like_any_other_letter() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
press("ö", &mut state);
|
||||||
|
assert_eq!(state.search, "ö");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_toggles_but_types_mid_search() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
||||||
|
|
||||||
|
state.search = "geolino".into();
|
||||||
|
assert!(press("space", &mut state).is_empty());
|
||||||
|
assert_eq!(state.search, "geolino ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_toggles_from_the_play_view_even_with_a_query_behind_it() {
|
||||||
|
let mut state = UiState {
|
||||||
|
search: "conni".into(),
|
||||||
|
play_view: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_media_works_from_every_screen() {
|
||||||
|
for play_view in [false, true] {
|
||||||
|
let mut state = UiState {
|
||||||
|
play_view,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("H", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Previous]);
|
||||||
|
let got = handle_key("K", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Volume(VOLUME_STEP)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_shifted_letters_still_reach_the_search_box() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
handle_key("C", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.search, "C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrows_browse_while_browsing_and_drive_the_player_while_playing() {
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(press("right", &mut state).is_empty());
|
||||||
|
|
||||||
|
state.play_view = true;
|
||||||
|
assert_eq!(press("right", &mut state), vec![Action::Next]);
|
||||||
|
assert_eq!(press("up", &mut state), vec![Action::Volume(VOLUME_STEP)]);
|
||||||
|
assert_eq!(
|
||||||
|
press("down", &mut state),
|
||||||
|
vec![Action::Volume(-VOLUME_STEP)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_arrows_seek_from_either_screen() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
let got = handle_key("right", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Seek(SEEK_STEP)]);
|
||||||
|
let got = handle_key("left", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Seek(-SEEK_STEP)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_root_shelf_is_two_axis_and_clamps_to_the_row_it_lands_on() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
let lengths = [5, 2, 0];
|
||||||
|
|
||||||
|
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!((state.shelf_row, state.sel_index), (0, 3));
|
||||||
|
|
||||||
|
// Row 1 only has two tiles, so the column has to come back with it.
|
||||||
|
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!((state.shelf_row, state.sel_index), (1, 1));
|
||||||
|
|
||||||
|
// Row 2 has none at all.
|
||||||
|
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!((state.shelf_row, state.sel_index), (2, 0));
|
||||||
|
|
||||||
|
// And it does not walk off the bottom.
|
||||||
|
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!(state.shelf_row, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_at_the_root_opens_the_focused_shelfs_group() {
|
||||||
|
let mut state = UiState {
|
||||||
|
shelf_row: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(press("enter", &mut state).is_empty());
|
||||||
|
assert_eq!(state.group, Some(Group::Audiobooks));
|
||||||
|
assert_eq!(state.sel_index, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_peels_one_layer_at_a_time() {
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
category: Some("Rolf".into()),
|
||||||
|
search: "abc".into(),
|
||||||
|
tracks_mode: true,
|
||||||
|
play_view: true,
|
||||||
|
open_album: Some("x".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert!(state.open_album.is_none(), "the sheet closes first");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert!(!state.play_view, "then the play view");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert_eq!(state.search, "", "then the query");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert!(!state.tracks_mode, "then track-search mode");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert_eq!(
|
||||||
|
(state.group, state.category.clone()),
|
||||||
|
(None, None),
|
||||||
|
"then both at once"
|
||||||
|
);
|
||||||
|
assert!(!state.can_go_back());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_cycles_the_three_groups_and_wraps() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
for expected in [
|
||||||
|
Group::Music,
|
||||||
|
Group::Audiobooks,
|
||||||
|
Group::Podcasts,
|
||||||
|
Group::Music,
|
||||||
|
] {
|
||||||
|
press("tab", &mut state);
|
||||||
|
assert_eq!(state.group, Some(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_only_bites_when_there_is_a_query() {
|
||||||
|
let mut state = UiState {
|
||||||
|
search: "ab".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
press("backspace", &mut state);
|
||||||
|
assert_eq!(state.search, "a");
|
||||||
|
press("backspace", &mut state);
|
||||||
|
press("backspace", &mut state);
|
||||||
|
assert_eq!(state.search, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn question_mark_switches_to_track_search_and_clears_the_query() {
|
||||||
|
let mut state = UiState {
|
||||||
|
search: "conni".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
press("?", &mut state);
|
||||||
|
assert!(state.tracks_mode);
|
||||||
|
assert_eq!(state.search, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- selection over a real results set ----
|
||||||
|
|
||||||
|
fn results_with(songs: usize, categories: usize, albums: usize) -> Results {
|
||||||
|
let library = Library::build(
|
||||||
|
(0..albums.max(1))
|
||||||
|
.map(|i| {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": format!("al{i}"),
|
||||||
|
"section": "Musik",
|
||||||
|
"kind": "music",
|
||||||
|
"title": format!("Album {i}"),
|
||||||
|
"artist": "A",
|
||||||
|
"category": "A",
|
||||||
|
"tracks": [{ "title": "T", "duration": 10.0 }],
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
Results {
|
||||||
|
songs: (0..songs)
|
||||||
|
.map(|i| SongHit {
|
||||||
|
album: std::rc::Rc::clone(&library.albums[0]),
|
||||||
|
index: i,
|
||||||
|
title: format!("S{i}"),
|
||||||
|
duration: 10.0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
categories: (0..categories)
|
||||||
|
.map(|i| Category {
|
||||||
|
key: format!("C{i}"),
|
||||||
|
albums: vec![],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
albums: library
|
||||||
|
.albums
|
||||||
|
.iter()
|
||||||
|
.take(albums)
|
||||||
|
.map(std::rc::Rc::clone)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_selection_runs_flat_across_songs_then_categories_then_albums() {
|
||||||
|
let results = results_with(2, 2, 3);
|
||||||
|
assert_eq!(results.total(), 7);
|
||||||
|
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
cols: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inside the song list a vertical move is one row, not one grid row.
|
||||||
|
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.sel_index, 1);
|
||||||
|
|
||||||
|
// Past the songs it jumps a whole grid row.
|
||||||
|
state.sel_index = 2;
|
||||||
|
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.sel_index, 5);
|
||||||
|
|
||||||
|
// And it never runs off the end.
|
||||||
|
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.sel_index, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_plays_a_song_hit_at_its_own_track_index() {
|
||||||
|
let results = results_with(2, 0, 0);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id: "al0".into(),
|
||||||
|
track_index: 1
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_a_category_drills_in_rather_than_playing() {
|
||||||
|
let results = results_with(0, 2, 0);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
||||||
|
assert_eq!(state.category.as_deref(), Some("C1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_an_album_plays_it_from_the_top() {
|
||||||
|
let results = results_with(0, 0, 3);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id: "al2".into(),
|
||||||
|
track_index: 0
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_enter_opens_the_track_list_instead() {
|
||||||
|
let results = results_with(0, 0, 2);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
||||||
|
assert_eq!(state.open_album.as_deref(), Some("al1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_enter_on_a_category_falls_through_to_drilling_in() {
|
||||||
|
let results = results_with(0, 1, 0);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert!(state.open_album.is_none());
|
||||||
|
assert_eq!(state.category.as_deref(), Some("C0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_jk_walks_the_open_track_list_and_clamps() {
|
||||||
|
let mut state = UiState {
|
||||||
|
open_album: Some("al0".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
assert_eq!(state.sheet_index, 2);
|
||||||
|
handle_key("k", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
assert_eq!(state.sheet_index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_with_the_sheet_open_plays_the_highlighted_track() {
|
||||||
|
let mut state = UiState {
|
||||||
|
open_album: Some("al0".into()),
|
||||||
|
sheet_index: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("enter", false, false, &mut state, &empty(), &[0, 0, 0], 4);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id: "al0".into(),
|
||||||
|
track_index: 2
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
504
slint-frontend/src/library.rs
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
//! Browsing and searching the library, entirely on the client.
|
||||||
|
//!
|
||||||
|
//! The whole index arrives in one response, so type-to-search has no round trip - which
|
||||||
|
//! is the point of a keyboard-first UI. Ported from `web/src/lib/search.ts`, including
|
||||||
|
//! the reason that module is shaped the way it is:
|
||||||
|
//!
|
||||||
|
//! The cost that matters is per keystroke, not per library load. A real collection is
|
||||||
|
//! 343 albums and 4969 tracks, and the naive version re-derives its search keys from
|
||||||
|
//! scratch on every one of them for every letter typed - `normalize` runs a lowercase,
|
||||||
|
//! a Unicode NFD decomposition and two filtering passes, so track search would mean
|
||||||
|
//! five thousand NFD normalizations before a single comparison. That is work whose
|
||||||
|
//! answer cannot change until the library does, so it happens once, in
|
||||||
|
//! [`Library::build`], and a keystroke becomes `str::contains` over strings that
|
||||||
|
//! already exist.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
use crate::api::Album;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Group {
|
||||||
|
Music,
|
||||||
|
Audiobooks,
|
||||||
|
Podcasts,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const GROUPS: [Group; 3] = [Group::Music, Group::Audiobooks, Group::Podcasts];
|
||||||
|
|
||||||
|
impl Group {
|
||||||
|
/// Which of the three shelves an album belongs on. A podcast is exactly a
|
||||||
|
/// `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
|
||||||
|
/// album joins whichever of music/audiobooks matches what it actually holds.
|
||||||
|
pub fn of(album: &Album) -> Self {
|
||||||
|
if album.section == "Kinderpodcasts" {
|
||||||
|
Self::Podcasts
|
||||||
|
} else if album.is_book() {
|
||||||
|
Self::Audiobooks
|
||||||
|
} else {
|
||||||
|
Self::Music
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Music => 0,
|
||||||
|
Self::Audiobooks => 1,
|
||||||
|
Self::Podcasts => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_index(index: usize) -> Self {
|
||||||
|
GROUPS[index.min(2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "Musik",
|
||||||
|
Self::Audiobooks => "Hörbücher",
|
||||||
|
Self::Podcasts => "Podcasts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "🎵",
|
||||||
|
Self::Audiobooks => "📖",
|
||||||
|
Self::Podcasts => "🎙️",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heading over the category tiles inside this group.
|
||||||
|
pub fn category_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "Künstler",
|
||||||
|
Self::Audiobooks => "Figuren",
|
||||||
|
Self::Podcasts => "Sendungen",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heading over the album grid inside this group.
|
||||||
|
pub fn section_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "Alben",
|
||||||
|
Self::Audiobooks => "Hörbücher",
|
||||||
|
Self::Podcasts => "Episoden",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-group hue in oklch degrees, shared with the generated cover art so a shelf
|
||||||
|
/// already hints at the mood its albums play into.
|
||||||
|
pub fn hue(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Music => 210.0, // today's sea - the baseline hue
|
||||||
|
Self::Audiobooks => 55.0, // warm amber
|
||||||
|
Self::Podcasts => 300.0, // the one otherwise-unused family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diacritics and punctuation are noise when a child is hunting for letters.
|
||||||
|
pub fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.to_lowercase()
|
||||||
|
.nfd()
|
||||||
|
// Combining marks, i.e. what NFD just split off the base letters.
|
||||||
|
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words of a query, normalized individually. A space-separated query like "conni rad"
|
||||||
|
/// should find "Conni lernt Rad fahren" even though "rad" is not right after "conni",
|
||||||
|
/// so requiring the whole phrase to be one contiguous substring is too strict.
|
||||||
|
fn search_words(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split_whitespace()
|
||||||
|
.map(normalize)
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every word has to show up somewhere in the haystack, in any order.
|
||||||
|
fn matches_words(haystack: &str, words: &[String]) -> bool {
|
||||||
|
words.iter().all(|word| haystack.contains(word.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AlbumEntry {
|
||||||
|
pub album: Rc<Album>,
|
||||||
|
/// `normalize(title + artist)`, as a naive `album_matches` would recompute per key.
|
||||||
|
haystack: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TrackEntry {
|
||||||
|
pub album: Rc<Album>,
|
||||||
|
/// Index within `album.tracks`, which is what playback wants.
|
||||||
|
pub index: usize,
|
||||||
|
pub title: String,
|
||||||
|
pub duration: f64,
|
||||||
|
haystack: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Category {
|
||||||
|
pub key: String,
|
||||||
|
pub albums: Vec<Rc<Album>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SongHit {
|
||||||
|
pub album: Rc<Album>,
|
||||||
|
pub index: usize,
|
||||||
|
pub title: String,
|
||||||
|
pub duration: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a search needs, derived once per library payload.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Library {
|
||||||
|
pub albums: Vec<Rc<Album>>,
|
||||||
|
entries: Vec<AlbumEntry>,
|
||||||
|
by_group: [Vec<usize>; 3],
|
||||||
|
/// Each group's categories, already grouped and sorted.
|
||||||
|
categories_by_group: [Vec<Category>; 3],
|
||||||
|
/// Unlocked tracks only, album order then track order. A locked track shows as a
|
||||||
|
/// question mark wherever it appears, so it has no title to match on and is left
|
||||||
|
/// out of the index entirely rather than skipped at match time.
|
||||||
|
tracks: Vec<TrackEntry>,
|
||||||
|
tracks_by_group: [Vec<usize>; 3],
|
||||||
|
by_id: HashMap<String, Rc<Album>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
pub fn build(albums: Vec<Album>) -> Self {
|
||||||
|
let mut library = Self::default();
|
||||||
|
// One map per group rather than one overall: two groups may legitimately hold
|
||||||
|
// the same category name, and merging them would put an artist's albums on
|
||||||
|
// the audiobook shelf.
|
||||||
|
let mut category_maps: [HashMap<String, Vec<Rc<Album>>>; 3] = Default::default();
|
||||||
|
|
||||||
|
for album in albums {
|
||||||
|
let album = Rc::new(album);
|
||||||
|
let group = Group::of(&album);
|
||||||
|
|
||||||
|
library.by_id.insert(album.id.clone(), Rc::clone(&album));
|
||||||
|
library.albums.push(Rc::clone(&album));
|
||||||
|
|
||||||
|
library.by_group[group.index()].push(library.entries.len());
|
||||||
|
library.entries.push(AlbumEntry {
|
||||||
|
haystack: normalize(&format!("{}{}", album.title, album.artist)),
|
||||||
|
album: Rc::clone(&album),
|
||||||
|
});
|
||||||
|
|
||||||
|
category_maps[group.index()]
|
||||||
|
.entry(album.category.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(Rc::clone(&album));
|
||||||
|
|
||||||
|
for (index, track) in album.tracks.iter().enumerate() {
|
||||||
|
if track.locked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
library.tracks_by_group[group.index()].push(library.tracks.len());
|
||||||
|
library.tracks.push(TrackEntry {
|
||||||
|
haystack: normalize(&track.title),
|
||||||
|
album: Rc::clone(&album),
|
||||||
|
index,
|
||||||
|
title: track.title.clone(),
|
||||||
|
duration: track.duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slot, map) in library.categories_by_group.iter_mut().zip(category_maps) {
|
||||||
|
let mut categories: Vec<Category> = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, albums)| Category { key, albums })
|
||||||
|
.collect();
|
||||||
|
// `localeCompare(_, "de")` in the original. A full collator is more than
|
||||||
|
// this earns; normalizing first folds the umlauts, which is the whole of
|
||||||
|
// what German ordering asks for here.
|
||||||
|
categories.sort_by_key(|c| normalize(&c.key));
|
||||||
|
*slot = categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
library
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, album_id: &str) -> Option<&Rc<Album>> {
|
||||||
|
self.by_id.get(album_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn categories(&self, group: Group) -> &[Category] {
|
||||||
|
&self.categories_by_group[group.index()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- queries --
|
||||||
|
|
||||||
|
/// `group: None` means unrestricted - typing at the root searches every group at once.
|
||||||
|
pub struct Query<'a> {
|
||||||
|
pub search: &'a str,
|
||||||
|
pub tracks_mode: bool,
|
||||||
|
pub group: Option<Group>,
|
||||||
|
pub category: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the three lists the browse view is showing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ListMode {
|
||||||
|
Tracks,
|
||||||
|
Albums,
|
||||||
|
Categories,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capped, because an empty query over 900 podcast episodes is not a useful screen.
|
||||||
|
pub const MAX_SONG_HITS: usize = 40;
|
||||||
|
|
||||||
|
/// Selection indices run flat across songs, then categories, then albums, so one pair
|
||||||
|
/// of arrow keys walks the whole page.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Results {
|
||||||
|
pub songs: Vec<SongHit>,
|
||||||
|
pub categories: Vec<Category>,
|
||||||
|
pub albums: Vec<Rc<Album>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Results {
|
||||||
|
pub fn total(&self) -> usize {
|
||||||
|
self.songs.len() + self.categories.len() + self.albums.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
pub fn list_mode(&self, query: &Query) -> ListMode {
|
||||||
|
if query.tracks_mode {
|
||||||
|
ListMode::Tracks
|
||||||
|
} else if !query.search.is_empty() || query.category.is_some() {
|
||||||
|
ListMode::Albums
|
||||||
|
} else {
|
||||||
|
ListMode::Categories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_pool(&self, query: &Query) -> Vec<usize> {
|
||||||
|
match query.group {
|
||||||
|
None => (0..self.entries.len()).collect(),
|
||||||
|
Some(group) => self.by_group[group.index()].clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category_matches(&self, query: &Query) -> Vec<Category> {
|
||||||
|
// `group: None` is the bare root screen, rendered as three shelves instead of a
|
||||||
|
// flat category list - each shelf asks again with its own group filled in.
|
||||||
|
match (self.list_mode(query), query.group) {
|
||||||
|
(ListMode::Categories, Some(group)) => self.categories(group).to_vec(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn album_matches(&self, query: &Query) -> Vec<Rc<Album>> {
|
||||||
|
if query.tracks_mode {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let words = search_words(query.search);
|
||||||
|
if words.is_empty() && query.category.is_none() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.album_pool(query)
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| &self.entries[i])
|
||||||
|
.filter(|entry| match query.category {
|
||||||
|
Some(category) => entry.album.category == category,
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.filter(|entry| words.is_empty() || matches_words(&entry.haystack, &words))
|
||||||
|
.map(|entry| Rc::clone(&entry.album))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn song_matches(&self, query: &Query) -> Vec<SongHit> {
|
||||||
|
if !query.tracks_mode {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let words = search_words(query.search);
|
||||||
|
let pool: &[usize] = match query.group {
|
||||||
|
None => return self.take_song_hits(self.tracks.iter(), &words),
|
||||||
|
Some(group) => &self.tracks_by_group[group.index()],
|
||||||
|
};
|
||||||
|
self.take_song_hits(pool.iter().map(|&i| &self.tracks[i]), &words)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stopping at the cap rather than filling every hit and slicing is what keeps an
|
||||||
|
/// empty query in tracks mode from walking all 4969 tracks to show 40 rows.
|
||||||
|
fn take_song_hits<'a>(
|
||||||
|
&self,
|
||||||
|
pool: impl Iterator<Item = &'a TrackEntry>,
|
||||||
|
words: &[String],
|
||||||
|
) -> Vec<SongHit> {
|
||||||
|
pool.filter(|track| words.is_empty() || matches_words(&track.haystack, words))
|
||||||
|
.take(MAX_SONG_HITS)
|
||||||
|
.map(|track| SongHit {
|
||||||
|
album: Rc::clone(&track.album),
|
||||||
|
index: track.index,
|
||||||
|
title: track.title.clone(),
|
||||||
|
duration: track.duration,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn results(&self, query: &Query) -> Results {
|
||||||
|
Results {
|
||||||
|
songs: self.song_matches(query),
|
||||||
|
categories: self.category_matches(query),
|
||||||
|
albums: self.album_matches(query),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn album(id: &str, title: &str, artist: &str, section: &str, tracks: &[&str]) -> Album {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"section": section,
|
||||||
|
"kind": if section == "Musik" { "music" } else { "book" },
|
||||||
|
"title": title,
|
||||||
|
"artist": artist,
|
||||||
|
"category": artist,
|
||||||
|
"tracks": tracks.iter().map(|t| serde_json::json!({ "title": t, "duration": 60.0 })).collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Library {
|
||||||
|
Library::build(vec![
|
||||||
|
album(
|
||||||
|
"a",
|
||||||
|
"Grüffelo",
|
||||||
|
"Julia Donaldson",
|
||||||
|
"Hörbücher",
|
||||||
|
&["Kapitel 1", "Kapitel 2"],
|
||||||
|
),
|
||||||
|
album("b", "Bibi Blocksberg", "Bibi", "Hörbücher", &["Hexerei"]),
|
||||||
|
album(
|
||||||
|
"c",
|
||||||
|
"Rote Lieder",
|
||||||
|
"Rolf Zuckowski",
|
||||||
|
"Musik",
|
||||||
|
&["Wie schön"],
|
||||||
|
),
|
||||||
|
album(
|
||||||
|
"d",
|
||||||
|
"Maus Folge 3",
|
||||||
|
"Sendung mit der Maus",
|
||||||
|
"Kinderpodcasts",
|
||||||
|
&["Folge 3"],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_folds_umlauts_and_punctuation() {
|
||||||
|
assert_eq!(normalize("Hörbücher!"), "horbucher");
|
||||||
|
assert_eq!(normalize("Grüffelo"), "gruffelo");
|
||||||
|
assert_eq!(normalize("A-B C"), "abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn groups_split_podcasts_out_of_books() {
|
||||||
|
let library = fixture();
|
||||||
|
assert_eq!(Group::of(library.get("d").unwrap()), Group::Podcasts);
|
||||||
|
assert_eq!(Group::of(library.get("a").unwrap()), Group::Audiobooks);
|
||||||
|
assert_eq!(Group::of(library.get("c").unwrap()), Group::Music);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_search_is_word_order_independent() {
|
||||||
|
let library = fixture();
|
||||||
|
let hits = library.album_matches(&Query {
|
||||||
|
search: "donaldson gruffelo",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_query_lists_no_albums_but_does_list_categories() {
|
||||||
|
let library = fixture();
|
||||||
|
let query = Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: Some(Group::Audiobooks),
|
||||||
|
category: None,
|
||||||
|
};
|
||||||
|
assert!(library.album_matches(&query).is_empty());
|
||||||
|
assert_eq!(library.category_matches(&query).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_root_screen_has_no_flat_category_list_of_its_own() {
|
||||||
|
let library = fixture();
|
||||||
|
let query = Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
};
|
||||||
|
assert!(library.category_matches(&query).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_category_lists_its_albums_without_a_search() {
|
||||||
|
let library = fixture();
|
||||||
|
let hits = library.album_matches(&Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: Some(Group::Audiobooks),
|
||||||
|
category: Some("Bibi"),
|
||||||
|
});
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_search_is_capped_and_group_scoped() {
|
||||||
|
let library = fixture();
|
||||||
|
let hits = library.song_matches(&Query {
|
||||||
|
search: "kapitel",
|
||||||
|
tracks_mode: true,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
assert_eq!(hits.len(), 2);
|
||||||
|
assert!(hits.len() <= MAX_SONG_HITS);
|
||||||
|
|
||||||
|
let scoped = library.song_matches(&Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: true,
|
||||||
|
group: Some(Group::Music),
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
assert_eq!(scoped.len(), 1);
|
||||||
|
assert_eq!(scoped[0].title, "Wie schön");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn categories_sort_by_folded_key() {
|
||||||
|
let library = fixture();
|
||||||
|
let keys: Vec<&str> = library
|
||||||
|
.categories(Group::Audiobooks)
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.key.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(keys, ["Bibi", "Julia Donaldson"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
999
slint-frontend/src/main.rs
Normal file
@@ -0,0 +1,999 @@
|
|||||||
|
//! A native front-end for the MusicMouse player.
|
||||||
|
//!
|
||||||
|
//! The same split the web front-end is built on: **what is playing** comes from the
|
||||||
|
//! backend over a push-only websocket, **what you are looking at** lives here. The
|
||||||
|
//! device has other front-ends - the buttons on the mouse, a figurine on the reader,
|
||||||
|
//! Home Assistant - so this follows playback rather than owning it.
|
||||||
|
//!
|
||||||
|
//! Three threads and no async runtime. The UI thread does layout and nothing else; a
|
||||||
|
//! websocket thread reads state; a cover thread fetches and downscales album art; a
|
||||||
|
//! command thread makes the HTTP calls, because a blocking POST on the UI thread is
|
||||||
|
//! exactly the stall this front-end exists to remove.
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod covers;
|
||||||
|
mod format;
|
||||||
|
mod keymap;
|
||||||
|
mod library;
|
||||||
|
mod oklch;
|
||||||
|
mod ws;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc::{channel, Sender};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use slint::{ModelRc, SharedString, VecModel};
|
||||||
|
|
||||||
|
use api::{Album, Client, PlayerState};
|
||||||
|
|
||||||
|
use covers::Tier;
|
||||||
|
use keymap::{Action, UiState};
|
||||||
|
use library::{Group, Library, Query, Results, GROUPS};
|
||||||
|
|
||||||
|
slint::include_modules!();
|
||||||
|
|
||||||
|
// The application state lives on the UI thread and is reached from the event-loop
|
||||||
|
// closures the worker relay posts. See `pump_workers`.
|
||||||
|
thread_local! {
|
||||||
|
static APP: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- commands --
|
||||||
|
|
||||||
|
/// Anything that talks to the backend, run off the UI thread. They all answer 204 with
|
||||||
|
/// no body, so nothing comes back: the authoritative answer arrives as a pushed state
|
||||||
|
/// frame, which is also what makes the optimistic updates below safe.
|
||||||
|
enum Command {
|
||||||
|
Play {
|
||||||
|
album_id: String,
|
||||||
|
track_index: usize,
|
||||||
|
},
|
||||||
|
Resume,
|
||||||
|
Pause,
|
||||||
|
Next,
|
||||||
|
Previous,
|
||||||
|
Seek(f64),
|
||||||
|
Volume(i32),
|
||||||
|
ReloadLibrary,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FromWorker {
|
||||||
|
Library(Result<Vec<Album>, String>),
|
||||||
|
Cover(covers::Ready),
|
||||||
|
Ws(WsEvent),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WsEvent {
|
||||||
|
Connected,
|
||||||
|
Disconnected,
|
||||||
|
State(Box<PlayerState>),
|
||||||
|
Position { position: f64, duration: f64 },
|
||||||
|
LibraryChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------ state --
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
commands: Sender<Command>,
|
||||||
|
ui: UiState,
|
||||||
|
player: PlayerState,
|
||||||
|
library: Library,
|
||||||
|
covers: covers::Store,
|
||||||
|
online: bool,
|
||||||
|
loaded: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
/// Changes whenever the track itself changes, so the progress bar can stop
|
||||||
|
/// animating across two unrelated positions instead of sliding between them.
|
||||||
|
track_key: String,
|
||||||
|
/// False for exactly one render: the frame a track changes on, and the frame a seek
|
||||||
|
/// lands on.
|
||||||
|
smooth: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn send(&self, command: Command) {
|
||||||
|
let _ = self.commands.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query(&self) -> Query<'_> {
|
||||||
|
Query {
|
||||||
|
search: &self.ui.search,
|
||||||
|
tracks_mode: self.ui.tracks_mode,
|
||||||
|
group: self.ui.group,
|
||||||
|
category: self.ui.category.as_deref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_album(&self) -> Option<&Rc<Album>> {
|
||||||
|
self.player
|
||||||
|
.album_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| self.library.get(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long each of the three root shelves is, for the keyboard's column clamping.
|
||||||
|
fn shelf_lengths(&self) -> [usize; 3] {
|
||||||
|
[
|
||||||
|
self.library.categories(Group::Music).len(),
|
||||||
|
self.library.categories(Group::Audiobooks).len(),
|
||||||
|
self.library.categories(Group::Podcasts).len(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&mut self, actions: Vec<Action>) {
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index,
|
||||||
|
} => {
|
||||||
|
// Starting something switches to the full-screen view and closes
|
||||||
|
// the track list behind it, as the web front-end does: what you
|
||||||
|
// just chose becomes the thing on screen. ESC and `/` come back.
|
||||||
|
self.ui.play_view = true;
|
||||||
|
self.ui.open_album = None;
|
||||||
|
self.send(Command::Play {
|
||||||
|
album_id,
|
||||||
|
track_index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Action::Toggle => {
|
||||||
|
// Optimistic: flip it here and let the pushed frame confirm. Safe
|
||||||
|
// precisely because state only ever arrives pushed, never polled,
|
||||||
|
// so there is no poll in flight to race with.
|
||||||
|
self.player.playing = !self.player.playing;
|
||||||
|
self.send(if self.player.playing {
|
||||||
|
Command::Resume
|
||||||
|
} else {
|
||||||
|
Command::Pause
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Action::Next => self.send(Command::Next),
|
||||||
|
Action::Previous => self.send(Command::Previous),
|
||||||
|
Action::Volume(delta) => {
|
||||||
|
self.player.volume = (self.player.volume + delta).clamp(0, 100);
|
||||||
|
self.send(Command::Volume(self.player.volume));
|
||||||
|
}
|
||||||
|
Action::Seek(delta) => {
|
||||||
|
let target = (self.player.position + delta).clamp(0.0, self.player.duration);
|
||||||
|
self.player.position = target;
|
||||||
|
self.smooth = false;
|
||||||
|
self.send(Command::Seek(target));
|
||||||
|
}
|
||||||
|
Action::Mute => {
|
||||||
|
// Unmuting restores a sensible level rather than whatever it was:
|
||||||
|
// the level before a mute is not recorded anywhere the device
|
||||||
|
// agrees on, and coming back at 8% reads as still broken.
|
||||||
|
let target = if self.player.volume == 0 { 60 } else { 0 };
|
||||||
|
self.player.volume = target;
|
||||||
|
self.send(Command::Volume(target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ presentation --
|
||||||
|
|
||||||
|
fn cover_of(store: &mut covers::Store, album: &Album, tier: Tier) -> slint::Image {
|
||||||
|
let palette = album.palette();
|
||||||
|
store
|
||||||
|
.get(covers::Request {
|
||||||
|
album_id: album.id.clone(),
|
||||||
|
tier,
|
||||||
|
has_cover: album.has_cover,
|
||||||
|
is_book: album.is_book(),
|
||||||
|
locked: album.locked,
|
||||||
|
colors: [palette[0].into(), palette[1].into(), palette[2].into()],
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "12 Songs · 45:03 · 🧸" - the line under an album's title.
|
||||||
|
fn album_meta(album: &Album) -> String {
|
||||||
|
let unit = if album.is_book() {
|
||||||
|
"Kapitel"
|
||||||
|
} else if album.tracks.len() == 1 {
|
||||||
|
"Song"
|
||||||
|
} else {
|
||||||
|
"Songs"
|
||||||
|
};
|
||||||
|
let mut meta = format!(
|
||||||
|
"{} {unit} · {}",
|
||||||
|
album.tracks.len(),
|
||||||
|
format::clock(album.duration)
|
||||||
|
);
|
||||||
|
if album.figure.is_some() {
|
||||||
|
meta.push_str(" · 🧸");
|
||||||
|
}
|
||||||
|
meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "7 Hörbücher" - the line under a category tile.
|
||||||
|
fn category_meta(group: Group, count: usize) -> String {
|
||||||
|
let unit = match (group, count) {
|
||||||
|
(Group::Music, 1) => "Album",
|
||||||
|
(Group::Music, _) => "Alben",
|
||||||
|
(Group::Audiobooks, _) => "Hörbücher",
|
||||||
|
(Group::Podcasts, 1) => "Episode",
|
||||||
|
(Group::Podcasts, _) => "Episoden",
|
||||||
|
};
|
||||||
|
format!("{count} {unit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_tile(store: &mut covers::Store, album: &Album, current: bool, nav: usize) -> AlbumTile {
|
||||||
|
AlbumTile {
|
||||||
|
id: album.id.as_str().into(),
|
||||||
|
title: if album.locked {
|
||||||
|
"❓ Geheimnis".into()
|
||||||
|
} else {
|
||||||
|
album.title.as_str().into()
|
||||||
|
},
|
||||||
|
// A podcast episode's artist is its show, which the title already says.
|
||||||
|
artist: if album.artist == album.title {
|
||||||
|
SharedString::new()
|
||||||
|
} else {
|
||||||
|
album.artist.as_str().into()
|
||||||
|
},
|
||||||
|
meta: album_meta(album).into(),
|
||||||
|
cover: cover_of(store, album, Tier::Thumb),
|
||||||
|
is_book: album.is_book(),
|
||||||
|
locked: album.locked,
|
||||||
|
current,
|
||||||
|
nav: nav as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn category_tile(
|
||||||
|
store: &mut covers::Store,
|
||||||
|
group: Group,
|
||||||
|
category: &library::Category,
|
||||||
|
nav: usize,
|
||||||
|
) -> CategoryTile {
|
||||||
|
// Up to four covers in a 2x2 contact sheet, which is how a category reads as "a
|
||||||
|
// stack of these" without a label saying so.
|
||||||
|
let mut covers_out = [
|
||||||
|
slint::Image::default(),
|
||||||
|
slint::Image::default(),
|
||||||
|
slint::Image::default(),
|
||||||
|
slint::Image::default(),
|
||||||
|
];
|
||||||
|
for (slot, album) in covers_out.iter_mut().zip(category.albums.iter()) {
|
||||||
|
*slot = cover_of(store, album, Tier::Thumb);
|
||||||
|
}
|
||||||
|
let [c1, c2, c3, c4] = covers_out;
|
||||||
|
CategoryTile {
|
||||||
|
key: category.key.as_str().into(),
|
||||||
|
count_label: category_meta(group, category.albums.len()).into(),
|
||||||
|
cover1: c1,
|
||||||
|
cover2: c2,
|
||||||
|
cover3: c3,
|
||||||
|
cover4: c4,
|
||||||
|
cover_count: category.albums.len().min(4) as i32,
|
||||||
|
is_book: category.albums.first().is_some_and(|a| a.is_book()),
|
||||||
|
nav: nav as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_row(
|
||||||
|
store: &mut covers::Store,
|
||||||
|
album: &Album,
|
||||||
|
index: usize,
|
||||||
|
title: &str,
|
||||||
|
duration: f64,
|
||||||
|
current: bool,
|
||||||
|
nav: usize,
|
||||||
|
) -> TrackRow {
|
||||||
|
TrackRow {
|
||||||
|
title: title.into(),
|
||||||
|
subtitle: album.line().into(),
|
||||||
|
duration: format::clock(duration).into(),
|
||||||
|
cover: cover_of(store, album, Tier::Thumb),
|
||||||
|
is_book: album.is_book(),
|
||||||
|
current,
|
||||||
|
locked: false,
|
||||||
|
album_id: album.id.as_str().into(),
|
||||||
|
index: index as i32,
|
||||||
|
nav: nav as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_playing(app: &mut App) -> NowPlaying {
|
||||||
|
let album = app.current_album().map(Rc::clone);
|
||||||
|
let state = &app.player;
|
||||||
|
let is_book = album.as_ref().is_some_and(|a| a.is_book());
|
||||||
|
let is_podcast = album
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|a| Group::of(a) == Group::Podcasts);
|
||||||
|
|
||||||
|
let (cover, hero) = match &album {
|
||||||
|
Some(album) => (
|
||||||
|
cover_of(&mut app.covers, album, Tier::Thumb),
|
||||||
|
cover_of(&mut app.covers, album, Tier::Hero),
|
||||||
|
),
|
||||||
|
None => (slint::Image::default(), slint::Image::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining_label = match &album {
|
||||||
|
// "Noch ... im Hörbuch" means nothing for a podcast, where an episode is the
|
||||||
|
// whole of what was chosen.
|
||||||
|
Some(album) if !is_podcast => {
|
||||||
|
let durations: Vec<f64> = album.tracks.iter().map(|t| t.duration).collect();
|
||||||
|
let left = format::remaining_in_album(
|
||||||
|
&durations,
|
||||||
|
state.track_index.max(0) as usize,
|
||||||
|
state.position,
|
||||||
|
);
|
||||||
|
let what = if is_book { "im Hörbuch" } else { "im Album" };
|
||||||
|
format!("Noch {} {what}", format::clock(left))
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
NowPlaying {
|
||||||
|
title: state
|
||||||
|
.track_title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Wähl ein Album!".into())
|
||||||
|
.into(),
|
||||||
|
subtitle: album
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| "Tippen oder klicken".to_string(), |a| a.line())
|
||||||
|
.into(),
|
||||||
|
counter: format!(
|
||||||
|
"{} {} von {}",
|
||||||
|
if is_book { "Kapitel" } else { "Song" },
|
||||||
|
state.track_index + 1,
|
||||||
|
state.track_count.max(1)
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
cover,
|
||||||
|
hero_cover: hero,
|
||||||
|
is_book,
|
||||||
|
has_album: album.is_some(),
|
||||||
|
clock_label: if state.duration > 0.0 {
|
||||||
|
format!("−{}", format::clock(state.duration - state.position)).into()
|
||||||
|
} else {
|
||||||
|
SharedString::new()
|
||||||
|
},
|
||||||
|
remaining_label: remaining_label.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- rendering --
|
||||||
|
|
||||||
|
fn render(app: &mut App, window: &AppWindow) {
|
||||||
|
window.set_loading(!app.loaded);
|
||||||
|
window.set_splash_text(
|
||||||
|
match &app.error {
|
||||||
|
Some(error) => format!("Der Delfin ist nicht erreichbar …\n{error}"),
|
||||||
|
None => "Einen Moment …".to_string(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_online(app.online);
|
||||||
|
window.set_status_label(
|
||||||
|
if !app.online {
|
||||||
|
"Keine Verbindung".into()
|
||||||
|
} else {
|
||||||
|
match &app.player.active_figure {
|
||||||
|
Some(figure) => format!("🧸 {figure}"),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// How many columns fit, from the width the window is actually giving the grid. The
|
||||||
|
// keyboard needs the same number to know what "one row down" means.
|
||||||
|
let gap = 24.0_f32;
|
||||||
|
let card = 180.0_f32;
|
||||||
|
let cols = (((window.get_grid_width() + gap) / (card + gap)).floor() as usize).max(1);
|
||||||
|
app.ui.cols = cols;
|
||||||
|
|
||||||
|
window.set_play_view(app.ui.play_view);
|
||||||
|
window.set_can_go_back(app.ui.can_go_back());
|
||||||
|
window.set_playing(app.player.playing);
|
||||||
|
window.set_volume(app.player.volume);
|
||||||
|
window.set_smooth(app.smooth);
|
||||||
|
window.set_progress(if app.player.duration > 0.0 {
|
||||||
|
(app.player.position / app.player.duration).clamp(0.0, 1.0) as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
});
|
||||||
|
|
||||||
|
let now = now_playing(app);
|
||||||
|
let has_album = now.has_album;
|
||||||
|
window.set_now(now);
|
||||||
|
window.set_has_bar(has_album && !app.ui.play_view);
|
||||||
|
|
||||||
|
let root_screen = app.ui.is_root_shelf();
|
||||||
|
window.set_root_screen(root_screen);
|
||||||
|
window.set_shelf_row(app.ui.shelf_row as i32);
|
||||||
|
window.set_sel_index(app.ui.sel_index as i32);
|
||||||
|
|
||||||
|
if root_screen {
|
||||||
|
render_shelves(app, window);
|
||||||
|
} else {
|
||||||
|
render_results(app, window, cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_sheet(app, window);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_shelves(app: &mut App, window: &AppWindow) {
|
||||||
|
let mut shelves = Vec::with_capacity(GROUPS.len());
|
||||||
|
for group in GROUPS {
|
||||||
|
let categories: Vec<library::Category> = app.library.categories(group).to_vec();
|
||||||
|
let tiles: Vec<CategoryTile> = categories
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, category)| category_tile(&mut app.covers, group, category, i))
|
||||||
|
.collect();
|
||||||
|
shelves.push(Shelf {
|
||||||
|
label: group.label().into(),
|
||||||
|
icon: group.icon().into(),
|
||||||
|
tiles: ModelRc::new(VecModel::from(tiles)),
|
||||||
|
empty_label: "Noch nichts hier".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.set_shelves(ModelRc::new(VecModel::from(shelves)));
|
||||||
|
// The shelves replace the flat result lists rather than sitting beside them.
|
||||||
|
window.set_songs(ModelRc::new(VecModel::from(Vec::<TrackRow>::new())));
|
||||||
|
window.set_grid(ModelRc::new(VecModel::from(Vec::<GridRow>::new())));
|
||||||
|
window.set_category_grid(ModelRc::new(VecModel::from(Vec::<CategoryRow>::new())));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
|
||||||
|
let results: Results = app.library.results(&app.query());
|
||||||
|
let total = results.total();
|
||||||
|
let current_album = app.player.album_id.clone();
|
||||||
|
let current_track = app.player.track_index.max(0) as usize;
|
||||||
|
|
||||||
|
// Selection indices run flat across songs, then categories, then albums.
|
||||||
|
let mut nav = 0usize;
|
||||||
|
|
||||||
|
let songs: Vec<TrackRow> = results
|
||||||
|
.songs
|
||||||
|
.iter()
|
||||||
|
.map(|hit| {
|
||||||
|
let row = track_row(
|
||||||
|
&mut app.covers,
|
||||||
|
&hit.album,
|
||||||
|
hit.index,
|
||||||
|
&hit.title,
|
||||||
|
hit.duration,
|
||||||
|
current_album.as_deref() == Some(hit.album.id.as_str())
|
||||||
|
&& current_track == hit.index,
|
||||||
|
nav,
|
||||||
|
);
|
||||||
|
nav += 1;
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let group = app.ui.group.unwrap_or(Group::Music);
|
||||||
|
let categories: Vec<CategoryTile> = results
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.map(|category| {
|
||||||
|
let tile = category_tile(&mut app.covers, group, category, nav);
|
||||||
|
nav += 1;
|
||||||
|
tile
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Chunked like the album grid, and for the same reason: the view puts each row in
|
||||||
|
// a virtualizing list rather than one ever-widening horizontal layout.
|
||||||
|
let category_grid: Vec<CategoryRow> = categories
|
||||||
|
.chunks(cols)
|
||||||
|
.map(|chunk| CategoryRow {
|
||||||
|
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tiles: Vec<AlbumTile> = results
|
||||||
|
.albums
|
||||||
|
.iter()
|
||||||
|
.map(|album| {
|
||||||
|
let tile = album_tile(
|
||||||
|
&mut app.covers,
|
||||||
|
album,
|
||||||
|
current_album.as_deref() == Some(album.id.as_str()),
|
||||||
|
nav,
|
||||||
|
);
|
||||||
|
nav += 1;
|
||||||
|
tile
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Pre-chunked into rows: a ListView virtualizes its model, and a flat list of 343
|
||||||
|
// cards has no rows for it to skip. The web grid reaches the same place with
|
||||||
|
// `content-visibility: auto`.
|
||||||
|
let first_album_nav = songs.len() + categories.len();
|
||||||
|
let grid: Vec<GridRow> = tiles
|
||||||
|
.chunks(cols)
|
||||||
|
.map(|chunk| GridRow {
|
||||||
|
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Which grid row the selection is in, so the scroller can follow it without
|
||||||
|
// measuring anything - every row is the same height by construction.
|
||||||
|
let sel_row = app
|
||||||
|
.ui
|
||||||
|
.sel_index
|
||||||
|
.checked_sub(first_album_nav)
|
||||||
|
.map_or(0, |within| within / cols.max(1));
|
||||||
|
|
||||||
|
let count_label = if app.ui.tracks_mode {
|
||||||
|
format!("{} Titel gefunden", results.songs.len())
|
||||||
|
} else if results.albums.is_empty() {
|
||||||
|
"nichts gefunden".to_string()
|
||||||
|
} else if results.albums.len() == 1 {
|
||||||
|
"1 Album gefunden".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} Alben gefunden", results.albums.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
window.set_group_label(
|
||||||
|
app.ui
|
||||||
|
.group
|
||||||
|
.map_or(SharedString::new(), |g| g.label().into()),
|
||||||
|
);
|
||||||
|
window.set_category_heading(group.category_label().into());
|
||||||
|
window.set_album_heading(
|
||||||
|
match app.ui.category.as_deref() {
|
||||||
|
Some(category) => category.to_string(),
|
||||||
|
None => group.section_label().to_string(),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_show_search(!app.ui.search.is_empty() || app.ui.tracks_mode);
|
||||||
|
window.set_search_text(
|
||||||
|
if app.ui.search.is_empty() {
|
||||||
|
"tippe …".to_string()
|
||||||
|
} else {
|
||||||
|
format!("\"{}\"", app.ui.search)
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_mode_label(if app.ui.tracks_mode {
|
||||||
|
"♪ Titel".into()
|
||||||
|
} else {
|
||||||
|
"🔎 Alben".into()
|
||||||
|
});
|
||||||
|
window.set_count_label(count_label.into());
|
||||||
|
window.set_no_results(total == 0);
|
||||||
|
window.set_sel_row(sel_row as i32);
|
||||||
|
|
||||||
|
window.set_songs(ModelRc::new(VecModel::from(songs)));
|
||||||
|
window.set_category_grid(ModelRc::new(VecModel::from(category_grid)));
|
||||||
|
window.set_grid(ModelRc::new(VecModel::from(grid)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sheet(app: &mut App, window: &AppWindow) {
|
||||||
|
let Some(album) = app
|
||||||
|
.ui
|
||||||
|
.open_album
|
||||||
|
.clone()
|
||||||
|
.and_then(|id| app.library.get(&id).map(Rc::clone))
|
||||||
|
else {
|
||||||
|
window.set_show_sheet(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let playing_here = app.player.album_id.as_deref() == Some(album.id.as_str());
|
||||||
|
let current = app.player.track_index.max(0) as usize;
|
||||||
|
let rows: Vec<TrackRow> = album
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, track)| {
|
||||||
|
let title = if track.locked {
|
||||||
|
"❓".to_string()
|
||||||
|
} else {
|
||||||
|
track.title.clone()
|
||||||
|
};
|
||||||
|
track_row(
|
||||||
|
&mut app.covers,
|
||||||
|
&album,
|
||||||
|
i,
|
||||||
|
&title,
|
||||||
|
track.duration,
|
||||||
|
playing_here && current == i,
|
||||||
|
i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let kind = if album.is_book() {
|
||||||
|
"📖 Hörbuch"
|
||||||
|
} else {
|
||||||
|
"🎵 Album"
|
||||||
|
};
|
||||||
|
let mut meta = format!("{kind} · {}", album_meta(&album));
|
||||||
|
if let Some(figure) = &album.figure {
|
||||||
|
meta.push_str(&format!(" · {figure}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.set_show_sheet(true);
|
||||||
|
window.set_sheet_title(album.title.as_str().into());
|
||||||
|
window.set_sheet_subtitle(album.artist.as_str().into());
|
||||||
|
window.set_sheet_meta(meta.into());
|
||||||
|
window.set_sheet_cover(cover_of(&mut app.covers, &album, Tier::Hero));
|
||||||
|
window.set_sheet_is_book(album.is_book());
|
||||||
|
window.set_sheet_index(app.ui.sheet_index.min(album.tracks.len().saturating_sub(1)) as i32);
|
||||||
|
window.set_sheet_tracks(ModelRc::new(VecModel::from(rows)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------- wiring --
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let base = std::env::args()
|
||||||
|
.skip_while(|a| a != "--server")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||||||
|
println!("musicmouse-slint → {base}");
|
||||||
|
|
||||||
|
// Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because
|
||||||
|
// Slint cannot read the woff2 the web front-end self-hosts. Slint takes one file as
|
||||||
|
// the primary font through `SLINT_DEFAULT_FONT`; the dev shell and the device's
|
||||||
|
// launcher both set it, and this is the fallback for running the binary directly.
|
||||||
|
// None of them finding it just means the system sans - a missing font should change
|
||||||
|
// how this looks, not whether it runs.
|
||||||
|
if std::env::var_os("SLINT_DEFAULT_FONT").is_none() {
|
||||||
|
let beside_exe = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
// target/release/<bin> -> the crate root
|
||||||
|
.and_then(|exe| Some(exe.parent()?.parent()?.parent()?.join("fonts/Nunito.ttf")));
|
||||||
|
for candidate in [
|
||||||
|
Some(std::path::PathBuf::from("fonts/Nunito.ttf")),
|
||||||
|
beside_exe,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if candidate.exists() {
|
||||||
|
std::env::set_var("SLINT_DEFAULT_FONT", candidate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = AppWindow::new()?;
|
||||||
|
let client = Client::new(&base);
|
||||||
|
|
||||||
|
let (to_ui, from_workers) = channel::<FromWorker>();
|
||||||
|
let (cover_jobs, cover_rx) = channel::<covers::Job>();
|
||||||
|
let (commands, command_rx) = channel::<Command>();
|
||||||
|
|
||||||
|
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
|
||||||
|
{
|
||||||
|
let to_ui = to_ui.clone();
|
||||||
|
covers::spawn(client.clone(), cover_rx, move |ready| {
|
||||||
|
let _ = to_ui.send(FromWorker::Cover(ready));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands, and the library fetch that seeds everything.
|
||||||
|
{
|
||||||
|
let client = client.clone();
|
||||||
|
let to_ui = to_ui.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("commands".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||||||
|
for command in command_rx {
|
||||||
|
let result = match command {
|
||||||
|
Command::Play {
|
||||||
|
album_id,
|
||||||
|
track_index,
|
||||||
|
} => client.play(&album_id, track_index as i32),
|
||||||
|
Command::Resume => client.resume(),
|
||||||
|
Command::Pause => client.pause(),
|
||||||
|
Command::Next => client.next(),
|
||||||
|
Command::Previous => client.previous(),
|
||||||
|
Command::Seek(position) => client.seek(position),
|
||||||
|
Command::Volume(percent) => client.volume(percent),
|
||||||
|
Command::ReloadLibrary => {
|
||||||
|
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
// A failed command is not fatal: the next pushed frame says
|
||||||
|
// what actually happened, which is the only truth anyway.
|
||||||
|
eprintln!("{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State.
|
||||||
|
{
|
||||||
|
let to_ui = to_ui.clone();
|
||||||
|
let (ws_tx, ws_rx) = channel();
|
||||||
|
ws::spawn(client.clone(), ws_tx);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("ws-relay".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for update in ws_rx {
|
||||||
|
let event = match update {
|
||||||
|
ws::Update::Connected => WsEvent::Connected,
|
||||||
|
ws::Update::Disconnected => WsEvent::Disconnected,
|
||||||
|
ws::Update::State(state) => WsEvent::State(state),
|
||||||
|
ws::Update::Position { position, duration } => {
|
||||||
|
WsEvent::Position { position, duration }
|
||||||
|
}
|
||||||
|
ws::Update::LibraryChanged => WsEvent::LibraryChanged,
|
||||||
|
};
|
||||||
|
if to_ui.send(FromWorker::Ws(event)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = Rc::new(RefCell::new(App {
|
||||||
|
commands,
|
||||||
|
ui: UiState::default(),
|
||||||
|
player: PlayerState::default(),
|
||||||
|
library: Library::default(),
|
||||||
|
covers: covers::Store::new(cover_jobs),
|
||||||
|
online: false,
|
||||||
|
loaded: false,
|
||||||
|
error: None,
|
||||||
|
track_key: String::new(),
|
||||||
|
smooth: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
install_callbacks(&app, &window);
|
||||||
|
pump_workers(&app, &window, from_workers);
|
||||||
|
|
||||||
|
render(&mut app.borrow_mut(), &window);
|
||||||
|
window.run()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge the worker channels onto the Slint event loop.
|
||||||
|
///
|
||||||
|
/// `invoke_from_event_loop` takes a `Send` closure, and the application state is
|
||||||
|
/// `Rc<RefCell<..>>` that never leaves the UI thread - so the state stays in a
|
||||||
|
/// thread-local here and only the *messages* cross, which are `Send` by construction.
|
||||||
|
///
|
||||||
|
/// The queue is drained rather than handled one at a time, and that is not incidental:
|
||||||
|
/// a cold start delivers a few hundred covers in a burst, and one re-render per cover
|
||||||
|
/// would rebuild every model a few hundred times. Coalescing collapses each burst into
|
||||||
|
/// a single render.
|
||||||
|
fn pump_workers(
|
||||||
|
app: &Rc<RefCell<App>>,
|
||||||
|
window: &AppWindow,
|
||||||
|
from_workers: std::sync::mpsc::Receiver<FromWorker>,
|
||||||
|
) {
|
||||||
|
APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app)));
|
||||||
|
|
||||||
|
let queue: Arc<Mutex<VecDeque<FromWorker>>> = Arc::default();
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let producer = Arc::clone(&queue);
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("relay".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for message in from_workers {
|
||||||
|
match producer.lock() {
|
||||||
|
Ok(mut queue) => queue.push_back(message),
|
||||||
|
// Only reachable if a UI-thread panic poisoned the lock, in which
|
||||||
|
// case there is no UI left to deliver to.
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
let drain = Arc::clone(&producer);
|
||||||
|
let posted = weak.upgrade_in_event_loop(move |window| {
|
||||||
|
let messages: Vec<FromWorker> = match drain.lock() {
|
||||||
|
Ok(mut queue) => queue.drain(..).collect(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if messages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
APP.with(|slot| {
|
||||||
|
let Some(app) = slot.borrow().clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut app = app.borrow_mut();
|
||||||
|
for message in messages {
|
||||||
|
handle_worker_message(&mut app, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render(&mut app.borrow_mut(), &window);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if posted.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("spawning the worker relay");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_worker_message(app: &mut App, message: FromWorker) {
|
||||||
|
match message {
|
||||||
|
FromWorker::Library(Ok(albums)) => {
|
||||||
|
app.library = Library::build(albums);
|
||||||
|
app.loaded = true;
|
||||||
|
app.error = None;
|
||||||
|
}
|
||||||
|
FromWorker::Library(Err(error)) => {
|
||||||
|
app.error = Some(error);
|
||||||
|
}
|
||||||
|
FromWorker::Cover(ready) => app.covers.insert(ready),
|
||||||
|
FromWorker::Ws(WsEvent::Connected) => app.online = true,
|
||||||
|
FromWorker::Ws(WsEvent::Disconnected) => app.online = false,
|
||||||
|
FromWorker::Ws(WsEvent::State(state)) => {
|
||||||
|
let key = format!("{:?}:{}", state.album_id, state.track_index);
|
||||||
|
// A new track means the bar must jump, not slide: animating across two
|
||||||
|
// unrelated positions is what makes a seek look like it drifts backwards.
|
||||||
|
app.smooth = key == app.track_key;
|
||||||
|
app.track_key = key;
|
||||||
|
app.player = *state;
|
||||||
|
}
|
||||||
|
FromWorker::Ws(WsEvent::Position { position, duration }) => {
|
||||||
|
app.player.position = position;
|
||||||
|
if duration > 0.0 {
|
||||||
|
app.player.duration = duration;
|
||||||
|
}
|
||||||
|
app.smooth = true;
|
||||||
|
}
|
||||||
|
FromWorker::Ws(WsEvent::LibraryChanged) => {
|
||||||
|
// A rescan is the one moment the backend rewrites the art behind an
|
||||||
|
// unchanged cover URL, so the thumbnails go with it.
|
||||||
|
app.covers.clear();
|
||||||
|
app.send(Command::ReloadLibrary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
|
||||||
|
// Every callback does the same three things: mutate state, maybe queue a command,
|
||||||
|
// re-render. `bind!` is that shape written once, so no individual handler has to
|
||||||
|
// remember the third.
|
||||||
|
macro_rules! bind {
|
||||||
|
($setter:ident, |$a:ident $(, $arg:ident : $ty:ty)*| $body:block) => {
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = Rc::clone(app);
|
||||||
|
window.$setter(move |$($arg: $ty),*| {
|
||||||
|
let Some(window) = weak.upgrade() else { return };
|
||||||
|
{
|
||||||
|
let mut $a = handle.borrow_mut();
|
||||||
|
$body
|
||||||
|
}
|
||||||
|
render(&mut handle.borrow_mut(), &window);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bind!(on_key, |a, key: SharedString, ctrl: bool, shift: bool| {
|
||||||
|
let results = a.library.results(&a.query());
|
||||||
|
let shelves = a.shelf_lengths();
|
||||||
|
let sheet_tracks =
|
||||||
|
a.ui.open_album
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| a.library.get(id))
|
||||||
|
.map_or(0, |album| album.tracks.len());
|
||||||
|
let mut ui = a.ui.clone();
|
||||||
|
let actions = keymap::handle_key(
|
||||||
|
key.as_str(),
|
||||||
|
ctrl,
|
||||||
|
shift,
|
||||||
|
&mut ui,
|
||||||
|
&results,
|
||||||
|
&shelves,
|
||||||
|
sheet_tracks,
|
||||||
|
);
|
||||||
|
a.ui = ui;
|
||||||
|
a.apply(actions);
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_toggle, |a| {
|
||||||
|
a.apply(vec![Action::Toggle]);
|
||||||
|
});
|
||||||
|
bind!(on_next, |a| {
|
||||||
|
a.apply(vec![Action::Next]);
|
||||||
|
});
|
||||||
|
bind!(on_previous, |a| {
|
||||||
|
a.apply(vec![Action::Previous]);
|
||||||
|
});
|
||||||
|
bind!(on_mute, |a| {
|
||||||
|
a.apply(vec![Action::Mute]);
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_seek, |a, fraction: f32| {
|
||||||
|
let target = (fraction as f64 * a.player.duration).clamp(0.0, a.player.duration);
|
||||||
|
a.player.position = target;
|
||||||
|
a.smooth = false;
|
||||||
|
a.send(Command::Seek(target));
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_set_volume, |a, percent: i32| {
|
||||||
|
a.player.volume = percent.clamp(0, 100);
|
||||||
|
a.send(Command::Volume(a.player.volume));
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_go_back, |a| {
|
||||||
|
if a.ui.open_album.is_some() {
|
||||||
|
a.ui.open_album = None;
|
||||||
|
} else if a.ui.play_view {
|
||||||
|
a.ui.play_view = false;
|
||||||
|
} else {
|
||||||
|
a.ui.browse_back();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_enter_group, |a, shelf: i32| {
|
||||||
|
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||||||
|
a.ui.category = None;
|
||||||
|
a.ui.sel_index = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_enter_category, |a, shelf: i32, key: SharedString| {
|
||||||
|
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||||||
|
a.ui.category = Some(key.to_string());
|
||||||
|
a.ui.sel_index = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_open_category, |a, key: SharedString| {
|
||||||
|
a.ui.category = Some(key.to_string());
|
||||||
|
a.ui.sel_index = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_open_album, |a, id: SharedString| {
|
||||||
|
// A podcast episode is a single track with nothing to choose between, so the
|
||||||
|
// cover plays it rather than opening a list of one.
|
||||||
|
match a.library.get(id.as_str()).map(Rc::clone) {
|
||||||
|
Some(album) if Group::of(&album) == Group::Podcasts => {
|
||||||
|
a.apply(vec![Action::Play {
|
||||||
|
album_id: album.id.clone(),
|
||||||
|
track_index: 0,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
Some(album) => {
|
||||||
|
a.ui.open_album = Some(album.id.clone());
|
||||||
|
a.ui.sheet_index = 0;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_play_album, |a, id: SharedString| {
|
||||||
|
a.apply(vec![Action::Play {
|
||||||
|
album_id: id.to_string(),
|
||||||
|
track_index: 0,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_play_song, |a, id: SharedString, index: i32| {
|
||||||
|
a.apply(vec![Action::Play {
|
||||||
|
album_id: id.to_string(),
|
||||||
|
track_index: index.max(0) as usize,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_open_play_view, |a| {
|
||||||
|
a.ui.play_view = true;
|
||||||
|
});
|
||||||
|
bind!(on_close_sheet, |a| {
|
||||||
|
a.ui.open_album = None;
|
||||||
|
});
|
||||||
|
|
||||||
|
bind!(on_play_sheet_track, |a, index: i32| {
|
||||||
|
if let Some(album_id) = a.ui.open_album.clone() {
|
||||||
|
a.apply(vec![Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index: index.max(0) as usize,
|
||||||
|
}]);
|
||||||
|
a.ui.open_album = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
95
slint-frontend/src/oklch.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//! oklch -> sRGB.
|
||||||
|
//!
|
||||||
|
//! The web front-end's palette is oklch throughout (`web/src/styles/app.css`) and
|
||||||
|
//! Slint has no such colour space, so every value crossing over is converted here.
|
||||||
|
//! Ported from `web/src/lib/oklch.ts`.
|
||||||
|
//!
|
||||||
|
//! The static palette is converted once at build time into the literals in
|
||||||
|
//! `ui/theme.slint`; this module exists for the values that are only known at runtime -
|
||||||
|
//! the per-group hues behind the generated book-spine art.
|
||||||
|
|
||||||
|
/// `l` is 0..1 (not the 0..100% that CSS writes), `c` is chroma, `h` is degrees.
|
||||||
|
pub fn oklch_to_rgb(l: f32, c: f32, h: f32) -> [u8; 3] {
|
||||||
|
let (sin, cos) = (h.to_radians().sin(), h.to_radians().cos());
|
||||||
|
let (a, b) = (c * cos, c * sin);
|
||||||
|
|
||||||
|
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
||||||
|
let m_ = l - 0.105_561_34 * a - 0.063_854_17 * b;
|
||||||
|
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
||||||
|
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||||
|
|
||||||
|
let lin = [
|
||||||
|
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
|
||||||
|
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
|
||||||
|
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
|
||||||
|
];
|
||||||
|
lin.map(|v| {
|
||||||
|
// Gamut clipping is per channel and deliberately crude: every colour this app
|
||||||
|
// actually asks for is inside sRGB, so a channel landing outside it means a
|
||||||
|
// typo, and a hard clamp shows that as a flat primary rather than hiding it.
|
||||||
|
let v = v.clamp(0.0, 1.0);
|
||||||
|
let encoded = if v <= 0.003_130_8 {
|
||||||
|
12.92 * v
|
||||||
|
} else {
|
||||||
|
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||||
|
};
|
||||||
|
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#rrggbb` as the backend writes it. Anything unparseable falls back rather than
|
||||||
|
/// failing: the colours are decoration, and an album with a malformed one should still
|
||||||
|
/// appear on the shelf.
|
||||||
|
pub fn parse_hex(hex: &str, fallback: [u8; 3]) -> [u8; 3] {
|
||||||
|
let digits = hex.trim().trim_start_matches('#');
|
||||||
|
if digits.len() != 6 {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok();
|
||||||
|
match (byte(0), byte(2), byte(4)) {
|
||||||
|
(Some(r), Some(g), Some(b)) => [r, g, b],
|
||||||
|
_ => fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear blend, for the spine's translucent highlight lines.
|
||||||
|
pub fn mix(base: [u8; 3], over: [u8; 3], alpha: f32) -> [u8; 3] {
|
||||||
|
let alpha = alpha.clamp(0.0, 1.0);
|
||||||
|
[0, 1, 2].map(|i| (base[i] as f32 * (1.0 - alpha) + over[i] as f32 * alpha).round() as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_anchors_round_trip_into_srgb() {
|
||||||
|
// Pure white and pure black are the two values with no room to be subtly wrong.
|
||||||
|
assert_eq!(oklch_to_rgb(1.0, 0.0, 0.0), [255, 255, 255]);
|
||||||
|
assert_eq!(oklch_to_rgb(0.0, 0.0, 0.0), [0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_accent_pink_lands_where_the_web_ui_puts_it() {
|
||||||
|
// oklch(70% 0.16 340) is `--accent`; the browser resolves it to ~#e A pink in
|
||||||
|
// the red-magenta corner. Assert the shape of it rather than an exact byte.
|
||||||
|
let [r, g, b] = oklch_to_rgb(0.70, 0.16, 340.0);
|
||||||
|
assert!(r > g && b > g, "expected a pink, got {r},{g},{b}");
|
||||||
|
assert!(r > 200, "expected a light pink, got {r},{g},{b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_parsing_falls_back_rather_than_failing() {
|
||||||
|
assert_eq!(parse_hex("#4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
|
||||||
|
assert_eq!(parse_hex("4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
|
||||||
|
assert_eq!(parse_hex("nope", [1, 2, 3]), [1, 2, 3]);
|
||||||
|
assert_eq!(parse_hex("", [1, 2, 3]), [1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mix_interpolates_between_the_two_ends() {
|
||||||
|
assert_eq!(mix([0, 0, 0], [255, 255, 255], 0.0), [0, 0, 0]);
|
||||||
|
assert_eq!(mix([0, 0, 0], [255, 255, 255], 1.0), [255, 255, 255]);
|
||||||
|
assert_eq!(mix([0, 0, 0], [200, 100, 50], 0.5), [100, 50, 25]);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
slint-frontend/src/ws.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//! The state websocket, with reconnect-and-reseed.
|
||||||
|
//!
|
||||||
|
//! Ported from `web/src/hooks/usePlayerState.ts`. The backend pushes and never listens,
|
||||||
|
//! so this is a read loop and nothing else. Two details are load-bearing:
|
||||||
|
//!
|
||||||
|
//! * **Reseed on open, not just on connect.** A reconnect can have missed every change
|
||||||
|
//! between the drop and the new socket, and the server only sends a snapshot to a
|
||||||
|
//! client it already considers connected. So each successful open is followed by a
|
||||||
|
//! `GET /api/state`.
|
||||||
|
//! * **Position is not on the event bus.** The server polls its player on a 0.5 s timer
|
||||||
|
//! and sends `position` frames only while something is playing - so the absence of
|
||||||
|
//! them is not a stall, and the UI interpolates between them rather than waiting.
|
||||||
|
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::api::{Client, PlayerState, ServerMessage};
|
||||||
|
|
||||||
|
/// What the UI thread hears from this connection. Wider than `ServerMessage` because
|
||||||
|
/// connectivity is itself a thing the screen shows.
|
||||||
|
pub enum Update {
|
||||||
|
Connected,
|
||||||
|
Disconnected,
|
||||||
|
State(Box<PlayerState>),
|
||||||
|
Position {
|
||||||
|
position: f64,
|
||||||
|
duration: f64,
|
||||||
|
},
|
||||||
|
/// The library was rescanned: re-fetch it, and drop the cover thumbnails, since a
|
||||||
|
/// rescan is exactly when the art behind an unchanged URL gets rewritten.
|
||||||
|
LibraryChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matches the web client's retry interval. Short enough that the screen comes back
|
||||||
|
/// before a child gives up on it, long enough not to spin while the backend restarts.
|
||||||
|
const RETRY: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
|
pub fn spawn(client: Client, updates: Sender<Update>) -> std::thread::JoinHandle<()> {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("ws".into())
|
||||||
|
.spawn(move || loop {
|
||||||
|
match connect_once(&client, &updates) {
|
||||||
|
Ok(()) | Err(()) => {}
|
||||||
|
}
|
||||||
|
// `send` failing means the UI is gone, which is the one reason to stop.
|
||||||
|
if updates.send(Update::Disconnected).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(RETRY);
|
||||||
|
})
|
||||||
|
.expect("spawning the websocket thread")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_once(client: &Client, updates: &Sender<Update>) -> Result<(), ()> {
|
||||||
|
let (mut socket, _) = tungstenite::connect(client.websocket_url()).map_err(|_| ())?;
|
||||||
|
|
||||||
|
if updates.send(Update::Connected).is_err() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
// Reseed: see the note at the top of this module.
|
||||||
|
if let Ok(state) = client.state() {
|
||||||
|
if updates.send(Update::State(Box::new(state))).is_err() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let message = match socket.read() {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(_) => return Err(()),
|
||||||
|
};
|
||||||
|
let text = match message {
|
||||||
|
tungstenite::Message::Text(text) => text,
|
||||||
|
tungstenite::Message::Close(_) => return Ok(()),
|
||||||
|
// Ping/Pong are answered by tungstenite itself on the next write; binary
|
||||||
|
// frames are not part of this protocol.
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let parsed: ServerMessage = match serde_json::from_str(&text) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
// A frame this build cannot read is not a reason to drop the connection -
|
||||||
|
// the next one is probably fine.
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let update = match parsed {
|
||||||
|
ServerMessage::State { state } => Update::State(Box::new(state)),
|
||||||
|
ServerMessage::Position { position, duration } => {
|
||||||
|
Update::Position { position, duration }
|
||||||
|
}
|
||||||
|
ServerMessage::Library => Update::LibraryChanged,
|
||||||
|
ServerMessage::Unknown => continue,
|
||||||
|
};
|
||||||
|
if updates.send(update).is_err() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
slint-frontend/tools/headless-shots.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Render the UI and screenshot it without touching your session.
|
||||||
|
#
|
||||||
|
# A Slint window needs a compositor: under bare Xvfb nothing maps, and on a locked or
|
||||||
|
# remote machine there is no session to draw into. So this starts a nested headless
|
||||||
|
# sway, runs the app inside it, drives it with synthetic keys and grabs a frame per
|
||||||
|
# screen. It is how the shots in README.md were made, and how to check a layout change
|
||||||
|
# on a machine with no display.
|
||||||
|
#
|
||||||
|
# nix-shell -E '(import ./shell.nix {}).overrideAttrs (o: {
|
||||||
|
# nativeBuildInputs = o.nativeBuildInputs ++ (with (import <nixpkgs> {}); [ sway grim wtype ]);
|
||||||
|
# })' --run 'tools/headless-shots.sh out/ http://127.0.0.1:8080'
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
OUTDIR="${1:?usage: headless-shots.sh <outdir> [server-url]}"
|
||||||
|
SERVER="${2:-http://127.0.0.1:8080}"
|
||||||
|
BIN="${BIN:-./target/release/musicmouse-slint}"
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
|
export XDG_RUNTIME_DIR="$(mktemp -d)"
|
||||||
|
unset WAYLAND_DISPLAY DISPLAY
|
||||||
|
export WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman
|
||||||
|
trap 'kill %1 2>/dev/null || true; rm -rf "$XDG_RUNTIME_DIR"' EXIT
|
||||||
|
|
||||||
|
launcher="$XDG_RUNTIME_DIR/launch.sh"
|
||||||
|
printf '#!/usr/bin/env bash\nexec %q --server %q >%q/app.log 2>&1\n' \
|
||||||
|
"$(realpath "$BIN")" "$SERVER" "$XDG_RUNTIME_DIR" > "$launcher"
|
||||||
|
chmod +x "$launcher"
|
||||||
|
printf 'output HEADLESS-1 mode 1920x1080\ndefault_border none\nexec %s\n' "$launcher" \
|
||||||
|
> "$XDG_RUNTIME_DIR/sway.cfg"
|
||||||
|
|
||||||
|
sway -c "$XDG_RUNTIME_DIR/sway.cfg" >"$XDG_RUNTIME_DIR/sway.log" 2>&1 &
|
||||||
|
for _ in $(seq 30); do [ -S "$XDG_RUNTIME_DIR/wayland-1" ] && break; sleep 1; done
|
||||||
|
export WAYLAND_DISPLAY=wayland-1
|
||||||
|
sleep 16 # library fetch plus the first covers
|
||||||
|
|
||||||
|
# Each step is ONE wtype invocation, and starts with a throwaway keypress.
|
||||||
|
#
|
||||||
|
# wtype creates a virtual keyboard, sends, and exits. The client needs a moment to
|
||||||
|
# notice the seat gained a keyboard, so the first key or two of any invocation is
|
||||||
|
# dropped - which makes a per-key loop lose every key it sends. Batching a step into
|
||||||
|
# one invocation and opening it with "x" + BackSpace spends that warm-up on a keypress
|
||||||
|
# that cancels itself out.
|
||||||
|
WARM=(-s 260 "x" -k BackSpace)
|
||||||
|
shot() { sleep 2.5; grim "$OUTDIR/$1.png"; echo " $1"; }
|
||||||
|
|
||||||
|
shot 01-root
|
||||||
|
wtype "${WARM[@]}" -k Down -k Return; shot 02-categories
|
||||||
|
wtype "${WARM[@]}" -k Return; shot 03-grid
|
||||||
|
wtype "${WARM[@]}" -k Escape -k Escape "maja"; shot 04-search
|
||||||
|
wtype "${WARM[@]}" -k Escape -k question "brumm"; shot 05-tracksearch
|
||||||
|
wtype "${WARM[@]}" -k Return; sleep 4; shot 06-play
|
||||||
|
wtype "${WARM[@]}" -k Escape; shot 07-bar
|
||||||
|
|
||||||
|
echo "wrote 7 frames to $OUTDIR"
|
||||||
261
slint-frontend/ui/app.slint
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { RoundButton } from "widgets.slint";
|
||||||
|
import { ShelfView, ResultsView } from "browse.slint";
|
||||||
|
import { PlayerBar, PlayView, AlbumSheet } from "player.slint";
|
||||||
|
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying } from "models.slint";
|
||||||
|
|
||||||
|
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, Theme }
|
||||||
|
|
||||||
|
export component AppWindow inherits Window {
|
||||||
|
title: "Musik Delfin";
|
||||||
|
default-font-family: Theme.font;
|
||||||
|
default-font-size: Theme.text-body;
|
||||||
|
// The stage. In the web app a per-track animated canvas sits on top of this; here
|
||||||
|
// it is the whole background, on purpose. A full-viewport repaint loop is a floor
|
||||||
|
// you cannot get under while it runs at all, and it is the first thing `?pi=1`
|
||||||
|
// already removes on the device this is meant to replace.
|
||||||
|
background: @linear-gradient(180deg,
|
||||||
|
Theme.stage-top 0%,
|
||||||
|
Theme.stage-mid 45%,
|
||||||
|
Theme.sea-deep 100%);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- inputs --
|
||||||
|
in property <bool> loading: true;
|
||||||
|
in property <string> splash-text;
|
||||||
|
in property <string> status-label;
|
||||||
|
in property <bool> online: false;
|
||||||
|
|
||||||
|
in property <bool> play-view: false;
|
||||||
|
in property <bool> show-sheet: false;
|
||||||
|
in property <bool> can-go-back: false;
|
||||||
|
|
||||||
|
in property <[Shelf]> shelves;
|
||||||
|
in property <bool> root-screen: true;
|
||||||
|
in property <int> shelf-row: 0;
|
||||||
|
|
||||||
|
in property <string> group-label;
|
||||||
|
in property <string> category-heading;
|
||||||
|
in property <string> album-heading;
|
||||||
|
in property <bool> show-search: false;
|
||||||
|
in property <string> search-text;
|
||||||
|
in property <string> mode-label;
|
||||||
|
in property <string> count-label;
|
||||||
|
in property <[TrackRow]> songs;
|
||||||
|
in property <[CategoryRow]> category-grid;
|
||||||
|
in property <[GridRow]> grid;
|
||||||
|
in property <bool> no-results: false;
|
||||||
|
in property <int> sel-index: 0;
|
||||||
|
in property <int> sel-row: 0;
|
||||||
|
|
||||||
|
in property <NowPlaying> now;
|
||||||
|
in property <bool> playing: false;
|
||||||
|
in property <float> progress: 0.0;
|
||||||
|
in property <bool> smooth: true;
|
||||||
|
in property <int> volume: 60;
|
||||||
|
in property <bool> has-bar: false;
|
||||||
|
|
||||||
|
in property <string> sheet-title;
|
||||||
|
in property <string> sheet-subtitle;
|
||||||
|
in property <string> sheet-meta;
|
||||||
|
in property <image> sheet-cover;
|
||||||
|
in property <bool> sheet-is-book: false;
|
||||||
|
in property <[TrackRow]> sheet-tracks;
|
||||||
|
in property <int> sheet-index: 0;
|
||||||
|
|
||||||
|
// How wide the grid may be. Rust reads this back to decide the column count, so the
|
||||||
|
// chunking into `GridRow`s and the layout agree about how many fit.
|
||||||
|
out property <length> grid-width: self.width - 32px - Theme.rail-clearance;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- callbacks --
|
||||||
|
// The special keys are named here rather than in Rust because this is where the
|
||||||
|
// `Key.*` constants exist; Rust receives "escape"/"left"/"enter"/… or the literal
|
||||||
|
// character that was typed.
|
||||||
|
callback key(string, bool, bool);
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
callback seek(float);
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
callback go-back();
|
||||||
|
callback enter-group(int);
|
||||||
|
callback enter-category(int, string);
|
||||||
|
callback open-category(string);
|
||||||
|
callback open-album(string);
|
||||||
|
callback play-album(string);
|
||||||
|
callback play-song(string, int);
|
||||||
|
callback open-play-view();
|
||||||
|
callback close-sheet();
|
||||||
|
callback play-sheet-track(int);
|
||||||
|
|
||||||
|
forward-focus: keys;
|
||||||
|
|
||||||
|
keys := FocusScope {
|
||||||
|
key-pressed(event) => {
|
||||||
|
root.key(
|
||||||
|
event.text == Key.Escape ? "escape"
|
||||||
|
: event.text == Key.Return ? "enter"
|
||||||
|
: event.text == Key.Backspace ? "backspace"
|
||||||
|
: event.text == Key.Tab ? "tab"
|
||||||
|
: event.text == Key.LeftArrow ? "left"
|
||||||
|
: event.text == Key.RightArrow ? "right"
|
||||||
|
: event.text == Key.UpArrow ? "up"
|
||||||
|
: event.text == Key.DownArrow ? "down"
|
||||||
|
: event.text == Key.F1 ? "f1"
|
||||||
|
: event.text == Key.Space ? "space"
|
||||||
|
: event.text,
|
||||||
|
event.modifiers.control,
|
||||||
|
event.modifiers.shift);
|
||||||
|
accept
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
// ------------------------------------------------------------ header --
|
||||||
|
if !root.play-view: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 16px;
|
||||||
|
padding-top: 18px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
Text {
|
||||||
|
text: "Musik Delfin";
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
if root.status-label != "": VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.paper.with-alpha(0.16);
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
width: status.preferred-width;
|
||||||
|
height: status.preferred-height;
|
||||||
|
status := HorizontalLayout {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.status-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: root.online ? Theme.paper : #ffb4a2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- body --
|
||||||
|
if !root.play-view && root.root-screen: ShelfView {
|
||||||
|
shelves: root.shelves;
|
||||||
|
shelf-row: root.shelf-row;
|
||||||
|
sel-index: root.sel-index;
|
||||||
|
enter-group(s) => { root.enter-group(s); }
|
||||||
|
enter-category(s, key) => { root.enter-category(s, key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !root.play-view && !root.root-screen: ResultsView {
|
||||||
|
group-label: root.group-label;
|
||||||
|
category-heading: root.category-heading;
|
||||||
|
album-heading: root.album-heading;
|
||||||
|
show-search: root.show-search;
|
||||||
|
search-text: root.search-text;
|
||||||
|
mode-label: root.mode-label;
|
||||||
|
count-label: root.count-label;
|
||||||
|
songs: root.songs;
|
||||||
|
category-grid: root.category-grid;
|
||||||
|
grid: root.grid;
|
||||||
|
sel-index: root.sel-index;
|
||||||
|
sel-row: root.sel-row;
|
||||||
|
empty: root.no-results;
|
||||||
|
play-song(id, i) => { root.play-song(id, i); }
|
||||||
|
open-category(key) => { root.open-category(key); }
|
||||||
|
open-album(id) => { root.open-album(id); }
|
||||||
|
play-album(id) => { root.play-album(id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.play-view: PlayView {
|
||||||
|
now: root.now;
|
||||||
|
playing: root.playing;
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
volume: root.volume;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
seek(f) => { root.seek(f); }
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
open-album => { root.open-album(root.now.title); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The player bar floats over the browse screen rather than displacing it, which is
|
||||||
|
// why the scrollers above reserve bottom padding for it. Geometry is set here
|
||||||
|
// rather than inside the component: a plain element takes its *preferred* width
|
||||||
|
// from its content, not its parent's width, so a bar that only declared a height
|
||||||
|
// left gaps for the grid behind it to show through.
|
||||||
|
if !root.play-view && root.has-bar: PlayerBar {
|
||||||
|
x: 0;
|
||||||
|
y: parent.height - self.height;
|
||||||
|
width: parent.width;
|
||||||
|
now: root.now;
|
||||||
|
playing: root.playing;
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
volume: root.volume;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
seek(f) => { root.seek(f); }
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
open-play-view => { root.open-play-view(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One fixed round button in the top-left: back, everywhere it applies, so "top
|
||||||
|
// left" always means the same thing.
|
||||||
|
if root.can-go-back: RoundButton {
|
||||||
|
x: 24px;
|
||||||
|
y: 18px;
|
||||||
|
label: "‹";
|
||||||
|
clicked => { root.go-back(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.show-sheet: AlbumSheet {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
title: root.sheet-title;
|
||||||
|
subtitle: root.sheet-subtitle;
|
||||||
|
meta: root.sheet-meta;
|
||||||
|
cover: root.sheet-cover;
|
||||||
|
is-book: root.sheet-is-book;
|
||||||
|
tracks: root.sheet-tracks;
|
||||||
|
sel-index: root.sheet-index;
|
||||||
|
close => { root.close-sheet(); }
|
||||||
|
play(i) => { root.play-sheet-track(i); }
|
||||||
|
play-all => { root.play-sheet-track(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.loading: Rectangle {
|
||||||
|
background: Theme.sea-deep.with-alpha(0.92);
|
||||||
|
TouchArea { }
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 18px;
|
||||||
|
Text {
|
||||||
|
text: "🐬";
|
||||||
|
font-size: 96px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.splash-text;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
567
slint-frontend/ui/browse.slint
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { ListView } from "std-widgets.slint";
|
||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { GlassPanel, Cover } from "widgets.slint";
|
||||||
|
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow } from "models.slint";
|
||||||
|
|
||||||
|
// Heights are fixed rather than derived from content, and that is the whole reason the
|
||||||
|
// grid can be virtualized: a `ListView` that has to measure a row before it can decide
|
||||||
|
// whether to skip it has not skipped anything. The web grid does the same thing with
|
||||||
|
// `contain-intrinsic-size` for the same reason.
|
||||||
|
//
|
||||||
|
// A book is taller than an album, so the cover *slot* is sized for the taller of the
|
||||||
|
// two and each cover is centred in it. That keeps every row identical without
|
||||||
|
// flattening the shape difference that tells the two media apart.
|
||||||
|
export global Metrics {
|
||||||
|
out property <length> cover-slot: 200px;
|
||||||
|
out property <length> card-text: 78px;
|
||||||
|
out property <length> card-height: 278px; // cover-slot + card-text
|
||||||
|
out property <length> row-height: 302px; // card-height + grid-gap
|
||||||
|
out property <length> shelf-height: 236px;
|
||||||
|
out property <length> category-row-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
component SelectionRing inherits Rectangle {
|
||||||
|
in property <bool> selected;
|
||||||
|
in property <bool> current;
|
||||||
|
|
||||||
|
// Selection outranks "currently playing": when the keyboard is on the album that is
|
||||||
|
// also playing, the ring it needs to see is the one that moves.
|
||||||
|
border-width: root.selected ? 5px : (root.current ? 4px : 0px);
|
||||||
|
border-color: root.selected ? Theme.paper : Theme.accent;
|
||||||
|
border-radius: Theme.radius-card + 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
component AlbumCard inherits Rectangle {
|
||||||
|
in property <AlbumTile> tile;
|
||||||
|
in property <bool> selected;
|
||||||
|
callback open();
|
||||||
|
callback play();
|
||||||
|
|
||||||
|
height: Metrics.card-height;
|
||||||
|
|
||||||
|
ring := SelectionRing {
|
||||||
|
selected: root.selected;
|
||||||
|
current: root.tile.current;
|
||||||
|
x: -7px;
|
||||||
|
y: -7px;
|
||||||
|
width: parent.width + 14px;
|
||||||
|
height: parent.height + 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
card := Rectangle {
|
||||||
|
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
||||||
|
border-radius: Theme.radius-card;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 18px;
|
||||||
|
drop-shadow-offset-y: 6px;
|
||||||
|
// The lift on selection: 120ms, the same as the web card's transition.
|
||||||
|
y: root.selected ? -3px : 0px;
|
||||||
|
animate y { duration: 120ms; easing: ease-out; }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 10px;
|
||||||
|
spacing: 6px;
|
||||||
|
|
||||||
|
// The cover, centred in a slot tall enough for the taller media type.
|
||||||
|
Rectangle {
|
||||||
|
height: Metrics.cover-slot - 20px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Cover {
|
||||||
|
source: root.tile.cover;
|
||||||
|
is-book: root.tile.is-book;
|
||||||
|
height: parent.height;
|
||||||
|
width: root.tile.is-book ? parent.height * 0.82 : parent.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
// Cover opens the track list; the title below starts it playing.
|
||||||
|
// Two targets, because "show me what is in this" and "play this"
|
||||||
|
// are different intentions and a child has both.
|
||||||
|
clicked => { root.open(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: Metrics.card-text - 26px;
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 1px;
|
||||||
|
Text {
|
||||||
|
text: root.tile.title;
|
||||||
|
font-size: Theme.text-body - 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.ink;
|
||||||
|
wrap: word-wrap;
|
||||||
|
overflow: elide;
|
||||||
|
// Two lines always, whether or not the title needs them, so
|
||||||
|
// every card in the row ends at the same place.
|
||||||
|
height: (Theme.text-body - 1px) * 2.4;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.tile.artist;
|
||||||
|
font-size: Theme.text-small - 1px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink.with-alpha(0.65);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.play(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.tile.meta;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.count-pink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component CategoryCard inherits Rectangle {
|
||||||
|
in property <CategoryTile> tile;
|
||||||
|
in property <bool> selected;
|
||||||
|
in property <length> tile-width: Theme.shelf-tile;
|
||||||
|
callback open();
|
||||||
|
|
||||||
|
width: root.tile-width;
|
||||||
|
height: Metrics.shelf-height;
|
||||||
|
|
||||||
|
SelectionRing {
|
||||||
|
selected: root.selected;
|
||||||
|
current: false;
|
||||||
|
x: -7px;
|
||||||
|
y: -7px;
|
||||||
|
width: parent.width + 14px;
|
||||||
|
height: parent.height + 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
||||||
|
border-radius: Theme.radius-card;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 18px;
|
||||||
|
drop-shadow-offset-y: 6px;
|
||||||
|
y: root.selected ? -3px : 0px;
|
||||||
|
animate y { duration: 120ms; easing: ease-out; }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 8px;
|
||||||
|
spacing: 5px;
|
||||||
|
|
||||||
|
// One album fills the tile; several show as a 2x2 contact sheet, which is
|
||||||
|
// how a category reads as "a stack of these" without a label saying so.
|
||||||
|
Rectangle {
|
||||||
|
height: root.tile-width - 16px;
|
||||||
|
clip: true;
|
||||||
|
if root.tile.cover-count <= 1: Cover {
|
||||||
|
source: root.tile.cover1;
|
||||||
|
is-book: root.tile.is-book;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
if root.tile.cover-count > 1: VerticalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
Cover { source: root.tile.cover1; radius: 6px; }
|
||||||
|
Cover { source: root.tile.cover2; radius: 6px; }
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
Cover { source: root.tile.cover3; radius: 6px; }
|
||||||
|
Cover { source: root.tile.cover4; radius: 6px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.tile.key;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.ink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.tile.count-label;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.count-pink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.open(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component TrackListRow inherits Rectangle {
|
||||||
|
in property <TrackRow> track;
|
||||||
|
in property <bool> selected;
|
||||||
|
callback play();
|
||||||
|
|
||||||
|
height: 58px;
|
||||||
|
background: root.track.current ? Theme.row-active : Theme.row-idle;
|
||||||
|
border-radius: Theme.radius-row;
|
||||||
|
border-width: root.selected ? 3px : 1px;
|
||||||
|
border-color: root.selected ? Theme.paper : (root.track.current ? Theme.accent : Theme.glass-border);
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 16px;
|
||||||
|
spacing: 12px;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Cover {
|
||||||
|
source: root.track.cover;
|
||||||
|
is-book: root.track.is-book;
|
||||||
|
radius: 8px;
|
||||||
|
height: 40px;
|
||||||
|
width: root.track.is-book ? 33px : 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.track.title;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.track.subtitle;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-sub.with-alpha(0.75);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.track.duration;
|
||||||
|
font-family: Theme.mono;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.play(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root screen: three shelves, each scrolling sideways.
|
||||||
|
export component ShelfView inherits Flickable {
|
||||||
|
in property <[Shelf]> shelves;
|
||||||
|
in property <int> shelf-row;
|
||||||
|
in property <int> sel-index;
|
||||||
|
callback enter-group(int); // shelf index
|
||||||
|
callback enter-category(int, string); // shelf index, category key
|
||||||
|
|
||||||
|
content-height: layout.preferred-height;
|
||||||
|
content-width: self.width;
|
||||||
|
|
||||||
|
layout := VerticalLayout {
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: Theme.rail-clearance;
|
||||||
|
padding-top: 14px;
|
||||||
|
// Clear of the player bar, which floats over this rather than displacing it.
|
||||||
|
padding-bottom: 210px;
|
||||||
|
spacing: 25px;
|
||||||
|
|
||||||
|
for shelf[s] in root.shelves: GlassPanel {
|
||||||
|
height: Metrics.shelf-height + 74px;
|
||||||
|
// The panel itself is a target: it opens the whole group.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.enter-group(s); }
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 14px;
|
||||||
|
spacing: 10px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
alignment: start;
|
||||||
|
// A round frosted badge behind the emoji, which otherwise renders
|
||||||
|
// thin and low-contrast straight on the stage gradient.
|
||||||
|
Rectangle {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 17px;
|
||||||
|
background: Theme.paper.with-alpha(0.2);
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.paper.with-alpha(0.4);
|
||||||
|
Text {
|
||||||
|
text: shelf.icon;
|
||||||
|
font-size: 17px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: shelf.label;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: "›";
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper.with-alpha(0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shelf.tiles.length == 0: Text {
|
||||||
|
text: shelf.empty-label;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.6);
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
if shelf.tiles.length > 0: Flickable {
|
||||||
|
content-width: shelf.tiles.length * (Theme.shelf-tile + 12px) + 20px;
|
||||||
|
content-height: self.height;
|
||||||
|
// Keep the focused tile in view as the arrow keys walk past the fold.
|
||||||
|
content-x: root.shelf-row != s
|
||||||
|
? 0px
|
||||||
|
: -max(0px, min(
|
||||||
|
self.content-width - self.width,
|
||||||
|
root.sel-index * (Theme.shelf-tile + 12px) - self.width / 2 + Theme.shelf-tile / 2));
|
||||||
|
animate content-x { duration: 180ms; easing: ease-out; }
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
alignment: start;
|
||||||
|
for tile[i] in shelf.tiles: CategoryCard {
|
||||||
|
tile: tile;
|
||||||
|
selected: root.shelf-row == s && root.sel-index == i;
|
||||||
|
open => { root.enter-category(s, tile.key); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a group, or showing search results.
|
||||||
|
//
|
||||||
|
// Exactly one of the three lists is ever populated, and that is a property of the query
|
||||||
|
// model rather than a coincidence worth defending against: track hits only exist in
|
||||||
|
// track-search mode, which forces the album list empty; the category list only exists
|
||||||
|
// when there is neither a query nor a chosen category, which is exactly when the album
|
||||||
|
// list is empty. So this shows one list filling the page, instead of stacking three
|
||||||
|
// inside a scroller and nesting a second scroller in it.
|
||||||
|
export component ResultsView inherits VerticalLayout {
|
||||||
|
in property <string> group-label;
|
||||||
|
in property <string> category-heading;
|
||||||
|
in property <string> album-heading;
|
||||||
|
in property <bool> show-search;
|
||||||
|
in property <string> search-text;
|
||||||
|
in property <string> mode-label;
|
||||||
|
in property <string> count-label;
|
||||||
|
in property <[TrackRow]> songs;
|
||||||
|
in property <[CategoryRow]> category-grid;
|
||||||
|
in property <[GridRow]> grid;
|
||||||
|
in property <int> sel-index;
|
||||||
|
/// Which row of the visible grid holds the selection. Every row is the same height
|
||||||
|
/// by construction, so following the keyboard is arithmetic, not measurement.
|
||||||
|
in property <int> sel-row;
|
||||||
|
in property <bool> empty;
|
||||||
|
callback play-song(string, int);
|
||||||
|
callback open-category(string);
|
||||||
|
callback open-album(string);
|
||||||
|
callback play-album(string);
|
||||||
|
|
||||||
|
spacing: 6px;
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: Theme.rail-clearance;
|
||||||
|
|
||||||
|
if root.group-label != "": Text {
|
||||||
|
text: root.group-label;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.show-search: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 12px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.paper;
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 14px;
|
||||||
|
drop-shadow-offset-y: 4px;
|
||||||
|
width: chip.preferred-width;
|
||||||
|
height: chip.preferred-height;
|
||||||
|
|
||||||
|
chip := HorizontalLayout {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
spacing: 10px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.ink;
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
width: label.preferred-width;
|
||||||
|
label := HorizontalLayout {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
Text {
|
||||||
|
text: root.mode-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.search-text;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.ink;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.count-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.empty: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: "Nichts gefunden — probier andere Buchstaben!";
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- track hits -----------------------------------------------------------
|
||||||
|
// Capped at 40 rows upstream, so this one does not need virtualizing. Held to a
|
||||||
|
// readable column rather than the full width, which is what lets a long album
|
||||||
|
// line elide instead of running the width of a 1080p screen. The heading rides
|
||||||
|
// inside that column so the two are aligned with each other, not with the page.
|
||||||
|
if root.songs.length > 0: Flickable {
|
||||||
|
content-height: songs-column.preferred-height + 210px;
|
||||||
|
content-width: self.width;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
songs-column := VerticalLayout {
|
||||||
|
// Without this the column is centred in whatever vertical space is
|
||||||
|
// left over, which on a short result list leaves it floating.
|
||||||
|
alignment: start;
|
||||||
|
width: 860px;
|
||||||
|
spacing: 8px;
|
||||||
|
Text {
|
||||||
|
text: "Titel";
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
GlassPanel {
|
||||||
|
height: song-rows.preferred-height;
|
||||||
|
song-rows := VerticalLayout {
|
||||||
|
padding: 12px;
|
||||||
|
spacing: 6px;
|
||||||
|
for song in root.songs: TrackListRow {
|
||||||
|
track: song;
|
||||||
|
selected: root.sel-index == song.nav;
|
||||||
|
play => { root.play-song(song.album-id, song.index); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- categories -----------------------------------------------------------
|
||||||
|
if root.category-grid.length > 0: Text {
|
||||||
|
text: root.category-heading;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
if root.category-grid.length > 0: ListView {
|
||||||
|
for row in root.category-grid: HorizontalLayout {
|
||||||
|
height: Metrics.category-row-height;
|
||||||
|
spacing: Theme.grid-gap;
|
||||||
|
alignment: start;
|
||||||
|
for tile in row.tiles: CategoryCard {
|
||||||
|
tile: tile;
|
||||||
|
tile-width: Theme.card-width;
|
||||||
|
selected: root.sel-index == tile.nav;
|
||||||
|
open => { root.open-category(tile.key); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- albums ---------------------------------------------------------------
|
||||||
|
if root.grid.length > 0: Text {
|
||||||
|
text: root.album-heading;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
if root.grid.length > 0: album-list := ListView {
|
||||||
|
// Follow the keyboard without measuring anything.
|
||||||
|
content-y: -max(0px, min(
|
||||||
|
max(0px, self.content-height - self.height),
|
||||||
|
root.sel-row * Metrics.row-height));
|
||||||
|
animate content-y { duration: 180ms; easing: ease-out; }
|
||||||
|
|
||||||
|
for row in root.grid: HorizontalLayout {
|
||||||
|
height: Metrics.row-height;
|
||||||
|
spacing: Theme.grid-gap;
|
||||||
|
alignment: start;
|
||||||
|
for tile in row.tiles: AlbumCard {
|
||||||
|
width: Theme.card-width;
|
||||||
|
tile: tile;
|
||||||
|
selected: root.sel-index == tile.nav;
|
||||||
|
open => { root.open-album(tile.id); }
|
||||||
|
play => { root.play-album(tile.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
slint-frontend/ui/models.slint
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// The shapes that cross between Rust and the UI.
|
||||||
|
//
|
||||||
|
// Everything here is already *presentation*: labels are formatted, durations are
|
||||||
|
// strings, covers are decoded images. Nothing in the .slint files formats a number or
|
||||||
|
// picks a word, so the German copy and the duration format live in one place (Rust)
|
||||||
|
// rather than being split across two languages.
|
||||||
|
//
|
||||||
|
// `nav` is each item's position in the single flat selection order that runs songs ->
|
||||||
|
// categories -> albums, so one pair of arrow keys walks the whole page. It is assigned
|
||||||
|
// in Rust, where the three lists are built together.
|
||||||
|
|
||||||
|
export struct AlbumTile {
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
// "12 Songs · 45:03 · 🧸" - already assembled, including the figurine marker.
|
||||||
|
meta: string,
|
||||||
|
cover: image,
|
||||||
|
is-book: bool,
|
||||||
|
locked: bool,
|
||||||
|
// This album is the one playing; drawn with the accent ring rather than the
|
||||||
|
// selection ring, and outranked by it when they coincide.
|
||||||
|
current: bool,
|
||||||
|
nav: int,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grid is handed to the UI pre-chunked into rows, because a `ListView` virtualizes
|
||||||
|
// its model and a flat list of 343 cards has no rows to skip. This is the direct
|
||||||
|
// counterpart of the web app's `content-visibility: auto` on `.grid > .card`, which
|
||||||
|
// exists for exactly the same measurement: 340 live cards saturated the Pi's renderer.
|
||||||
|
export struct GridRow {
|
||||||
|
tiles: [AlbumTile],
|
||||||
|
}
|
||||||
|
|
||||||
|
export struct CategoryTile {
|
||||||
|
key: string,
|
||||||
|
// "7 Hörbücher"
|
||||||
|
count-label: string,
|
||||||
|
// Up to four covers in a 2x2, or one filling the tile when the category holds a
|
||||||
|
// single album. Four fields rather than an array: the count is fixed and small, and
|
||||||
|
// this keeps the tile a plain struct.
|
||||||
|
cover1: image,
|
||||||
|
cover2: image,
|
||||||
|
cover3: image,
|
||||||
|
cover4: image,
|
||||||
|
cover-count: int,
|
||||||
|
is-book: bool,
|
||||||
|
nav: int,
|
||||||
|
}
|
||||||
|
|
||||||
|
export struct CategoryRow {
|
||||||
|
tiles: [CategoryTile],
|
||||||
|
}
|
||||||
|
|
||||||
|
// One of the three shelves on the root screen.
|
||||||
|
export struct Shelf {
|
||||||
|
label: string,
|
||||||
|
icon: string,
|
||||||
|
tiles: [CategoryTile],
|
||||||
|
empty-label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export struct TrackRow {
|
||||||
|
title: string,
|
||||||
|
subtitle: string,
|
||||||
|
duration: string,
|
||||||
|
cover: image,
|
||||||
|
is-book: bool,
|
||||||
|
current: bool,
|
||||||
|
locked: bool,
|
||||||
|
album-id: string,
|
||||||
|
index: int,
|
||||||
|
nav: int,
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the player bar and the full-screen view both say. Shared so the two cannot
|
||||||
|
// drift apart on a copy change.
|
||||||
|
export struct NowPlaying {
|
||||||
|
title: string,
|
||||||
|
subtitle: string,
|
||||||
|
// "Song 3 von 8" / "Kapitel 3 von 8"
|
||||||
|
counter: string,
|
||||||
|
cover: image,
|
||||||
|
hero-cover: image,
|
||||||
|
is-book: bool,
|
||||||
|
has-album: bool,
|
||||||
|
// "−4:12", the time left in this track.
|
||||||
|
clock-label: string,
|
||||||
|
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
|
||||||
|
remaining-label: string,
|
||||||
|
}
|
||||||
413
slint-frontend/ui/player.slint
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { Cover, Transport, ProgressBar, VolumeBars, RoundButton, GlassPanel } from "widgets.slint";
|
||||||
|
import { NowPlaying, TrackRow } from "models.slint";
|
||||||
|
|
||||||
|
// The bar along the bottom of the browse screen.
|
||||||
|
export component PlayerBar inherits Rectangle {
|
||||||
|
in property <NowPlaying> now;
|
||||||
|
in property <bool> playing;
|
||||||
|
in property <float> progress;
|
||||||
|
in property <bool> smooth;
|
||||||
|
in property <int> volume;
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
callback seek(float);
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
callback open-play-view();
|
||||||
|
|
||||||
|
height: 96px;
|
||||||
|
background: Theme.bar-bg;
|
||||||
|
|
||||||
|
// The accent rule along the top is what separates the bar from the stage without a
|
||||||
|
// shadow, which over a gradient reads as a smudge.
|
||||||
|
Rectangle {
|
||||||
|
y: 0;
|
||||||
|
height: 3px;
|
||||||
|
width: 100%;
|
||||||
|
background: Theme.accent.with-alpha(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
spacing: 18px;
|
||||||
|
|
||||||
|
// Cover: the way back into the full-screen view.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: root.now.is-book ? 46px : 56px;
|
||||||
|
height: 56px;
|
||||||
|
Cover {
|
||||||
|
source: root.now.cover;
|
||||||
|
is-book: root.now.is-book;
|
||||||
|
radius: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.open-play-view(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
width: 240px;
|
||||||
|
Text {
|
||||||
|
text: root.now.title;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.now.subtitle;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 600;
|
||||||
|
color: Theme.text-sub.with-alpha(0.75);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
if root.now.has-album: Text {
|
||||||
|
text: root.now.counter;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.accent-dim;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
ProgressBar {
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
on-dark: true;
|
||||||
|
bar-height: 10px;
|
||||||
|
seek(fraction) => { root.seek(fraction); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Transport {
|
||||||
|
playing: root.playing;
|
||||||
|
size: 48px;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VolumeBars {
|
||||||
|
volume: root.volume;
|
||||||
|
bar-width: 11px;
|
||||||
|
bar-height: 30px;
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full-screen now-playing view.
|
||||||
|
export component PlayView inherits VerticalLayout {
|
||||||
|
in property <NowPlaying> now;
|
||||||
|
in property <bool> playing;
|
||||||
|
in property <float> progress;
|
||||||
|
in property <bool> smooth;
|
||||||
|
in property <int> volume;
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
callback seek(float);
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
callback open-album();
|
||||||
|
|
||||||
|
alignment: center;
|
||||||
|
spacing: 18px;
|
||||||
|
padding: 32px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
height: 320px;
|
||||||
|
width: root.now.is-book ? 262px : 320px;
|
||||||
|
drop-shadow-color: #0016208c;
|
||||||
|
drop-shadow-blur: 40px;
|
||||||
|
drop-shadow-offset-y: 20px;
|
||||||
|
|
||||||
|
if root.now.has-album: Cover {
|
||||||
|
source: root.now.hero-cover;
|
||||||
|
is-book: root.now.is-book;
|
||||||
|
radius: 28px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
if !root.now.has-album: Rectangle {
|
||||||
|
background: #2a4f57;
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.open-album(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.now.title;
|
||||||
|
font-size: Theme.text-hero;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.now.subtitle;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
horizontal-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
if root.now.has-album: Text {
|
||||||
|
text: root.now.counter;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.accent-dim;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VerticalLayout {
|
||||||
|
width: 680px;
|
||||||
|
spacing: 8px;
|
||||||
|
ProgressBar {
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
bar-height: 14px;
|
||||||
|
seek(fraction) => { root.seek(fraction); }
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
Text {
|
||||||
|
text: root.now.clock-label;
|
||||||
|
font-family: Theme.mono;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
}
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
Text {
|
||||||
|
text: root.now.remaining-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Transport {
|
||||||
|
playing: root.playing;
|
||||||
|
size: 72px;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
VolumeBars {
|
||||||
|
volume: root.volume;
|
||||||
|
bar-width: 18px;
|
||||||
|
bar-height: 44px;
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The track list for one album, over a dimmed stage.
|
||||||
|
export component AlbumSheet inherits Rectangle {
|
||||||
|
in property <string> title;
|
||||||
|
in property <string> subtitle;
|
||||||
|
in property <string> meta;
|
||||||
|
in property <image> cover;
|
||||||
|
in property <bool> is-book;
|
||||||
|
in property <[TrackRow]> tracks;
|
||||||
|
in property <int> sel-index;
|
||||||
|
callback close();
|
||||||
|
callback play(int);
|
||||||
|
callback play-all();
|
||||||
|
|
||||||
|
background: Theme.backdrop.with-alpha(0.6);
|
||||||
|
|
||||||
|
// Catches every click that misses the sheet, so tapping the dimmed area closes it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 640px;
|
||||||
|
height: min(root.height - 80px, 720px);
|
||||||
|
background: Theme.sheet-bg;
|
||||||
|
border-radius: Theme.radius-sheet;
|
||||||
|
drop-shadow-color: #0016208c;
|
||||||
|
drop-shadow-blur: 60px;
|
||||||
|
drop-shadow-offset-y: 24px;
|
||||||
|
|
||||||
|
// Without this the backdrop's TouchArea above would receive clicks aimed at the
|
||||||
|
// sheet's own background and close it.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 26px;
|
||||||
|
spacing: 16px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 18px;
|
||||||
|
Cover {
|
||||||
|
source: root.cover;
|
||||||
|
is-book: root.is-book;
|
||||||
|
width: root.is-book ? 123px : 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: start;
|
||||||
|
spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.title;
|
||||||
|
font-size: Theme.text-title;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.ink;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.subtitle;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink.with-alpha(0.7);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.meta;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.count-pink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
background: play-all-touch.pressed ? Theme.accent-dim : Theme.accent;
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
width: play-all.preferred-width;
|
||||||
|
height: play-all.preferred-height;
|
||||||
|
play-all := HorizontalLayout {
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
padding-top: 9px;
|
||||||
|
padding-bottom: 9px;
|
||||||
|
Text {
|
||||||
|
text: "▶ Alle abspielen";
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
play-all-touch := TouchArea {
|
||||||
|
clicked => { root.play-all(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: start;
|
||||||
|
RoundButton {
|
||||||
|
label: "×";
|
||||||
|
size: 40px;
|
||||||
|
clicked => { root.close(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
content-height: list.preferred-height;
|
||||||
|
content-width: self.width;
|
||||||
|
// Follow the keyboard through the list without measuring: every row is
|
||||||
|
// the same height, so the selected one's offset is arithmetic.
|
||||||
|
content-y: -max(0px, min(
|
||||||
|
max(0px, self.content-height - self.height),
|
||||||
|
root.sel-index * 52px - self.height / 2));
|
||||||
|
animate content-y { duration: 150ms; easing: ease-out; }
|
||||||
|
|
||||||
|
list := VerticalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
for track[i] in root.tracks: Rectangle {
|
||||||
|
height: 48px;
|
||||||
|
background: track.current ? Theme.accent.with-alpha(0.18) : transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
border-width: root.sel-index == i ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
spacing: 12px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: track.current ? Theme.accent : Theme.ink.with-alpha(0.12);
|
||||||
|
Text {
|
||||||
|
text: i + 1;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: track.current ? white : Theme.ink;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: track.title;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: track.duration;
|
||||||
|
font-family: Theme.mono;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink.with-alpha(0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.play(i); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
slint-frontend/ui/theme.slint
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// The palette, converted once from the web front-end's oklch tokens
|
||||||
|
// (`web/src/styles/app.css`) into the sRGB hex Slint speaks. The oklch source value is
|
||||||
|
// kept beside each one, because that is the form the design is actually maintained in -
|
||||||
|
// if a colour needs adjusting, adjust the oklch and re-convert (`src/oklch.rs`), rather
|
||||||
|
// than nudging the hex and letting the two drift.
|
||||||
|
|
||||||
|
export global Theme {
|
||||||
|
// ------------------------------------------------------------------ colours --
|
||||||
|
out property <color> ink: #113339; // oklch(30% 0.04 210)
|
||||||
|
out property <color> paper: #eef7f9; // oklch(97% 0.01 210)
|
||||||
|
out property <color> accent: #de73bd; // oklch(70% 0.16 340)
|
||||||
|
out property <color> accent-dim: #f292d3; // oklch(78% 0.14 340)
|
||||||
|
out property <color> sea-deep: #001b21; // oklch(20% 0.045 210)
|
||||||
|
out property <color> shadow: #001017; // oklch(15% 0.05 210)
|
||||||
|
|
||||||
|
// The stage gradient, which in the web app sits under an animated canvas. There is
|
||||||
|
// no canvas here: a full-viewport repaint loop is the one cost a Pi cannot absorb,
|
||||||
|
// and `?pi=1` already removes it on the device this replaces.
|
||||||
|
out property <color> stage-top: #397d88; // oklch(55% 0.07 210)
|
||||||
|
out property <color> stage-mid: #0e4b54; // oklch(38% 0.06 210)
|
||||||
|
|
||||||
|
out property <color> text-dim: #c9dbdf; // oklch(88% 0.02 210)
|
||||||
|
out property <color> text-sub: #c0d2d5; // oklch(85% 0.02 210)
|
||||||
|
out property <color> count-pink: #7d0563; // oklch(40% 0.17 340)
|
||||||
|
|
||||||
|
out property <color> card-music: #e4f1f4; // oklch(95% 0.015 210)
|
||||||
|
out property <color> card-book: #ffd5ab; // oklch(92% 0.09 55)
|
||||||
|
out property <color> book-edge-1: #ffc298; // oklch(86% 0.09 55)
|
||||||
|
out property <color> book-edge-2: #e5a880; // oklch(78% 0.09 55)
|
||||||
|
|
||||||
|
out property <color> bar-bg: #00171e; // oklch(18% 0.05 210)
|
||||||
|
out property <color> sheet-bg: #e9f4f6; // oklch(96% 0.012 210)
|
||||||
|
out property <color> backdrop: #000e12; // oklch(15% 0.03 210)
|
||||||
|
// Two track colours, not one with an alpha: the seek bar sits on the pale stage in
|
||||||
|
// the play view and on the near-black player bar in the browse view, and a single
|
||||||
|
// translucent fill that reads correctly on one is invisible on the other.
|
||||||
|
out property <color> progress-track: #1b3236; // oklch(30% 0.03 210)
|
||||||
|
out property <color> progress-track-dark: #2e4f57;
|
||||||
|
out property <color> vol-empty: #c9dbdf; // oklch(88% 0.02 210)
|
||||||
|
|
||||||
|
// Glass, without the glass. `backdrop-filter` has no counterpart here, so these are
|
||||||
|
// exactly the web app's own `data-blur="off"` fallback: a flat translucent fill.
|
||||||
|
// That is a real loss of depth and the honest substitute for it - faking a blur by
|
||||||
|
// compositing a pre-blurred copy would reintroduce the per-frame cost the blur was
|
||||||
|
// dropped to avoid.
|
||||||
|
out property <brush> glass: @linear-gradient(160deg, #eef7f924 0%, #eef7f90d 100%);
|
||||||
|
out property <color> glass-border: #eef7f929;
|
||||||
|
out property <color> row-idle: #f7fcfd1a;
|
||||||
|
out property <color> row-active: #f7fcfd38;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- metrics --
|
||||||
|
out property <length> radius-panel: 22px;
|
||||||
|
out property <length> radius-card: 16px;
|
||||||
|
out property <length> radius-row: 12px;
|
||||||
|
out property <length> radius-sheet: 26px;
|
||||||
|
out property <length> pill: 999px;
|
||||||
|
|
||||||
|
// The album grid's track width. The web grid is `minmax(180px, 1fr)`; keeping the
|
||||||
|
// same number keeps the same number of columns at 1920, which is what the shelf
|
||||||
|
// rows and the measured card height below were both tuned against.
|
||||||
|
out property <length> card-width: 180px;
|
||||||
|
out property <length> grid-gap: 24px;
|
||||||
|
out property <length> shelf-tile: 148px;
|
||||||
|
|
||||||
|
// How much of the right edge the page must leave clear for nothing to render
|
||||||
|
// underneath the corner controls.
|
||||||
|
out property <length> rail-clearance: 88px;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- typography --
|
||||||
|
// Heavy throughout: 700/800/900. Durations and counters use the monospace family so
|
||||||
|
// a ticking clock does not reflow the row it sits in.
|
||||||
|
out property <string> font: "Nunito";
|
||||||
|
out property <string> mono: "monospace";
|
||||||
|
|
||||||
|
out property <length> text-hero: 44px;
|
||||||
|
out property <length> text-title: 26px;
|
||||||
|
out property <length> text-heading: 20px;
|
||||||
|
out property <length> text-body: 16px;
|
||||||
|
out property <length> text-small: 13px;
|
||||||
|
out property <length> text-tiny: 12px;
|
||||||
|
}
|
||||||
299
slint-frontend/ui/widgets.slint
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
|
||||||
|
// A frosted surface wrapped around a shelf or a list, so it reads as something
|
||||||
|
// floating over the stage rather than text on a gradient.
|
||||||
|
export component GlassPanel inherits Rectangle {
|
||||||
|
background: Theme.glass;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.glass-border;
|
||||||
|
border-radius: Theme.radius-panel;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 24px;
|
||||||
|
drop-shadow-offset-y: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album art. `source` is whatever the cover store has decoded; until it has one, the
|
||||||
|
// Rust side hands over generated art built from the album's own three colours, so this
|
||||||
|
// never has an empty frame to show.
|
||||||
|
//
|
||||||
|
// The shape comes from the media type and from nowhere else: square for an album,
|
||||||
|
// taller than wide for an audiobook, everywhere either appears.
|
||||||
|
export component Cover inherits Rectangle {
|
||||||
|
in property <image> source;
|
||||||
|
in property <bool> is-book: false;
|
||||||
|
in property <length> radius: Theme.radius-card;
|
||||||
|
|
||||||
|
clip: true;
|
||||||
|
border-radius: root.radius;
|
||||||
|
// A book keeps a sliver of its generated spine showing past real square artwork, so
|
||||||
|
// the shelf metaphor survives contact with a photograph.
|
||||||
|
background: root.is-book ? Theme.book-edge-2 : transparent;
|
||||||
|
|
||||||
|
Image {
|
||||||
|
source: root.source;
|
||||||
|
image-fit: cover;
|
||||||
|
width: root.is-book ? parent.width * 1.05 : parent.width;
|
||||||
|
height: parent.height;
|
||||||
|
x: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev / next / play. Every glyph is drawn from rectangles and a clip, not an icon
|
||||||
|
// font: there is no icon font on the device, and a missing glyph is a blank button.
|
||||||
|
export component TransportButton inherits Rectangle {
|
||||||
|
in property <length> size: 48px;
|
||||||
|
in property <bool> primary: false;
|
||||||
|
in property <bool> playing: false;
|
||||||
|
in property <string> direction: "play"; // "play" | "next" | "previous"
|
||||||
|
callback clicked();
|
||||||
|
|
||||||
|
width: root.size;
|
||||||
|
height: root.size;
|
||||||
|
border-radius: root.size / 2;
|
||||||
|
background: root.primary
|
||||||
|
? (touch.pressed ? Theme.accent-dim : Theme.accent)
|
||||||
|
: (touch.pressed ? #2a4f57 : Theme.ink);
|
||||||
|
drop-shadow-color: root.primary ? #de73bd80 : #00101766;
|
||||||
|
drop-shadow-blur: root.primary ? 20px : 10px;
|
||||||
|
drop-shadow-offset-y: 4px;
|
||||||
|
animate background { duration: 100ms; }
|
||||||
|
|
||||||
|
// Pause: two bars. Play and the skips: a triangle, clipped out of a square.
|
||||||
|
if root.direction == "play" && root.playing: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: root.size * 0.1;
|
||||||
|
Rectangle {
|
||||||
|
width: root.size * 0.1;
|
||||||
|
height: root.size * 0.34;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: root.size * 0.1;
|
||||||
|
height: root.size * 0.34;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.direction != "play" || !root.playing: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: root.size * 0.06;
|
||||||
|
// "Previous" is the same triangle mirrored, plus the bar it butts against.
|
||||||
|
if root.direction == "previous": Rectangle {
|
||||||
|
width: root.size * 0.07;
|
||||||
|
height: root.size * 0.3;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: root.size * 0.3;
|
||||||
|
height: root.size * 0.3;
|
||||||
|
Path {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: white;
|
||||||
|
viewbox-width: 100;
|
||||||
|
viewbox-height: 100;
|
||||||
|
commands: root.direction == "previous"
|
||||||
|
? "M 100 0 L 100 100 L 0 50 Z"
|
||||||
|
: "M 6 0 L 100 50 L 6 100 Z";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.direction == "next": Rectangle {
|
||||||
|
width: root.size * 0.07;
|
||||||
|
height: root.size * 0.3;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {
|
||||||
|
clicked => { root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component Transport inherits HorizontalLayout {
|
||||||
|
in property <bool> playing;
|
||||||
|
in property <length> size: 48px;
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
|
||||||
|
alignment: center;
|
||||||
|
spacing: root.size * 0.21;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
TransportButton {
|
||||||
|
direction: "previous";
|
||||||
|
size: root.size;
|
||||||
|
clicked => { root.previous(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
TransportButton {
|
||||||
|
direction: "play";
|
||||||
|
primary: true;
|
||||||
|
playing: root.playing;
|
||||||
|
// The play button is the one target a child aims at from across the room.
|
||||||
|
size: root.size * 1.26;
|
||||||
|
clicked => { root.toggle(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
TransportButton {
|
||||||
|
direction: "next";
|
||||||
|
size: root.size;
|
||||||
|
clicked => { root.next(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seek bar.
|
||||||
|
//
|
||||||
|
// `position` is only pushed twice a second, so the fill is *animated* between frames
|
||||||
|
// rather than recomputed on a client-side clock. That puts the interpolation on the
|
||||||
|
// render side, where it costs a property evaluation per frame instead of a layout pass,
|
||||||
|
// and it is why there is no equivalent of the web client's `usePlaybackClock` here.
|
||||||
|
//
|
||||||
|
// The animation is suppressed whenever `smooth` goes false - while dragging, and for
|
||||||
|
// the frame a track changes on - because sliding the fill across two unrelated
|
||||||
|
// positions is exactly the "it moves slightly backwards" artefact the web UI has.
|
||||||
|
export component ProgressBar inherits Rectangle {
|
||||||
|
in property <float> progress; // 0..1
|
||||||
|
in property <bool> interactive: true;
|
||||||
|
in property <length> bar-height: 10px;
|
||||||
|
in property <bool> smooth: true;
|
||||||
|
/// Set on the player bar, whose background is darker than the seek bar's own
|
||||||
|
/// track would otherwise show against.
|
||||||
|
in property <bool> on-dark: false;
|
||||||
|
callback seek(float);
|
||||||
|
|
||||||
|
height: max(root.bar-height, 28px);
|
||||||
|
|
||||||
|
track := Rectangle {
|
||||||
|
y: (parent.height - root.bar-height) / 2;
|
||||||
|
height: root.bar-height;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: root.bar-height / 2;
|
||||||
|
background: root.on-dark ? Theme.progress-track-dark : Theme.progress-track.with-alpha(0.6);
|
||||||
|
clip: true;
|
||||||
|
|
||||||
|
fill := Rectangle {
|
||||||
|
x: 0;
|
||||||
|
width: parent.width * clamp(root.progress, 0.0, 1.0);
|
||||||
|
height: 100%;
|
||||||
|
border-radius: root.bar-height / 2;
|
||||||
|
background: Theme.accent;
|
||||||
|
animate width {
|
||||||
|
duration: root.smooth ? 500ms : 0ms;
|
||||||
|
easing: linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grab handle only while it is worth having one: at player-bar size the bar is
|
||||||
|
// the target, and a knob would be smaller than the thing it sits on.
|
||||||
|
if root.interactive && root.bar-height >= 12px: Rectangle {
|
||||||
|
x: max(0px, min(parent.width - self.width, track.width * clamp(root.progress, 0.0, 1.0) - self.width / 2));
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
width: root.bar-height * 1.7;
|
||||||
|
height: root.bar-height * 1.7;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: Theme.paper;
|
||||||
|
drop-shadow-color: #00101773;
|
||||||
|
drop-shadow-blur: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.interactive: TouchArea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
// Both a click and a drag resolve to "the position under the finger", so
|
||||||
|
// scrubbing works without a separate drag state machine.
|
||||||
|
moved => {
|
||||||
|
if (self.pressed) { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
||||||
|
}
|
||||||
|
clicked => { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume as five discrete steps, not a continuous slider: a child aiming at a bar hits
|
||||||
|
// a level, where a slider would hand back whatever pixel they landed on.
|
||||||
|
export component VolumeBars inherits HorizontalLayout {
|
||||||
|
in property <int> volume; // 0..100
|
||||||
|
in property <length> bar-width: 11px;
|
||||||
|
in property <length> bar-height: 30px;
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
|
||||||
|
spacing: root.bar-width * 0.3;
|
||||||
|
alignment: center;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: root.bar-height * 0.8;
|
||||||
|
height: root.bar-height * 0.8;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: speaker.has-hover ? #ffffff26 : transparent;
|
||||||
|
Text {
|
||||||
|
text: root.volume == 0 ? "🔇" : "🔊";
|
||||||
|
font-size: root.bar-width * 1.6;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
speaker := TouchArea {
|
||||||
|
clicked => { root.mute(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for level[i] in [20, 40, 60, 80, 100]: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: root.bar-width;
|
||||||
|
// Stepped heights, so the control reads as a volume ramp at a glance.
|
||||||
|
height: root.bar-height * (0.45 + 0.55 * (i + 1) / 5);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: root.volume >= level ? Theme.accent : Theme.vol-empty.with-alpha(0.4);
|
||||||
|
animate background { duration: 120ms; }
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.set-volume(level); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A round icon button - back, close, and the fixed corner controls.
|
||||||
|
export component RoundButton inherits Rectangle {
|
||||||
|
in property <string> label;
|
||||||
|
in property <length> size: 44px;
|
||||||
|
in property <bool> highlighted: false;
|
||||||
|
callback clicked();
|
||||||
|
|
||||||
|
width: root.size;
|
||||||
|
height: root.size;
|
||||||
|
border-radius: root.size / 2;
|
||||||
|
background: root.highlighted
|
||||||
|
? Theme.accent
|
||||||
|
: (touch.pressed ? Theme.text-dim : Theme.paper.with-alpha(0.95));
|
||||||
|
drop-shadow-color: #00101766;
|
||||||
|
drop-shadow-blur: 18px;
|
||||||
|
drop-shadow-offset-y: 6px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.label;
|
||||||
|
font-size: root.size * 0.45;
|
||||||
|
font-weight: 900;
|
||||||
|
color: root.highlighted ? white : Theme.ink;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {
|
||||||
|
clicked => { root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,14 @@ npx tsc --noEmit # strict, noUncheckedIndexedAccess
|
|||||||
Only the pure modules are tested - the search, the keyboard machine and the time
|
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.
|
formatting. That is where the behaviour lives; the components are mostly layout.
|
||||||
|
|
||||||
|
`src/lib/search.ts` builds its normalized search keys once per library payload
|
||||||
|
(`buildSearchIndex`) rather than per keystroke. It matters more than it sounds: a real
|
||||||
|
collection is 343 albums and 4969 tracks, and `normalize()` does a Unicode NFD
|
||||||
|
decomposition, so track search used to mean five thousand of those before a single
|
||||||
|
comparison - 4 ms per keystroke on a laptop, and this runs on a Pi. The same build also
|
||||||
|
partitions albums by shelf and pre-sorts each shelf's categories, which `App` and
|
||||||
|
`BrowseView` were otherwise deriving separately from the same data.
|
||||||
|
|
||||||
## How it is put together
|
## How it is put together
|
||||||
|
|
||||||
The split that matters: **what is playing** comes from the backend, **what you are
|
The split that matters: **what is playing** comes from the backend, **what you are
|
||||||
@@ -52,7 +60,8 @@ are local.
|
|||||||
| Path | What it is |
|
| 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/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/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant - see the note on `buildSearchIndex` below. |
|
||||||
|
| `src/lib/lowPower.ts` | The `?pi=1` profile: what gets cheaper, and by how much. |
|
||||||
| `src/lib/covers.ts` | Generated cover art from the album's three colours, and the book-spine treatment. |
|
| `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/usePlayerState.ts` | The websocket, with reconnect-and-reseed. |
|
||||||
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
|
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
|
||||||
@@ -84,6 +93,42 @@ pointer. `F1` shows the same list in-app.
|
|||||||
`SHIFT+arrows` is the one addition to the mockup: plain arrows were already taken, and
|
`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.
|
seeking is the one thing a real player can do that the mockup could not.
|
||||||
|
|
||||||
|
## The Raspberry Pi profile
|
||||||
|
|
||||||
|
`?pi=1` loads the same app with nothing on screen animating itself. It exists for
|
||||||
|
musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen.
|
||||||
|
|
||||||
|
| | Full | `?pi=1` |
|
||||||
|
|---|---|---|
|
||||||
|
| Ambient canvas (gradient + bubbles) | on | **not rendered** |
|
||||||
|
| Decorative CSS loops, view transitions | on | off |
|
||||||
|
| Typing game bubbles, next-key pulse | on | off |
|
||||||
|
| Typing game pets | swimming | placed, held still |
|
||||||
|
| `backdrop-filter` on cards and list rows | on | off |
|
||||||
|
| `backdrop-filter` on panels | on | **on** |
|
||||||
|
| Progress-bar re-renders | every animation frame | 10 per second |
|
||||||
|
|
||||||
|
Two of those rows are worth knowing the reasons for, because both are the opposite of
|
||||||
|
what they look like.
|
||||||
|
|
||||||
|
**The panel blur stays on.** Dropping it measured *five times worse* on the device -
|
||||||
|
118% of a core against 25%. `backdrop-filter` is what promotes each glass panel to its
|
||||||
|
own compositing layer; without it the panels and everything under them collapse into
|
||||||
|
one layer that repaints wholesale. The card blur goes because that is a different
|
||||||
|
problem: three hundred of them in one grid, not one per panel.
|
||||||
|
|
||||||
|
**The canvas goes away rather than getting cheaper.** Half resolution and 30 fps took
|
||||||
|
it from 53.5% of a core to 25.0%, and 25% was still not good enough to use. A
|
||||||
|
`requestAnimationFrame` loop repainting the viewport is a floor you cannot get under
|
||||||
|
while it runs at all. `.stage` keeps its own static CSS gradient, so there is still a
|
||||||
|
sea behind everything - it just no longer moves or follows the track.
|
||||||
|
`AMBIENCE_QUALITY` in `src/lib/lowPower.ts` is kept accurate for whoever turns the
|
||||||
|
canvas back on.
|
||||||
|
|
||||||
|
The flag is read once at module load from the URL and nowhere else - no persistence, no
|
||||||
|
auto-detection. That is what makes "is this the profile or the hardware?" answerable by
|
||||||
|
editing the address bar. The kiosk gets it from `pi_kiosk_url` in the ansible repo.
|
||||||
|
|
||||||
## Parent mode
|
## Parent mode
|
||||||
|
|
||||||
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
|
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
|
||||||
|
|||||||
BIN
web/public/hidden_audiobook_or_podcast.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/public/hiddenalbum.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@@ -4,8 +4,9 @@
|
|||||||
// cached answer for any of those would be a lie.
|
// cached answer for any of those would be a lie.
|
||||||
|
|
||||||
const SHELL = "musikdelphin-shell-v1";
|
const SHELL = "musikdelphin-shell-v1";
|
||||||
const COVERS = "musikdelphin-covers-v1";
|
|
||||||
|
|
||||||
|
// There is no cover cache any more; `activate` below deletes every cache that is not
|
||||||
|
// SHELL, which retires the two generations of cover cache this worker used to keep.
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches
|
||||||
@@ -29,7 +30,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
caches
|
caches
|
||||||
.keys()
|
.keys()
|
||||||
.then((names) =>
|
.then((names) =>
|
||||||
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
|
Promise.all(names.filter((n) => n !== SHELL).map((n) => caches.delete(n))),
|
||||||
)
|
)
|
||||||
.then(() => self.clients.claim()),
|
.then(() => self.clients.claim()),
|
||||||
);
|
);
|
||||||
@@ -42,19 +43,22 @@ self.addEventListener("fetch", (event) => {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin) return;
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
// Cover art is immutable per album id and is the only heavy thing here.
|
// Cover art is NOT cached here, and that is deliberate. This worker only ever
|
||||||
if (url.pathname.startsWith("/api/albums/")) {
|
// registers on localhost or over HTTPS (see the README), which on this setup means
|
||||||
event.respondWith(
|
// the kiosk on the device itself - where the backend is the same machine, and a
|
||||||
caches.open(COVERS).then(async (cache) => {
|
// Cache Storage lookup plus a revalidating fetch plus a cache write is strictly more
|
||||||
const hit = await cache.match(request);
|
// work than just asking for the file.
|
||||||
if (hit) return hit;
|
//
|
||||||
const response = await fetch(request);
|
// It was measured, after a stale-while-revalidate version of this handler made
|
||||||
if (response.ok) cache.put(request, response.clone());
|
// searching visibly worse. Typing "conni" over a 343-album library, keydown to
|
||||||
return response;
|
// painted, worst keystroke: 5580 ms through this worker against 461 ms with
|
||||||
}),
|
// Network.setBypassServiceWorker on. The main thread was not running any JavaScript
|
||||||
);
|
// during those stalls - it sat waiting while ImageDecodeTask blocked on bytes this
|
||||||
return;
|
// worker was serialising through a single thread.
|
||||||
}
|
//
|
||||||
|
// If the player ever moves behind a TLS proxy for the tablet, revisit: caching covers
|
||||||
|
// is worth something over wifi, and would want a budget rather than every cover on
|
||||||
|
// every load.
|
||||||
|
|
||||||
// Everything else under /api is live state. Never cache it, never serve it stale.
|
// Everything else under /api is live state. Never cache it, never serve it stale.
|
||||||
if (url.pathname.startsWith("/api/")) return;
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf
|
|||||||
import { playPop } from "./lib/pop";
|
import { playPop } from "./lib/pop";
|
||||||
import { targetForAlbum } from "./lib/remote";
|
import { targetForAlbum } from "./lib/remote";
|
||||||
import type { Group, Results, SongHit } from "./lib/search";
|
import type { Group, Results, SongHit } from "./lib/search";
|
||||||
import { categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||||
import { SHOW_AMBIENCE, SHOW_GLASS_BLUR } from "./lib/theme";
|
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
|
||||||
|
|
||||||
/** How long an armed "A" waits for the digit that completes the shortcut. */
|
/** How long an armed "A" waits for the digit that completes the shortcut. */
|
||||||
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
|
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
|
||||||
@@ -92,33 +92,38 @@ export function App() {
|
|||||||
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
|
// Normalized search keys, group partitions and each shelf's category list, derived
|
||||||
|
// once per library payload instead of per keystroke - see lib/search.ts. The library
|
||||||
|
// only reloads when the backend says a rescan finished, so this is genuinely rare.
|
||||||
|
const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]);
|
||||||
|
|
||||||
const results: Results = useMemo(
|
const results: Results = useMemo(
|
||||||
() =>
|
() =>
|
||||||
computeResults({
|
computeResults({
|
||||||
albums: library.albums,
|
index,
|
||||||
search: ui.search,
|
search: ui.search,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
group: ui.group,
|
group: ui.group,
|
||||||
category: ui.category,
|
category: ui.category,
|
||||||
}),
|
}),
|
||||||
[library.albums, ui.search, ui.mode, ui.group, ui.category],
|
[index, ui.search, ui.mode, ui.group, ui.category],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The bare root's three shelves each compute their own categories independently
|
// The bare root's three shelves, by category key only: the keyboard handler needs
|
||||||
// (see BrowseView) - the keyboard handler needs the same lists, by key only, to know
|
// these to know how far Ctrl+hjkl can move within a row and which category ENTER
|
||||||
// how far Ctrl+hjkl can move within a row and which category ENTER opens.
|
// opens. BrowseView renders the same shelves from the same precomputed lists.
|
||||||
const rootShelves = useMemo(
|
const rootShelves = useMemo(
|
||||||
() =>
|
() =>
|
||||||
GROUP_ORDER.map((group) =>
|
GROUP_ORDER.map((group) =>
|
||||||
categoryMatches({
|
categoryMatches({
|
||||||
albums: library.albums,
|
index,
|
||||||
search: "",
|
search: "",
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
category: null,
|
category: null,
|
||||||
group,
|
group,
|
||||||
}).map((entry) => entry.key),
|
}).map((entry) => entry.key),
|
||||||
),
|
),
|
||||||
[library.albums],
|
[index],
|
||||||
);
|
);
|
||||||
|
|
||||||
const byId = useMemo(
|
const byId = useMemo(
|
||||||
@@ -407,7 +412,11 @@ export function App() {
|
|||||||
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
|
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}>
|
<div
|
||||||
|
className="stage"
|
||||||
|
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
|
||||||
|
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
|
||||||
|
>
|
||||||
{state && SHOW_AMBIENCE && (
|
{state && SHOW_AMBIENCE && (
|
||||||
<Ambience
|
<Ambience
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
@@ -432,7 +441,7 @@ export function App() {
|
|||||||
<BrowseView
|
<BrowseView
|
||||||
results={results}
|
results={results}
|
||||||
group={ui.group}
|
group={ui.group}
|
||||||
albums={library.albums}
|
index={index}
|
||||||
mode={ui.mode}
|
mode={ui.mode}
|
||||||
search={ui.search}
|
search={ui.search}
|
||||||
category={ui.category}
|
category={ui.category}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export interface LircConfig {
|
|||||||
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
|
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
|
||||||
|
|
||||||
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
|
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||||
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
export type TippenModeId = "dive" | "bubbles" | "feed" | "race";
|
||||||
|
|
||||||
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
|
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
|
||||||
* configured path matches nothing in the current library. */
|
* configured path matches nothing in the current library. */
|
||||||
@@ -236,7 +236,6 @@ export interface TippenSettings {
|
|||||||
export interface TippenProgress {
|
export interface TippenProgress {
|
||||||
lessons: Record<string, TippenLessonProgress>;
|
lessons: Record<string, TippenLessonProgress>;
|
||||||
key_stats: Record<string, TippenKeyStat>;
|
key_stats: Record<string, TippenKeyStat>;
|
||||||
pearls: number;
|
|
||||||
aquarium: string[];
|
aquarium: string[];
|
||||||
streak: TippenStreak;
|
streak: TippenStreak;
|
||||||
settings: TippenSettings;
|
settings: TippenSettings;
|
||||||
@@ -257,7 +256,6 @@ export interface TippenRunInput {
|
|||||||
animal: string;
|
animal: string;
|
||||||
points: number;
|
points: number;
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
pearls: number;
|
|
||||||
strokes: TippenStroke[];
|
strokes: TippenStroke[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type Oklch,
|
type Oklch,
|
||||||
} from "../lib/ambience";
|
} from "../lib/ambience";
|
||||||
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
||||||
|
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
||||||
import { groupOf, type Group } from "../lib/search";
|
import { groupOf, type Group } from "../lib/search";
|
||||||
|
|
||||||
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
||||||
@@ -247,16 +248,22 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
// can't feed back into the size it just measured (see the comment there). The
|
// can't feed back into the size it just measured (see the comment there). The
|
||||||
// clamp is a second line of defence against the same failure mode from anywhere
|
// clamp is a second line of defence against the same failure mode from anywhere
|
||||||
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
|
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
|
||||||
const MAX_BACKING_STORE_PX = 4096;
|
//
|
||||||
|
// `resolutionScale` below 1 (the `?pi=1` profile) makes the backing store smaller
|
||||||
|
// than the canvas is on screen and lets the browser scale it back up. Everything
|
||||||
|
// in `tick` still works in CSS-pixel units, because the transform is derived from
|
||||||
|
// whatever the backing store ended up being rather than from `dpr`.
|
||||||
|
const quality = AMBIENCE_QUALITY;
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = (window.devicePixelRatio || 1) * quality.resolutionScale;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
size.width = rect.width;
|
size.width = rect.width;
|
||||||
size.height = rect.height;
|
size.height = rect.height;
|
||||||
canvas.width = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.width * dpr)));
|
const cap = quality.maxBackingStorePx;
|
||||||
canvas.height = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.height * dpr)));
|
canvas.width = Math.min(cap, Math.max(1, Math.round(rect.width * dpr)));
|
||||||
// Scale factors from the (possibly clamped) backing store, not raw `dpr`, so
|
canvas.height = Math.min(cap, Math.max(1, Math.round(rect.height * dpr)));
|
||||||
// drawing in CSS-pixel units still lands correctly even when clamped.
|
// Scale factors from the (possibly clamped, possibly downscaled) backing store,
|
||||||
|
// not raw `dpr`, so drawing in CSS-pixel units still lands correctly.
|
||||||
const scaleX = canvas.width / Math.max(1, rect.width);
|
const scaleX = canvas.width / Math.max(1, rect.width);
|
||||||
const scaleY = canvas.height / Math.max(1, rect.height);
|
const scaleY = canvas.height / Math.max(1, rect.height);
|
||||||
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
||||||
@@ -312,7 +319,16 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
smoothedDrive,
|
smoothedDrive,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A frame budget, not a timer: rAF still drives everything, this just returns
|
||||||
|
// early until enough time has passed. The 1ms slack stops a 30fps budget from
|
||||||
|
// landing just inside a 60Hz vsync interval and silently halving again to 20.
|
||||||
|
const minFrameMs = quality.maxFps > 0 ? 1000 / quality.maxFps - 1 : 0;
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
|
if (now - lastFrame < minFrameMs) {
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
|
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
|
||||||
lastFrame = now;
|
lastFrame = now;
|
||||||
|
|
||||||
@@ -386,6 +402,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
|
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
|
||||||
while (spawnAccumulator >= 1) {
|
while (spawnAccumulator >= 1) {
|
||||||
spawnAccumulator -= 1;
|
spawnAccumulator -= 1;
|
||||||
|
// The cap drops the *newest* bubble rather than evicting an old one, so a
|
||||||
|
// loud passage thins the field out instead of making it flicker.
|
||||||
|
if (quality.maxBubbles > 0 && bubbles.length >= quality.maxBubbles) break;
|
||||||
bubbles.push(spawnBubble(size.width));
|
bubbles.push(spawnBubble(size.width));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +421,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.clearRect(0, 0, size.width, size.height);
|
// No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to
|
||||||
|
// alpha 1) and covers the whole canvas, so clearing first is a second
|
||||||
|
// full-screen write per frame that nothing can ever see.
|
||||||
const [top, mid, deep] = current.gradient;
|
const [top, mid, deep] = current.gradient;
|
||||||
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
|
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
|
||||||
gradient.addColorStop(0, oklchString(top));
|
gradient.addColorStop(0, oklchString(top));
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
unitLabel,
|
unitLabel,
|
||||||
} from "../lib/covers";
|
} from "../lib/covers";
|
||||||
import { clock } from "../lib/format";
|
import { clock } from "../lib/format";
|
||||||
import type { Category, Group, Results, SongHit } from "../lib/search";
|
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
||||||
import { categoryMatches, groupOf } from "../lib/search";
|
import { categoryMatches, groupOf } from "../lib/search";
|
||||||
import {
|
import {
|
||||||
ANIMATE_VIEW_TRANSITIONS,
|
ANIMATE_VIEW_TRANSITIONS,
|
||||||
@@ -37,7 +37,7 @@ import { Cover } from "./Cover";
|
|||||||
interface Props {
|
interface Props {
|
||||||
results: Results;
|
results: Results;
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
albums: Album[];
|
index: SearchIndex;
|
||||||
mode: "albums" | "tracks";
|
mode: "albums" | "tracks";
|
||||||
search: string;
|
search: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
@@ -76,7 +76,7 @@ const GROUP_SECTION_LABEL: Record<Group, string> = {
|
|||||||
export function BrowseView({
|
export function BrowseView({
|
||||||
results,
|
results,
|
||||||
group,
|
group,
|
||||||
albums,
|
index,
|
||||||
mode,
|
mode,
|
||||||
search,
|
search,
|
||||||
category,
|
category,
|
||||||
@@ -114,14 +114,14 @@ export function BrowseView({
|
|||||||
GROUPS.map((shelfGroup) => ({
|
GROUPS.map((shelfGroup) => ({
|
||||||
group: shelfGroup,
|
group: shelfGroup,
|
||||||
categories: categoryMatches({
|
categories: categoryMatches({
|
||||||
albums,
|
index,
|
||||||
search: "",
|
search: "",
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
category: null,
|
category: null,
|
||||||
group: shelfGroup,
|
group: shelfGroup,
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
[albums],
|
[index],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the selection on screen as the arrow keys walk past the fold.
|
// Keep the selection on screen as the arrow keys walk past the fold.
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ interface Props {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||||
label?: boolean;
|
label?: boolean;
|
||||||
/** A reward-gated album/track not yet earned - shows a question mark instead of the
|
/** A reward-gated album/track not yet earned - shows a placeholder cover instead of
|
||||||
* real art or title, regardless of `has_cover`. */
|
* the real art or title, regardless of `has_cover`. */
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,20 +53,18 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{locked ? (
|
{locked ? (
|
||||||
<div
|
<img
|
||||||
|
src={book ? "/hidden_audiobook_or_podcast.png" : "/hiddenalbum.png"}
|
||||||
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
display: "flex",
|
width: "100%",
|
||||||
alignItems: "center",
|
height: "100%",
|
||||||
justifyContent: "center",
|
objectFit: "cover",
|
||||||
fontSize: Math.max(22, Math.round(size / 2.2)),
|
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||||
color: "oklch(99% 0 0 / .85)",
|
|
||||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
❓
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!album.has_cover && label && (
|
{!album.has_cover && label && (
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/** The typing game, as a tab of the music player rather than its own app.
|
/** The typing game, as a tab of the music player rather than its own app.
|
||||||
*
|
*
|
||||||
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
|
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
|
||||||
* keydown listener for navigation, four screens) - with two changes: curriculum and
|
* keydown listener for navigation, screens) - with two changes: curriculum and
|
||||||
* progress are now fetched from the backend instead of a build-time YAML import and
|
* progress are now fetched from the backend instead of a build-time YAML import and
|
||||||
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
|
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
|
||||||
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one
|
* `onExit` is the new base case for "back". The lesson map is the floor screen - the
|
||||||
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
|
* swimming aquarium creatures already show behind it, so there is no separate landing
|
||||||
* more Escape leaves the tab entirely, back to the music player.
|
* screen in front of it - and one Escape from the map leaves the tab entirely, back to
|
||||||
|
* the music player.
|
||||||
*
|
*
|
||||||
* The rule that still matters most: **while a run is going, every key belongs to the
|
* The rule that still matters most: **while a run is going, every key belongs to the
|
||||||
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
|
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
|
||||||
@@ -18,36 +19,37 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
|
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
|
||||||
import { useTippenProgress } from "../hooks/useTippenProgress";
|
import { useTippenProgress } from "../hooks/useTippenProgress";
|
||||||
import { Aquarium } from "./tippen/Aquarium";
|
|
||||||
import { AppHeader } from "./tippen/AppHeader";
|
import { AppHeader } from "./tippen/AppHeader";
|
||||||
import { HelpOverlay } from "./tippen/HelpOverlay";
|
import { HelpOverlay } from "./tippen/HelpOverlay";
|
||||||
import { LessonMap } from "./tippen/LessonMap";
|
import { LessonMap } from "./tippen/LessonMap";
|
||||||
import { ResultSheet } from "./tippen/ResultSheet";
|
import { ResultSheet } from "./tippen/ResultSheet";
|
||||||
|
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
|
||||||
import { Stage } from "./tippen/Stage";
|
import { Stage } from "./tippen/Stage";
|
||||||
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
||||||
import { DiveRun } from "./tippen/modes/DiveRun";
|
import { DiveRun } from "./tippen/modes/DiveRun";
|
||||||
import { FeedRun } from "./tippen/modes/FeedRun";
|
import { FeedRun } from "./tippen/modes/FeedRun";
|
||||||
import { JellyfishRun } from "./tippen/modes/JellyfishRun";
|
|
||||||
import { RaceRun } from "./tippen/modes/RaceRun";
|
import { RaceRun } from "./tippen/modes/RaceRun";
|
||||||
import type { CreatureId } from "../lib/tippen/aquarium";
|
import type { CreatureId } from "../lib/tippen/aquarium";
|
||||||
|
import { creatureById } from "../lib/tippen/aquarium";
|
||||||
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
||||||
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
||||||
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
||||||
|
import { animalById } from "../lib/tippen/grading";
|
||||||
import type { RunResult } from "../lib/tippen/grading";
|
import type { RunResult } from "../lib/tippen/grading";
|
||||||
import { playFanfare, playPop } from "../lib/tippen/pop";
|
import { playFanfare, playPop } from "../lib/tippen/pop";
|
||||||
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
|
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
|
||||||
import type { UnlockedReward } from "../lib/tippen/progress";
|
import type { UnlockedReward } from "../lib/tippen/progress";
|
||||||
import { bubbleCountFor } from "../lib/tippen/theme";
|
import { bubbleCountFor } from "../lib/tippen/theme";
|
||||||
|
|
||||||
type Screen = "aquarium" | "map" | "run";
|
type Screen = "map" | "run";
|
||||||
|
|
||||||
/** What a mode is handed to draw - see the original App.tsx for why there are only two
|
/** What a mode is handed to draw - see the original App.tsx for why there are only two
|
||||||
* shapes for five modes. */
|
* shapes for four modes. */
|
||||||
type RunTarget =
|
type RunTarget =
|
||||||
| { kind: "letters"; letters: readonly string[] }
|
| { kind: "letters"; letters: readonly string[] }
|
||||||
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
||||||
|
|
||||||
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
|
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles"];
|
||||||
|
|
||||||
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
||||||
|
|
||||||
@@ -68,10 +70,14 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
const configured = curriculum !== null;
|
const configured = curriculum !== null;
|
||||||
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
|
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
|
||||||
|
|
||||||
const [screen, setScreen] = useState<Screen>("aquarium");
|
const [screen, setScreen] = useState<Screen>("map");
|
||||||
const [lessonId, setLessonId] = useState<string | null>(null);
|
const [lessonId, setLessonId] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<ModeId>("dive");
|
const [mode, setMode] = useState<ModeId>("dive");
|
||||||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||||
|
/** The unlock celebration, in front of the result sheet - see RewardUnlockOverlay.
|
||||||
|
* Separate from `outcome.unlockedReward` because it is dismissed on its own, leaving
|
||||||
|
* the result sheet (and its recap of the same reward) behind it. */
|
||||||
|
const [celebration, setCelebration] = useState<UnlockedReward | null>(null);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
||||||
@@ -118,6 +124,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
}, [lesson, mode, round]);
|
}, [lesson, mode, round]);
|
||||||
|
|
||||||
const start = useCallback((lesson: Lesson) => {
|
const start = useCallback((lesson: Lesson) => {
|
||||||
|
setCelebration(null);
|
||||||
setLessonId(lesson.id);
|
setLessonId(lesson.id);
|
||||||
setMode(lesson.primaryMode);
|
setMode(lesson.primaryMode);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
@@ -136,10 +143,11 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
isNewBest: recorded.isNewBest,
|
isNewBest: recorded.isNewBest,
|
||||||
unlockedReward: recorded.unlockedReward,
|
unlockedReward: recorded.unlockedReward,
|
||||||
});
|
});
|
||||||
if (
|
// The unlock celebration brings its own sounds (and is the bigger moment), so
|
||||||
progress.settings.sound &&
|
// the generic fanfare would only step on its opening. One or the other.
|
||||||
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
|
if (recorded.unlockedReward) {
|
||||||
) {
|
setCelebration(recorded.unlockedReward);
|
||||||
|
} else if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
|
||||||
playFanfare();
|
playFanfare();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,6 +156,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
|
setCelebration(null);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
setRound((r) => r + 1);
|
setRound((r) => r + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -156,6 +165,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
|
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
|
||||||
* the best score, not change what is unlocked. */
|
* the best score, not change what is unlocked. */
|
||||||
const playBonus = useCallback((bonusMode: ModeId) => {
|
const playBonus = useCallback((bonusMode: ModeId) => {
|
||||||
|
setCelebration(null);
|
||||||
setMode(bonusMode);
|
setMode(bonusMode);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
setRound((r) => r + 1);
|
setRound((r) => r + 1);
|
||||||
@@ -163,22 +173,23 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
|
|
||||||
const continueAfterResult = useCallback(() => {
|
const continueAfterResult = useCallback(() => {
|
||||||
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
|
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
|
||||||
|
setCelebration(null);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
if (next && progress?.lessons[next.id]?.unlocked) start(next);
|
if (next && progress?.lessons[next.id]?.unlocked) start(next);
|
||||||
else setScreen("map");
|
else setScreen("map");
|
||||||
}, [lessonId, curriculum, progress, start]);
|
}, [lessonId, curriculum, progress, start]);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
|
if (celebration) return setCelebration(null);
|
||||||
if (outcome) return setOutcome(null);
|
if (outcome) return setOutcome(null);
|
||||||
if (screen === "run") return setScreen("map");
|
if (screen === "run") return setScreen("map");
|
||||||
if (screen === "map") return setScreen("aquarium");
|
if (screen === "map") return onExit();
|
||||||
if (screen === "aquarium") return onExit();
|
}, [celebration, outcome, screen, onExit]);
|
||||||
}, [outcome, screen, onExit]);
|
|
||||||
|
|
||||||
// --- navigation keys -----------------------------------------------------
|
// --- navigation keys -----------------------------------------------------
|
||||||
|
|
||||||
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
|
const latest = useRef({ screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum });
|
||||||
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
|
latest.current = { screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -196,6 +207,17 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enter dismisses the unlock celebration. It must be handled before the result
|
||||||
|
// sheet's own Enter below, or finishing a reward run would restart it instantly
|
||||||
|
// from behind the overlay.
|
||||||
|
if (current.celebration) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
setCelebration(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Enter repeats a finished run; the result sheet's own button has focus, so this
|
// Enter repeats a finished run; the result sheet's own button has focus, so this
|
||||||
// is only a fallback for when focus has been lost.
|
// is only a fallback for when focus has been lost.
|
||||||
if (current.outcome) {
|
if (current.outcome) {
|
||||||
@@ -212,12 +234,8 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (current.screen === "aquarium") {
|
const lesson = current.curriculum.lessons[current.selected];
|
||||||
if (current.nextUp) current.start(current.nextUp);
|
if (lesson) current.start(lesson);
|
||||||
} else {
|
|
||||||
const lesson = current.curriculum.lessons[current.selected];
|
|
||||||
if (lesson) current.start(lesson);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +257,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (screen === "run" && !outcome) return;
|
if (screen === "run" && !outcome) return;
|
||||||
|
if (celebration) return;
|
||||||
if (!progress) return;
|
if (!progress) return;
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
|
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
|
||||||
@@ -251,7 +270,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, [screen, outcome, progress, saveSettings]);
|
}, [screen, outcome, celebration, progress, saveSettings]);
|
||||||
|
|
||||||
// --- render --------------------------------------------------------------
|
// --- render --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -274,6 +293,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
|
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
|
||||||
|
const bestAnimal = overallBestAnimal(progress);
|
||||||
|
|
||||||
// Every mode takes the same bundle; only the drawing differs.
|
// Every mode takes the same bundle; only the drawing differs.
|
||||||
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
|
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
|
||||||
@@ -292,34 +312,58 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
compact={screen === "run"}
|
compact={screen === "run"}
|
||||||
status={
|
status={
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
||||||
<span>🦪 {progress.pearls}</span>
|
{screen === "map" && (
|
||||||
|
<>
|
||||||
|
<div className="tp-map-creatures">
|
||||||
|
{curriculum.worlds.map((world) => {
|
||||||
|
const creature = creatureById(world.reward);
|
||||||
|
const owned = progress.aquarium.includes(creature.id);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={creature.id}
|
||||||
|
src={creature.image}
|
||||||
|
alt=""
|
||||||
|
title={owned ? creature.name : `Welt ${world.number}`}
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
objectFit: "contain",
|
||||||
|
filter: owned ? "none" : "brightness(0) invert(1)",
|
||||||
|
opacity: owned ? 1 : 0.3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span title="Tage am Stück">🔥 {progress.streak.days}</span>
|
||||||
|
{bestAnimal && (
|
||||||
|
<span title="Schnellstes Tier">
|
||||||
|
{animalById(bestAnimal).emoji} {animalById(bestAnimal).name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{screen === "aquarium" && (
|
{screen === "map" && (
|
||||||
<Aquarium
|
<LessonMap
|
||||||
worlds={curriculum.worlds}
|
worlds={curriculum.worlds}
|
||||||
|
lessons={curriculum.lessons}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
selected={selected}
|
||||||
|
onPick={start}
|
||||||
nextLesson={nextUp}
|
nextLesson={nextUp}
|
||||||
onContinue={() => nextUp && start(nextUp)}
|
onContinue={() => nextUp && start(nextUp)}
|
||||||
onOpenMap={() => setScreen("map")}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{screen === "map" && (
|
|
||||||
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{screen === "run" && lesson && run && (
|
{screen === "run" && lesson && run && (
|
||||||
<>
|
<>
|
||||||
{run.kind === "letters" ? (
|
{run.kind === "letters" ? (
|
||||||
mode === "jellyfish" ? (
|
<BubblesRun {...letterProps(run.letters)} />
|
||||||
<JellyfishRun {...letterProps(run.letters)} />
|
|
||||||
) : (
|
|
||||||
<BubblesRun {...letterProps(run.letters)} />
|
|
||||||
)
|
|
||||||
) : mode === "feed" ? (
|
) : mode === "feed" ? (
|
||||||
<FeedRun {...textProps(run)} />
|
<FeedRun {...textProps(run)} />
|
||||||
) : mode === "race" ? (
|
) : mode === "race" ? (
|
||||||
@@ -337,7 +381,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
newCreature={outcome.newCreature}
|
newCreature={outcome.newCreature}
|
||||||
unlockedReward={outcome.unlockedReward}
|
unlockedReward={outcome.unlockedReward}
|
||||||
isNewBest={outcome.isNewBest}
|
isNewBest={outcome.isNewBest}
|
||||||
bestEver={overallBestAnimal(progress)}
|
bestEver={bestAnimal}
|
||||||
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
||||||
onPlayBonus={playBonus}
|
onPlayBonus={playBonus}
|
||||||
onRetry={retry}
|
onRetry={retry}
|
||||||
@@ -346,6 +390,14 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{celebration && (
|
||||||
|
<RewardUnlockOverlay
|
||||||
|
reward={celebration}
|
||||||
|
sound={progress.settings.sound}
|
||||||
|
onClose={() => setCelebration(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
||||||
</Stage>
|
</Stage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Shown on the right - pearls, streak, a back hint. */
|
/** Shown on the right - streak, best animal, a back hint. */
|
||||||
status?: ReactNode;
|
status?: ReactNode;
|
||||||
/** Smaller header while a lesson is running, so the target line gets the room. */
|
/** Smaller header while a lesson is running, so the target line gets the room. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
/** The home screen: what she has collected, before she is asked to do anything.
|
|
||||||
*
|
|
||||||
* Deliberately the first thing on opening the app. The reward for finishing a world is a
|
|
||||||
* pet that moves in for good - it swims behind every screen from then on (see
|
|
||||||
* AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
|
|
||||||
* one you are told about afterwards.
|
|
||||||
*
|
|
||||||
* The panel shows every pet there is to earn: the ones at home in colour, the rest as
|
|
||||||
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
|
|
||||||
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
|
|
||||||
|
|
||||||
import { creatureById } from "../../lib/tippen/aquarium";
|
|
||||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
|
||||||
import { animalById } from "../../lib/tippen/grading";
|
|
||||||
import { overallBestAnimal } from "../../lib/tippen/progress";
|
|
||||||
import type { Progress } from "../../lib/tippen/progress";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
worlds: readonly World[];
|
|
||||||
progress: Progress;
|
|
||||||
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
|
|
||||||
nextLesson: Lesson | null;
|
|
||||||
onContinue: () => void;
|
|
||||||
onOpenMap: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
|
|
||||||
const bestAnimal = overallBestAnimal(progress);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="view-enter"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 22,
|
|
||||||
padding: "0 32px 32px",
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="tp-glass-panel"
|
|
||||||
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
|
|
||||||
Dein Aquarium
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-label="Deine Tiere"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 18,
|
|
||||||
margin: "16px 0 6px",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{worlds.map((world) => {
|
|
||||||
const creature = creatureById(world.reward);
|
|
||||||
const owned = progress.aquarium.includes(creature.id);
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
key={creature.id}
|
|
||||||
src={creature.image}
|
|
||||||
alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
|
|
||||||
title={owned ? creature.name : `Welt ${world.number}`}
|
|
||||||
style={{
|
|
||||||
width: 62,
|
|
||||||
height: 62,
|
|
||||||
objectFit: "contain",
|
|
||||||
// Not yet earned: a pale outline of the shape, no colours given away.
|
|
||||||
filter: owned ? "none" : "brightness(0) invert(1)",
|
|
||||||
opacity: owned ? 1 : 0.2,
|
|
||||||
animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{progress.aquarium.length === 0 && (
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 800, color: "var(--paper)", opacity: 0.65 }}>
|
|
||||||
Schaffe eine ganze Welt und dein erstes Tier zieht ein!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
|
|
||||||
<Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
|
|
||||||
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
|
|
||||||
<Stat
|
|
||||||
label="Schnellstes Tier"
|
|
||||||
value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 12 }}>
|
|
||||||
{nextLesson && (
|
|
||||||
<button
|
|
||||||
onClick={onContinue}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: "15px 32px",
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: 900,
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "var(--accent)",
|
|
||||||
color: "var(--paper)",
|
|
||||||
boxShadow: "0 8px 24px var(--shadow)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶ {nextLesson.title}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
|
|
||||||
🗺️ Alle Lektionen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>{value}</div>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>{label}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { LOW_POWER } from "../../lib/lowPower";
|
||||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
||||||
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
||||||
|
|
||||||
@@ -37,8 +38,11 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
|||||||
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
|
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reduced motion: every pet is still placed and shown, it just holds still.
|
// Reduced motion: every pet is still placed and shown, it just holds still. `?pi=1`
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
// takes the same door: on that device the point is that nothing runs a frame loop,
|
||||||
|
// and the pets are the reward - they should be *there*, they just need not swim.
|
||||||
|
const reducedMotion =
|
||||||
|
LOW_POWER || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let lastTime = performance.now();
|
let lastTime = performance.now();
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
/** The map: a single winding path, one world section at a time.
|
/** The map: a single winding path, one world section at a time.
|
||||||
|
*
|
||||||
|
* The floor screen of the tippen tab - the swimming aquarium creatures already show
|
||||||
|
* behind it, so there is no separate landing screen in front of this one anymore.
|
||||||
*
|
*
|
||||||
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
|
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
|
||||||
* waiting is half the reason to finish the world that is open. Each node carries its own
|
* waiting is half the reason to finish the world that is open. Each node carries its own
|
||||||
* best animal, star count and a badge for which game it plays, so the map doubles as
|
* best animal, star count and a badge for which game it plays, so the map doubles as
|
||||||
* both a path forward and a trophy cabinet. */
|
* both a path forward and a trophy cabinet.
|
||||||
|
*
|
||||||
|
* A lesson that unlocks real music or an audiobook chapter carries a treasure chest,
|
||||||
|
* drawn *outside* its node so the dimming a locked node gets never touches it: the whole
|
||||||
|
* value of the chest is that it is visible from far off, on lessons she cannot play yet.
|
||||||
|
* Shut while the reward is still to be won, open with the cover art inside it once it has
|
||||||
|
* been - same reasoning as the dimmed-not-hidden lessons, except stronger here, because
|
||||||
|
* this is the one reward that reaches outside the game. */
|
||||||
|
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
import { coverUrl } from "../../api/client";
|
||||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||||
import { animalById } from "../../lib/tippen/grading";
|
import { animalById } from "../../lib/tippen/grading";
|
||||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||||
import type { Progress } from "../../lib/tippen/progress";
|
import type { Progress } from "../../lib/tippen/progress";
|
||||||
|
import { TreasureChest } from "./TreasureChest";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
worlds: readonly World[];
|
worlds: readonly World[];
|
||||||
@@ -18,9 +32,15 @@ interface Props {
|
|||||||
/** Which card the keyboard selection is on. */
|
/** Which card the keyboard selection is on. */
|
||||||
selected: number;
|
selected: number;
|
||||||
onPick: (lesson: Lesson) => void;
|
onPick: (lesson: Lesson) => void;
|
||||||
|
/** The lesson the floating "Weiter üben" button jumps to - the first unfinished one. */
|
||||||
|
nextLesson: Lesson | null;
|
||||||
|
onContinue: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NODE_SIZE = 88;
|
const NODE_SIZE = 88;
|
||||||
|
/** Big enough to read as a treasure chest at a glance, scrolling past - the 15px 🎁 this
|
||||||
|
* replaced was invisible in practice. */
|
||||||
|
const CHEST_SIZE = 46;
|
||||||
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
|
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
|
||||||
const PATH_HALF_WIDTH = 160;
|
const PATH_HALF_WIDTH = 160;
|
||||||
|
|
||||||
@@ -32,7 +52,7 @@ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> =
|
|||||||
sentences: "Sätze",
|
sentences: "Sätze",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
|
export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLesson, onContinue }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="view-enter"
|
className="view-enter"
|
||||||
@@ -88,78 +108,90 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
|
|||||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
// The node and its chest are two siblings, both positioned against
|
||||||
key={lesson.id}
|
// this world's own box - see RewardChest for why the chest must not
|
||||||
className="card tp-glass-panel"
|
// be a child of the node.
|
||||||
data-selected={index === selected}
|
<Fragment key={lesson.id}>
|
||||||
data-locked={locked}
|
<button
|
||||||
disabled={locked}
|
className="card tp-glass-panel"
|
||||||
onClick={() => onPick(lesson)}
|
data-selected={index === selected}
|
||||||
title={lesson.title}
|
data-locked={locked}
|
||||||
style={{
|
disabled={locked}
|
||||||
position: "absolute",
|
onClick={() => onPick(lesson)}
|
||||||
left: `calc(50% + ${point.x}px)`,
|
title={lesson.title}
|
||||||
top: point.y,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: NODE_SIZE,
|
|
||||||
height: NODE_SIZE,
|
|
||||||
borderRadius: "50%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 2,
|
|
||||||
padding: 0,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: -4,
|
left: `calc(50% + ${point.x}px)`,
|
||||||
right: -4,
|
top: point.y,
|
||||||
fontSize: 15,
|
transform: "translate(-50%, -50%)",
|
||||||
filter: locked ? "grayscale(1)" : "none",
|
width: NODE_SIZE,
|
||||||
opacity: locked ? 0.4 : 0.9,
|
height: NODE_SIZE,
|
||||||
}}
|
borderRadius: "50%",
|
||||||
title={modeInfo.name}
|
display: "flex",
|
||||||
>
|
flexDirection: "column",
|
||||||
{modeInfo.emoji}
|
alignItems: "center",
|
||||||
</span>
|
justifyContent: "center",
|
||||||
|
gap: 2,
|
||||||
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
padding: 0,
|
||||||
|
textAlign: "center",
|
||||||
{/* The keys themselves: for a pre-reader this is the real label, the
|
|
||||||
title is decoration. A drill has no new keys, so it says so with
|
|
||||||
a symbol instead of showing an empty line. */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "var(--paper)",
|
|
||||||
opacity: 0.8,
|
|
||||||
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
|
||||||
lineHeight: 1.1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lesson.isDrill
|
<span
|
||||||
? "🔁"
|
aria-hidden
|
||||||
: lesson.newKeys.length > 0
|
style={{
|
||||||
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
position: "absolute",
|
||||||
: lesson.kind === "letters"
|
top: -4,
|
||||||
? "üben"
|
right: -4,
|
||||||
: CONSOLIDATION_LABEL[lesson.kind]}
|
fontSize: 15,
|
||||||
</span>
|
filter: locked ? "grayscale(1)" : "none",
|
||||||
|
opacity: locked ? 0.4 : 0.9,
|
||||||
|
}}
|
||||||
|
title={modeInfo.name}
|
||||||
|
>
|
||||||
|
{modeInfo.emoji}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
||||||
{[1, 2, 3].map((star) => (
|
|
||||||
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
{/* The keys themselves: for a pre-reader this is the real label, the
|
||||||
⭐
|
title is decoration. A drill has no new keys, so it says so with
|
||||||
</span>
|
a symbol instead of showing an empty line. */}
|
||||||
))}
|
<span
|
||||||
</span>
|
style={{
|
||||||
</button>
|
fontSize: 11,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: 0.8,
|
||||||
|
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lesson.isDrill
|
||||||
|
? "🔁"
|
||||||
|
: lesson.newKeys.length > 0
|
||||||
|
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
||||||
|
: lesson.kind === "letters"
|
||||||
|
? "üben"
|
||||||
|
: CONSOLIDATION_LABEL[lesson.kind]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
||||||
|
{[1, 2, 3].map((star) => (
|
||||||
|
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
||||||
|
⭐
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{lesson.reward.resolved && (
|
||||||
|
<RewardChest
|
||||||
|
point={point}
|
||||||
|
earned={entry?.earned ?? false}
|
||||||
|
albumId={lesson.reward.hasCover ? lesson.reward.albumId : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +199,100 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{nextLesson && (
|
||||||
|
<button
|
||||||
|
onClick={onContinue}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "15px 32px",
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 900,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--paper)",
|
||||||
|
boxShadow: "0 8px 24px var(--shadow)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶ {nextLesson.title}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lesson's treasure chest, sitting on the rim of its node.
|
||||||
|
*
|
||||||
|
* A sibling of the node button rather than a child, for one reason that is easy to lose
|
||||||
|
* again later: `.card[data-locked="true"]` dims the whole button to 45% opacity, and the
|
||||||
|
* chest that most needs to be bright is exactly the one on a lesson three worlds away.
|
||||||
|
* `pointerEvents: none` hands clicks straight through to the node underneath, so the
|
||||||
|
* chest never becomes a second, dead target beside the real one.
|
||||||
|
*
|
||||||
|
* Open, with the cover art of what it gave inside it, once the lesson is earned: it stops
|
||||||
|
* being a promise and becomes the trophy, which makes scrolling the map also a way of
|
||||||
|
* seeing everything she has won. */
|
||||||
|
function RewardChest({
|
||||||
|
point,
|
||||||
|
earned,
|
||||||
|
albumId,
|
||||||
|
}: {
|
||||||
|
point: { x: number; y: number };
|
||||||
|
earned: boolean;
|
||||||
|
albumId: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
// On the node's lower-right rim, overlapping it a little so the two read as one
|
||||||
|
// object rather than as a badge parked nearby.
|
||||||
|
left: `calc(50% + ${point.x + NODE_SIZE / 2 + 2}px)`,
|
||||||
|
top: point.y + NODE_SIZE / 2 - 6,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 1,
|
||||||
|
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 175 / 0.55))",
|
||||||
|
}}
|
||||||
|
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
|
||||||
|
>
|
||||||
|
{/* Shut, the chest is drawn in one piece, because a shut lid has to cover the body's
|
||||||
|
top edge. Won, it is split around the cover art so the cover sits *in* the chest -
|
||||||
|
behind the front wall, under the thrown-back lid - rather than on top of it. See
|
||||||
|
TreasureChest's `layer`. */}
|
||||||
|
{earned && albumId ? (
|
||||||
|
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
|
||||||
|
<TreasureChest size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
<img
|
||||||
|
src={coverUrl(albumId)}
|
||||||
|
alt=""
|
||||||
|
// A cover that fails to load must not leave a broken-image glyph in the chest:
|
||||||
|
// the open chest on its own still says "won", which is the part that matters.
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.style.display = "none";
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "34%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: CHEST_SIZE * 0.46,
|
||||||
|
height: CHEST_SIZE * 0.46,
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1.5px solid oklch(92% 0.13 92 / 0.9)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TreasureChest size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export function ResultSheet({
|
|||||||
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
||||||
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
||||||
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
||||||
<Stat label="Perlen" value={`+${result.pearls}`} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How close the next animal is. A near miss is the strongest reason to press
|
{/* How close the next animal is. A near miss is the strongest reason to press
|
||||||
@@ -149,34 +148,42 @@ export function ResultSheet({
|
|||||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
|
||||||
|
full-screen celebration by the time this sheet is visible, so here it only has
|
||||||
|
to stay on the page as a reminder of what she just won - which is also why it
|
||||||
|
is the one badge on this sheet that gets a tinted row of its own rather than a
|
||||||
|
line of text. */}
|
||||||
{unlockedReward && (
|
{unlockedReward && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
gap: 12,
|
||||||
gap: 10,
|
marginTop: 12,
|
||||||
color: "var(--ink)",
|
padding: "10px 14px",
|
||||||
fontWeight: 900,
|
borderRadius: 16,
|
||||||
marginTop: 8,
|
textAlign: "left",
|
||||||
|
background: "oklch(93% 0.06 92)",
|
||||||
|
border: "1px solid oklch(80% 0.12 90)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{unlockedReward.hasCover ? (
|
{unlockedReward.hasCover ? (
|
||||||
<img
|
<img
|
||||||
src={coverUrl(unlockedReward.albumId)}
|
src={coverUrl(unlockedReward.albumId)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{ width: 52, height: 52, objectFit: "cover", borderRadius: 8, flexShrink: 0 }}
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
objectFit: "cover",
|
|
||||||
borderRadius: 8,
|
|
||||||
animation: "tierEnter 520ms ease-out",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span>
|
<span style={{ fontSize: 40, flexShrink: 0 }}>🎵</span>
|
||||||
)}
|
)}
|
||||||
🎁 Neu zum Anhören: {unlockedReward.title}
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.7 }}>
|
||||||
|
Freigespielt — ab jetzt im Musik-Player
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--ink)" }}>
|
||||||
|
{unlockedReward.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!result.passed && !unlockedTitle && (
|
{!result.passed && !unlockedTitle && (
|
||||||
|
|||||||
409
web/src/components/tippen/RewardUnlockOverlay.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/** The unlock celebration: a treasure chest that opens and hands back real music.
|
||||||
|
*
|
||||||
|
* This is the one moment the typing game exists for. Everything else it gives out -
|
||||||
|
* stars, animals, aquarium creatures - lives inside the game; this is the only reward
|
||||||
|
* that reaches outside it, into the music player. So it gets the whole screen, its own
|
||||||
|
* tune (`playRewardJingle`), and a sequence that has to be waited out, rather than being
|
||||||
|
* one more badge on the result sheet. The result sheet stays mounted underneath and is
|
||||||
|
* revealed when this closes, so nothing is lost by putting this in front of it.
|
||||||
|
*
|
||||||
|
* The staging, and why each beat is there:
|
||||||
|
*
|
||||||
|
* 0ms "drop" the chest falls in, shut, and rattles. The rattle is the beat that
|
||||||
|
* earns the opening - something inside wants out.
|
||||||
|
* 1150ms "open" the lid swings, light bursts, confetti starts, the tune starts.
|
||||||
|
* 1500ms "reveal" the cover art rises out of the chest; the title and the button
|
||||||
|
* arrive under it.
|
||||||
|
*
|
||||||
|
* Dismissal is deliberately not automatic. A six-year-old should get to look at the
|
||||||
|
* cover of the song she just won for as long as she likes, and pressing the button is
|
||||||
|
* itself part of the reward. It is, however, dismissible from the first frame: the
|
||||||
|
* sequence can always be cut short with Enter, Escape or a click. */
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { coverUrl } from "../../api/client";
|
||||||
|
import {
|
||||||
|
playChestOpen,
|
||||||
|
playChestThud,
|
||||||
|
playRewardJingle,
|
||||||
|
} from "../../lib/tippen/pop";
|
||||||
|
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||||
|
import { TreasureChest } from "./TreasureChest";
|
||||||
|
|
||||||
|
type Phase = "drop" | "open" | "reveal";
|
||||||
|
|
||||||
|
const OPEN_AT = 1150;
|
||||||
|
const REVEAL_AT = 1500;
|
||||||
|
|
||||||
|
/** The chest is drawn wider than the cover on purpose: the lid swings up across the
|
||||||
|
* middle, so a cover as wide as the chest hides it completely and the open chest reads as
|
||||||
|
* a shut one. Keeping the cover to roughly three-quarters of the chest's width leaves the
|
||||||
|
* lid standing clear beside it. */
|
||||||
|
const CHEST_SIZE = 196;
|
||||||
|
const COVER_SIZE = 148;
|
||||||
|
|
||||||
|
/** Headline and fallback glyph per reward kind. In German, and phrased as a gift rather
|
||||||
|
* than as a transaction: "freigeschaltet" is what the map's badge says, "für dich" is
|
||||||
|
* what this screen says. */
|
||||||
|
const COPY: Record<
|
||||||
|
UnlockedReward["kind"],
|
||||||
|
{ heading: string; lead: string; glyph: string }
|
||||||
|
> = {
|
||||||
|
album: {
|
||||||
|
heading: "Ein neues Lied für dich!",
|
||||||
|
lead: "Du hast Musik freigespielt",
|
||||||
|
glyph: "🎵",
|
||||||
|
},
|
||||||
|
book: {
|
||||||
|
heading: "Ein neues Hörbuch für dich!",
|
||||||
|
lead: "Du hast eine Geschichte freigespielt",
|
||||||
|
glyph: "📖",
|
||||||
|
},
|
||||||
|
podcast_episode: {
|
||||||
|
heading: "Eine neue Folge für dich!",
|
||||||
|
lead: "Du hast eine Folge freigespielt",
|
||||||
|
glyph: "🎙️",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFETTI_COLOURS = [
|
||||||
|
"oklch(85% 0.17 88)",
|
||||||
|
"oklch(78% 0.16 340)",
|
||||||
|
"oklch(82% 0.15 150)",
|
||||||
|
"oklch(80% 0.14 220)",
|
||||||
|
"oklch(88% 0.13 60)",
|
||||||
|
"oklch(75% 0.17 300)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONFETTI_COUNT = 34;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reward: UnlockedReward;
|
||||||
|
/** Honours her own sound setting - the same flag that silences every other sound in
|
||||||
|
* the app. A silent celebration still celebrates. */
|
||||||
|
sound: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const [phase, setPhase] = useState<Phase>(reducedMotion ? "reveal" : "drop");
|
||||||
|
const button = useRef<HTMLButtonElement>(null);
|
||||||
|
const copy = COPY[reward.kind];
|
||||||
|
|
||||||
|
// Fixed per mount, so a re-render (the button focusing, say) never reshuffles the
|
||||||
|
// confetti mid-fall.
|
||||||
|
const confetti = useMemo(
|
||||||
|
() => (reducedMotion ? [] : makeConfetti()),
|
||||||
|
[reducedMotion],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reducedMotion) {
|
||||||
|
if (sound) playRewardJingle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sound) playChestThud();
|
||||||
|
const timers = [
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setPhase("open");
|
||||||
|
if (sound) {
|
||||||
|
playChestOpen();
|
||||||
|
playRewardJingle();
|
||||||
|
}
|
||||||
|
}, OPEN_AT),
|
||||||
|
window.setTimeout(() => setPhase("reveal"), REVEAL_AT),
|
||||||
|
];
|
||||||
|
return () => timers.forEach(window.clearTimeout);
|
||||||
|
}, [reducedMotion, sound]);
|
||||||
|
|
||||||
|
// The button only exists from "reveal" on, so this focuses it as it appears. Taking
|
||||||
|
// focus matters: the result sheet underneath grabbed it for its own "Nochmal" button
|
||||||
|
// when it mounted, and Enter must not restart the run from behind this screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "reveal") button.current?.focus();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
const open = phase !== "drop";
|
||||||
|
const revealed = phase === "reveal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="tp-reward-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`${copy.heading} ${reward.title}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{confetti.map((piece, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="tp-confetti-piece"
|
||||||
|
style={{
|
||||||
|
left: piece.left,
|
||||||
|
width: piece.width,
|
||||||
|
height: piece.height,
|
||||||
|
background: piece.colour,
|
||||||
|
borderRadius: piece.round ? "50%" : 2,
|
||||||
|
animationDelay: `${OPEN_AT + piece.delay}ms`,
|
||||||
|
animationDuration: `${piece.duration}ms`,
|
||||||
|
["--tp-drift" as string]: piece.drift,
|
||||||
|
["--tp-spin" as string]: piece.spin,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: revealed ? 0.75 : 0,
|
||||||
|
transition: "opacity 300ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copy.lead}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The chest and the cover share one stacking box, and the cover is sandwiched
|
||||||
|
between the chest's two halves - see TreasureChest's `layer`. That is what makes
|
||||||
|
the cover come *out of* the chest rather than float in front of a picture of
|
||||||
|
one: it rises from behind the chest's front wall, under the thrown-back lid. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: CHEST_SIZE,
|
||||||
|
height: CHEST_SIZE,
|
||||||
|
// Room above for the cover, which overhangs this box by most of its height.
|
||||||
|
marginTop: COVER_SIZE * 0.78,
|
||||||
|
animation: open
|
||||||
|
? undefined
|
||||||
|
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
|
||||||
|
{open && !reducedMotion && <Rays />}
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
// Deep enough that the cover's foot is hidden behind the front wall.
|
||||||
|
bottom: CHEST_SIZE * 0.5,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RewardArt reward={reward} glyph={copy.glyph} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 27,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
|
marginTop: 16,
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
animation: revealed
|
||||||
|
? "rewardTextEnter 360ms ease-out 80ms both"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copy.heading}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(90% 0.13 92)",
|
||||||
|
maxWidth: 460,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
marginTop: 4,
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
animation: revealed
|
||||||
|
? "rewardTextEnter 360ms ease-out 200ms both"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reward.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: revealed ? 0.7 : 0,
|
||||||
|
marginTop: 8,
|
||||||
|
transition: "opacity 300ms 400ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ab jetzt im Musik-Player 🎧
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<button
|
||||||
|
ref={button}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "14px 30px",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 900,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--paper)",
|
||||||
|
boxShadow: "0 8px 24px var(--shadow)",
|
||||||
|
marginTop: 22,
|
||||||
|
animation: "rewardTextEnter 360ms ease-out 320ms both",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toll! ⏎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cover art, or a glyph when the album has none. */
|
||||||
|
function RewardArt({
|
||||||
|
reward,
|
||||||
|
glyph,
|
||||||
|
}: {
|
||||||
|
reward: UnlockedReward;
|
||||||
|
glyph: string;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const shared: React.CSSProperties = {
|
||||||
|
width: COVER_SIZE,
|
||||||
|
height: COVER_SIZE,
|
||||||
|
borderRadius: 18,
|
||||||
|
border: "3px solid oklch(90% 0.13 92 / 0.9)",
|
||||||
|
animation:
|
||||||
|
"coverRise 760ms cubic-bezier(0.22, 1.2, 0.36, 1) both, coverHalo 2.6s ease-in-out 760ms infinite",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A cover the backend said exists but that fails to load would otherwise leave a
|
||||||
|
// broken-image box as the centrepiece of the celebration.
|
||||||
|
if (!reward.hasCover || failed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...shared,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 92,
|
||||||
|
background:
|
||||||
|
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={coverUrl(reward.albumId)}
|
||||||
|
alt=""
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
style={{
|
||||||
|
...shared,
|
||||||
|
objectFit: "cover",
|
||||||
|
background: "oklch(45% 0.08 175)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The burst of light at the moment the lid lets go. Two counter-rotating stars of rays,
|
||||||
|
* so the burst reads as light rather than as a spinning shape. */
|
||||||
|
function Rays() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "18%",
|
||||||
|
width: 260,
|
||||||
|
height: 260,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[0, 1].map((layer) => (
|
||||||
|
<div
|
||||||
|
key={layer}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
animation: `rayBurst ${layer === 0 ? 900 : 1150}ms ease-out ${layer * 90}ms both`,
|
||||||
|
background: `repeating-conic-gradient(from ${layer * 11}deg, oklch(95% 0.14 92 / 0.55) 0deg 5deg, transparent 5deg 22deg)`,
|
||||||
|
maskImage:
|
||||||
|
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfettiPiece {
|
||||||
|
left: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
colour: string;
|
||||||
|
round: boolean;
|
||||||
|
delay: number;
|
||||||
|
duration: number;
|
||||||
|
drift: string;
|
||||||
|
spin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConfetti(): ConfettiPiece[] {
|
||||||
|
return Array.from({ length: CONFETTI_COUNT }, (_, i) => {
|
||||||
|
const round = i % 3 === 0;
|
||||||
|
const size = 7 + Math.random() * 8;
|
||||||
|
return {
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
width: round ? size : size * 0.55,
|
||||||
|
height: size,
|
||||||
|
colour: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length]!,
|
||||||
|
round,
|
||||||
|
// Spread over a second and a half, so it falls as a shower rather than as a line.
|
||||||
|
delay: Math.random() * 1500,
|
||||||
|
duration: 2400 + Math.random() * 2200,
|
||||||
|
drift: `${(Math.random() - 0.5) * 220}px`,
|
||||||
|
spin: `${(Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 720)}deg`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrefersReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(
|
||||||
|
() =>
|
||||||
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||||
|
if (!query) return;
|
||||||
|
const onChange = () => setReduced(query.matches);
|
||||||
|
query.addEventListener("change", onChange);
|
||||||
|
return () => query.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
/** The gradient stage every screen sits on - the app's one full-height container.
|
/** The gradient stage every screen sits on - the app's one full-height container.
|
||||||
* `data-blur` is read by app.css to drop the backdrop filters wholesale.
|
* `data-blur` is read by app.css to drop the backdrop filters wholesale, and `data-anim`
|
||||||
|
* by tippen.css to stop the one CSS animation loop that is not a component's to switch
|
||||||
|
* off - the pulse on the next key to press.
|
||||||
*
|
*
|
||||||
* The pets swim here rather than on the home screen, so they stay with her on the lesson
|
* The pets swim here rather than on the home screen, so they stay with her on the lesson
|
||||||
* map and during a run too. */
|
* map and during a run too. */
|
||||||
@@ -7,6 +9,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||||
|
import { LOW_POWER } from "../../lib/lowPower";
|
||||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
||||||
import { AquariumCreatures } from "./AquariumCreatures";
|
import { AquariumCreatures } from "./AquariumCreatures";
|
||||||
import { Bubbles } from "./Bubbles";
|
import { Bubbles } from "./Bubbles";
|
||||||
@@ -21,7 +24,11 @@ interface Props {
|
|||||||
|
|
||||||
export function Stage({ children, creatures, dimmed }: Props) {
|
export function Stage({ children, creatures, dimmed }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
|
<div
|
||||||
|
className="tp-stage"
|
||||||
|
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
||||||
|
data-anim={LOW_POWER ? "off" : "on"}
|
||||||
|
>
|
||||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||||
{SHOW_BUBBLES && <Bubbles />}
|
{SHOW_BUBBLES && <Bubbles />}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
149
web/src/components/tippen/TreasureChest.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/** A treasure chest whose lid actually opens.
|
||||||
|
*
|
||||||
|
* Drawn rather than set as an emoji for one reason: the lid has to be a separate group
|
||||||
|
* so it can swing on a hinge. A 🎁 can only ever pop and scale, which is exactly the
|
||||||
|
* animation the old reward badge had and exactly why nobody noticed it. It is also
|
||||||
|
* sharp at every size, which matters because the same component draws both the 46px
|
||||||
|
* badge on the lesson map and the 168px chest in the unlock overlay.
|
||||||
|
*
|
||||||
|
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
|
||||||
|
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
|
||||||
|
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
|
||||||
|
* gets its swing for free just by flipping the prop mid-sequence.
|
||||||
|
*
|
||||||
|
* `layer` splits the drawing in two so that something can be put *inside* the chest. The
|
||||||
|
* lid swings up across the space above the chest, which is exactly where a rising cover
|
||||||
|
* wants to be; drawn as one piece, the lid crosses in front of the cover and the whole
|
||||||
|
* thing reads as a z-order bug. Drawn as "back" (lid, mouth, glow), then the cover, then
|
||||||
|
* "front" (the body), the cover emerges from behind the chest's own front wall - which is
|
||||||
|
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
|
||||||
|
* whose shut chests need the lid painted over the body's top edge, takes the default. */
|
||||||
|
|
||||||
|
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
|
||||||
|
* `<defs>` ids, or the second one silently paints with the first one's stops. */
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size: number;
|
||||||
|
open: boolean;
|
||||||
|
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
|
||||||
|
layer?: "all" | "back" | "front";
|
||||||
|
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
|
||||||
|
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
|
||||||
|
muted?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
|
||||||
|
const id = `tp-chest-${(nextId += 1)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 64 62"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
|
||||||
|
opacity: muted ? 0.9 : 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`${id}-wood`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(58% 0.11 55)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(38% 0.09 45)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`${id}-lid`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(66% 0.11 58)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(46% 0.1 48)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`${id}-gold`} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(92% 0.14 95)" />
|
||||||
|
<stop offset="45%" stopColor="oklch(82% 0.17 88)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(66% 0.15 80)" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id={`${id}-glow`}>
|
||||||
|
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
|
||||||
|
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* What makes it read as *open* rather than as a chest with its lid drawn beside
|
||||||
|
it: a dark mouth, with light coming out of it. Both are painted before the body,
|
||||||
|
so the body's front wall covers their lower half and the opening looks like a
|
||||||
|
hole in the chest rather than a shape on it. */}
|
||||||
|
{layer !== "front" && open && (
|
||||||
|
<>
|
||||||
|
<ellipse cx="32" cy="27" rx="24" ry="7" fill="oklch(22% 0.03 45)" />
|
||||||
|
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layer === "back" && <Lid id={id} open={open} />}
|
||||||
|
|
||||||
|
{layer !== "back" && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="26"
|
||||||
|
width="52"
|
||||||
|
height="30"
|
||||||
|
rx="5"
|
||||||
|
fill={`url(#${id}-wood)`}
|
||||||
|
stroke="oklch(28% 0.06 45)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
{/* Two gold straps down the body, and the band along its foot. */}
|
||||||
|
<rect x="13" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
|
||||||
|
<rect x="46.5" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
|
||||||
|
<rect x="6" y="47" width="52" height="4" fill={`url(#${id}-gold)`} opacity="0.7" />
|
||||||
|
|
||||||
|
{/* Lock plate. It stays on the body - a real chest's hasp swings with the lid,
|
||||||
|
but a lock that leaves with the lid loses the "this was shut" read. */}
|
||||||
|
<rect
|
||||||
|
x="26"
|
||||||
|
y="30"
|
||||||
|
width="12"
|
||||||
|
height="14"
|
||||||
|
rx="2.5"
|
||||||
|
fill={`url(#${id}-gold)`}
|
||||||
|
stroke="oklch(45% 0.1 75)"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
/>
|
||||||
|
<circle cx="32" cy="36" r="2.2" fill="oklch(32% 0.05 60)" />
|
||||||
|
<rect x="31" y="36" width="2" height="5" rx="1" fill="oklch(32% 0.05 60)" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In one piece, the lid goes last: shut, it has to cover the body's top edge. */}
|
||||||
|
{layer === "all" && <Lid id={id} open={open} />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A half-round lid, hinged at its left end - the cartoon flip-open, and the only hinge
|
||||||
|
* that works in a flat, front-on view. */
|
||||||
|
function Lid({ id, open }: { id: string; open: boolean }) {
|
||||||
|
return (
|
||||||
|
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
|
||||||
|
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
|
||||||
|
<rect
|
||||||
|
x="7"
|
||||||
|
y="22.5"
|
||||||
|
width="50"
|
||||||
|
height="5"
|
||||||
|
rx="2"
|
||||||
|
fill={`url(#${id}-gold)`}
|
||||||
|
stroke="oklch(45% 0.1 75)"
|
||||||
|
strokeWidth="0.8"
|
||||||
|
/>
|
||||||
|
<path d="M13 27 A 24 21 0 0 1 15.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
|
||||||
|
<path d="M51 27 A 24 21 0 0 0 48.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
/** Jellyfish mode - pure key location, nothing else.
|
|
||||||
*
|
|
||||||
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is
|
|
||||||
* the one to zap. There is no line to read and no word to spell, so the only thing
|
|
||||||
* being exercised is "where does this letter live" - which is exactly the skill dive
|
|
||||||
* mode hides behind reading.
|
|
||||||
*
|
|
||||||
* The decoys matter. Showing only the target turns this into the bubble mode; showing
|
|
||||||
* five wrong letters next to it means she has to find *her* letter before she can type
|
|
||||||
* it, which is the searching step that eventually goes away. */
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { currentChar } from "../../../lib/tippen/engine";
|
|
||||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
|
||||||
import { mulberry32 } from "../../../lib/tippen/generator";
|
|
||||||
import type { RunResult } from "../../../lib/tippen/grading";
|
|
||||||
import type { Progress } from "../../../lib/tippen/progress";
|
|
||||||
import { useRun } from "../../../hooks/useTippenRun";
|
|
||||||
import { Keyboard } from "../Keyboard";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
letters: readonly string[];
|
|
||||||
activeKeys: readonly string[];
|
|
||||||
progress: Progress;
|
|
||||||
paused: boolean;
|
|
||||||
onFinished: (result: RunResult) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many jellyfish are in the water at once, target included. */
|
|
||||||
const JELLYFISH_COUNT = 6;
|
|
||||||
|
|
||||||
interface Jellyfish {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
drift: number;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
|
||||||
const text = letters.join("");
|
|
||||||
const { state, wrong } = useRun({
|
|
||||||
target: text,
|
|
||||||
sound: progress.settings.sound,
|
|
||||||
paused,
|
|
||||||
onFinished,
|
|
||||||
});
|
|
||||||
|
|
||||||
const next = currentChar(state);
|
|
||||||
|
|
||||||
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
|
|
||||||
// would make the searching step impossible rather than merely hard.
|
|
||||||
const spots = useMemo<Jellyfish[]>(() => {
|
|
||||||
const rng = mulberry32(text.length * 31 + 7);
|
|
||||||
return Array.from({ length: JELLYFISH_COUNT }, () => ({
|
|
||||||
left: 10 + rng() * 76,
|
|
||||||
top: 6 + rng() * 66,
|
|
||||||
drift: 3 + rng() * 3,
|
|
||||||
size: 74 + rng() * 26,
|
|
||||||
}));
|
|
||||||
}, [text]);
|
|
||||||
|
|
||||||
/** The decoys shown alongside the target: other active keys, never the target itself,
|
|
||||||
* and stable for as long as the target is. */
|
|
||||||
const decoys = useMemo(() => {
|
|
||||||
if (!next) return [];
|
|
||||||
const rng = mulberry32(state.index * 101 + 13);
|
|
||||||
const others = activeKeys.filter((key) => key !== next && key !== " ");
|
|
||||||
const shuffled = [...others].sort(() => rng() - 0.5);
|
|
||||||
return shuffled.slice(0, JELLYFISH_COUNT - 1);
|
|
||||||
}, [next, state.index, activeKeys]);
|
|
||||||
|
|
||||||
// Which jellyfish carries the target. Moves around so it is not always the same one.
|
|
||||||
const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="view-enter"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
padding: "0 32px 8px",
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
|
|
||||||
{spots.map((spot, i) => {
|
|
||||||
const isTarget = i === targetSlot;
|
|
||||||
const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
|
|
||||||
if (!letter) return null;
|
|
||||||
const finger = fingerOf(letter);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="tp-jellyfish"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${spot.left}%`,
|
|
||||||
top: `${spot.top}%`,
|
|
||||||
width: spot.size,
|
|
||||||
height: spot.size,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontSize: spot.size * (isTarget ? 0.4 : 0.3),
|
|
||||||
fontWeight: 900,
|
|
||||||
borderRadius: "50% 50% 42% 42%",
|
|
||||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
|
||||||
background: isTarget
|
|
||||||
? "var(--paper)"
|
|
||||||
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
|
|
||||||
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
|
|
||||||
boxShadow: isTarget
|
|
||||||
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
|
|
||||||
: "0 4px 14px var(--shadow)",
|
|
||||||
opacity: isTarget ? 1 : 0.55,
|
|
||||||
transform: isTarget ? "scale(1.12)" : "scale(1)",
|
|
||||||
animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
|
|
||||||
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{letter === " " ? "␣" : letter}
|
|
||||||
{isTarget && wrong && (
|
|
||||||
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Keyboard
|
|
||||||
activeKeys={activeKeys}
|
|
||||||
nextKey={next}
|
|
||||||
progress={progress}
|
|
||||||
mode={progress.settings.keyboardHint}
|
|
||||||
size={36}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,13 @@ also gives beat-synced animation the frame-accurate clock it will need later.
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { PLAYBACK_CLOCK_FPS } from "../lib/lowPower";
|
||||||
|
|
||||||
|
/** A render budget, not a timer - rAF still drives the loop, this only decides which
|
||||||
|
* frames are allowed to push state. The 1ms slack keeps a budget from landing just
|
||||||
|
* inside a vsync interval and silently halving the rate it asked for. */
|
||||||
|
const MIN_RENDER_MS = PLAYBACK_CLOCK_FPS > 0 ? 1000 / PLAYBACK_CLOCK_FPS - 1 : 0;
|
||||||
|
|
||||||
export function usePlaybackClock(position: number, playing: boolean): number {
|
export function usePlaybackClock(position: number, playing: boolean): number {
|
||||||
const [interpolated, setInterpolated] = useState(position);
|
const [interpolated, setInterpolated] = useState(position);
|
||||||
const anchor = useRef({ position, at: performance.now() });
|
const anchor = useRef({ position, at: performance.now() });
|
||||||
@@ -20,10 +27,13 @@ export function usePlaybackClock(position: number, playing: boolean): number {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
const tick = () => {
|
let lastRender = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
if (now - lastRender < MIN_RENDER_MS) return;
|
||||||
|
lastRender = now;
|
||||||
const { position: base, at } = anchor.current;
|
const { position: base, at } = anchor.current;
|
||||||
setInterpolated(base + (performance.now() - at) / 1000);
|
setInterpolated(base + (performance.now() - at) / 1000);
|
||||||
frame = requestAnimationFrame(tick);
|
|
||||||
};
|
};
|
||||||
frame = requestAnimationFrame(tick);
|
frame = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import type { Album } from "../../api/types";
|
import type { Album } from "../../api/types";
|
||||||
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
|
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
|
||||||
import { normalize, results as computeResults, type Results } from "../search";
|
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
|
||||||
|
|
||||||
function album(id: string, over: Partial<Album> = {}): Album {
|
function album(id: string, over: Partial<Album> = {}): Album {
|
||||||
return {
|
return {
|
||||||
@@ -40,9 +40,11 @@ const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) =>
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const INDEX = buildSearchIndex(ALBUMS);
|
||||||
|
|
||||||
const resultsFor = (ui: UiState) =>
|
const resultsFor = (ui: UiState) =>
|
||||||
computeResults({
|
computeResults({
|
||||||
albums: ALBUMS,
|
index: INDEX,
|
||||||
search: ui.search,
|
search: ui.search,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
group: ui.group,
|
group: ui.group,
|
||||||
|
|||||||
131
web/src/lib/__tests__/search.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Album, Track } from "../../api/types";
|
||||||
|
import {
|
||||||
|
buildSearchIndex,
|
||||||
|
MAX_SONG_HITS,
|
||||||
|
normalize,
|
||||||
|
results as computeResults,
|
||||||
|
type Group,
|
||||||
|
} from "../search";
|
||||||
|
|
||||||
|
function track(title: string, over: Partial<Track> = {}): Track {
|
||||||
|
return { title, duration: 60, analysis: null, locked: false, unlock_hint: null, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
locked: false,
|
||||||
|
tracks: [track(`Lied ${id}`)],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALBUMS: Album[] = [
|
||||||
|
album("a", { title: "Conni lernt Rad fahren", artist: "Conni", kind: "book", category: "Conni" }),
|
||||||
|
album("b", { title: "Käpt'n Krabbe", artist: "Rolf", category: "Rolf" }),
|
||||||
|
album("c", {
|
||||||
|
section: "Kinderpodcasts",
|
||||||
|
title: "GEOlino Spezial",
|
||||||
|
artist: "WDR",
|
||||||
|
category: "GEOlino",
|
||||||
|
tracks: [track("Der Vater der Chemie"), track("Geheime Folge", { locked: true })],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const INDEX = buildSearchIndex(ALBUMS);
|
||||||
|
|
||||||
|
const query = (over: Partial<Parameters<typeof computeResults>[0]> = {}) =>
|
||||||
|
computeResults({ index: INDEX, search: "", mode: "albums", group: null, category: null, ...over });
|
||||||
|
|
||||||
|
describe("buildSearchIndex", () => {
|
||||||
|
it("buckets albums onto the three shelves by section and kind", () => {
|
||||||
|
const groups = Object.fromEntries(
|
||||||
|
(["music", "audiobooks", "podcasts"] as Group[]).map((g) => [
|
||||||
|
g,
|
||||||
|
INDEX.byGroup[g].map((e) => e.album.id),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(groups).toEqual({ music: ["b"], audiobooks: ["a"], podcasts: ["c"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts each shelf's categories, and only its own", () => {
|
||||||
|
expect(INDEX.categoriesByGroup.music.map((c) => c.key)).toEqual(["Rolf"]);
|
||||||
|
expect(INDEX.categoriesByGroup.audiobooks.map((c) => c.key)).toEqual(["Conni"]);
|
||||||
|
expect(INDEX.categoriesByGroup.podcasts.map((c) => c.key)).toEqual(["GEOlino"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves locked tracks out entirely - they have no title to match on", () => {
|
||||||
|
expect(INDEX.tracks.map((t) => t.title)).not.toContain("Geheime Folge");
|
||||||
|
expect(INDEX.tracks).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the same normalized keys the matchers used to compute per keystroke", () => {
|
||||||
|
const conni = INDEX.entries.find((e) => e.album.id === "a")!;
|
||||||
|
expect(conni.haystack).toBe(normalize("Conni lernt Rad fahrenConni"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("albumMatches", () => {
|
||||||
|
it("finds words in any order, across title and artist", () => {
|
||||||
|
expect(query({ search: "conni rad" }).albums.map((a) => a.id)).toEqual(["a"]);
|
||||||
|
expect(query({ search: "rad conni" }).albums.map((a) => a.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores punctuation and diacritics", () => {
|
||||||
|
expect(query({ search: "kaptn" }).albums.map((a) => a.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays inside the shelf once one is entered", () => {
|
||||||
|
expect(query({ search: "conni", group: "music" }).albums).toEqual([]);
|
||||||
|
expect(query({ search: "conni", group: "audiobooks" }).albums.map((a) => a.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a whole category with no search text", () => {
|
||||||
|
expect(query({ group: "music", category: "Rolf" }).albums.map((a) => a.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing at the bare root - that screen is the three shelves", () => {
|
||||||
|
expect(query().albums).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("songMatches", () => {
|
||||||
|
it("searches track titles, not album titles", () => {
|
||||||
|
const hits = query({ mode: "tracks", search: "vater" }).songs;
|
||||||
|
expect(hits.map((h) => [h.album.id, h.index, h.title])).toEqual([["c", 0, "Der Vater der Chemie"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a locked track", () => {
|
||||||
|
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps an empty query, which would otherwise be the whole library", () => {
|
||||||
|
const many = Array.from({ length: 60 }, (_, i) =>
|
||||||
|
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
|
||||||
|
);
|
||||||
|
const index = buildSearchIndex(many);
|
||||||
|
const hits = computeResults({
|
||||||
|
index,
|
||||||
|
search: "",
|
||||||
|
mode: "tracks",
|
||||||
|
group: null,
|
||||||
|
category: null,
|
||||||
|
}).songs;
|
||||||
|
expect(hits).toHaveLength(MAX_SONG_HITS);
|
||||||
|
// Album order, then track order - the cap takes a prefix, it does not reshuffle.
|
||||||
|
expect(hits[0]!.title).toBe("Lied 0");
|
||||||
|
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
web/src/lib/lowPower.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/** `?pi=1` - the same app, cut down to what a Raspberry Pi 4 can actually paint.
|
||||||
|
*
|
||||||
|
* The device this exists for is musicdolphin: a Pi 4 driving a 1920x1080 monitor
|
||||||
|
* through a Firefox kiosk window. The GPU is fine there (`V3D 4.2`, accelerated), but
|
||||||
|
* Firefox on Linux rasterizes 2D canvas in the content process, and the ambient canvas
|
||||||
|
* repaints the full screen every frame. Idle - nothing playing, nothing moving but the
|
||||||
|
* gradient - that was half a core in the content process alone.
|
||||||
|
*
|
||||||
|
* Everything here was measured on the device rather than reasoned about, and the
|
||||||
|
* reasoning lost. Sum of the Firefox process tree, idle on the browse screen, 15s
|
||||||
|
* average:
|
||||||
|
*
|
||||||
|
* full app 53.5%
|
||||||
|
* + ambient canvas at half resolution 25.0% <- the whole win
|
||||||
|
* + 30fps cap on top of that 25.0% (no idle change)
|
||||||
|
* + decorative CSS animation loops switched off 24.6% (no change; left on)
|
||||||
|
* + backdrop-filter glass blur switched off 118.1% <- 5x WORSE
|
||||||
|
*
|
||||||
|
* That last row is why this module is so much smaller than it started out. On this
|
||||||
|
* device `backdrop-filter` is load-bearing *for performance*: it promotes each glass
|
||||||
|
* panel to its own compositing layer, so a canvas frame underneath repaints only the
|
||||||
|
* canvas. Take the blur away and the canvas and everything stacked above it collapse
|
||||||
|
* into one layer, and every single frame repaints the whole page - all of the album
|
||||||
|
* grid included. The most expensive-looking CSS in the app is the thing keeping the
|
||||||
|
* rest of it cheap. Do not "optimize" it away without re-running the numbers above.
|
||||||
|
*
|
||||||
|
* That got it from 53.5% to 25%, and 25% was still not enough to use. The profile now
|
||||||
|
* goes further and stops the animation instead of thinning it: under `?pi=1` the
|
||||||
|
* ambient canvas is not rendered at all (`SHOW_AMBIENCE`), no bubbles rise anywhere,
|
||||||
|
* the decorative CSS loops are off, the view transitions are off, and the typing game's
|
||||||
|
* pets are placed but held still. A requestAnimationFrame loop that repaints the whole
|
||||||
|
* viewport is a floor you cannot get under while it runs at all, and on a Pi 4 that
|
||||||
|
* floor is too high. With it gone the browser has nothing to do between one keypress
|
||||||
|
* and the next. `.stage` keeps its own static CSS gradient, so the screen still has a
|
||||||
|
* sea behind it - it just no longer moves or follows the track.
|
||||||
|
*
|
||||||
|
* What stays: both blurs on the panels (see the table - dropping those was five times
|
||||||
|
* worse), and the card blur off, which is a count problem rather than a blur problem.
|
||||||
|
*
|
||||||
|
* Read once at module load, from the URL and nowhere else: no persistence, no
|
||||||
|
* auto-detection. The kiosk points Firefox at `http://localhost:8080/?pi=1` (see the
|
||||||
|
* `pi_kiosk_url` host var in the ansible repo) and a laptop opening the same page gets
|
||||||
|
* the full-fat version, which is what makes "is this the profile or the hardware?"
|
||||||
|
* answerable by editing the address bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** True when the page was loaded with `?pi=1`. */
|
||||||
|
export const LOW_POWER = readLowPowerFlag();
|
||||||
|
|
||||||
|
function readLowPowerFlag(): boolean {
|
||||||
|
// `location` is absent under vitest's default node environment.
|
||||||
|
if (typeof location === "undefined") return false;
|
||||||
|
const value = new URLSearchParams(location.search).get("pi");
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmbienceQuality {
|
||||||
|
/** Multiplies the canvas backing store relative to its on-screen size, before
|
||||||
|
* `devicePixelRatio`. At 0.5 the loop fills a quarter of the pixels and the browser
|
||||||
|
* scales the result back up. This is the one change that moved the number: 53.5% ->
|
||||||
|
* 25.0% idle. On a soft gradient with translucent circles drifting over it, the
|
||||||
|
* upscale is not something you can point at on the screen. */
|
||||||
|
resolutionScale: number;
|
||||||
|
/** Hard ceiling on either backing-store axis. Also a guard against a runaway
|
||||||
|
* measurement hitting the browser's canvas allocation limit. */
|
||||||
|
maxBackingStorePx: number;
|
||||||
|
/** Frames per second the loop will paint. 0 means "whatever the display offers".
|
||||||
|
* Worth nothing at idle once the resolution is halved - see the table above - and
|
||||||
|
* kept only for the playing case, where each frame also samples the mood curve and
|
||||||
|
* moves every bubble. The gradient eases over ~2s; nothing here has anything to say
|
||||||
|
* at 60fps it cannot say at 30. */
|
||||||
|
maxFps: number;
|
||||||
|
/** Ceiling on bubbles alive at once. 0 means unlimited. Spawn rate is driven by the
|
||||||
|
* music, so a loud track on a big screen can otherwise pile up more circles per
|
||||||
|
* frame than this device can draw. */
|
||||||
|
maxBubbles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unreachable under `?pi=1` as things stand, because `SHOW_AMBIENCE` switches the
|
||||||
|
* canvas off there entirely. Kept, and kept accurate, because it is the setting that
|
||||||
|
* matters the moment anyone turns the canvas back on for a weak device - the half-scale
|
||||||
|
* backing store is what took it from 53.5% to 25%. */
|
||||||
|
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
|
||||||
|
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
|
||||||
|
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };
|
||||||
|
|
||||||
|
/** How many times a second the interpolated playback position may push a new React
|
||||||
|
* render. 0 means "every animation frame", which is what it has always done.
|
||||||
|
*
|
||||||
|
* `usePlaybackClock` exists to stop the progress bar stepping twice a second, and it
|
||||||
|
* does that with a `setState` per animation frame - so `PlayView` and `PlayerBar` each
|
||||||
|
* re-render sixty times a second for the whole length of a track. A progress bar a
|
||||||
|
* thousand-odd pixels wide advances one pixel every few *hundred* milliseconds on a
|
||||||
|
* three-minute track, and the time label beside it only has one-second resolution, so
|
||||||
|
* ten updates a second is already more than either can show.
|
||||||
|
*
|
||||||
|
* Only bites while something is playing, so it is not in the idle table above.
|
||||||
|
*/
|
||||||
|
export const PLAYBACK_CLOCK_FPS = LOW_POWER ? 10 : 0;
|
||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
The whole index arrives in one response, so type-to-search has no round trip and feels
|
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.
|
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
|
||||||
|
|
||||||
|
The cost that matters here is per keystroke, not per library load. A real collection is
|
||||||
|
343 albums and 4969 tracks, and the naive version of this module re-derived its search
|
||||||
|
keys from scratch on every single one of them every time a letter was typed:
|
||||||
|
`normalize()` runs `toLowerCase` + Unicode NFD decomposition + two regex passes, so
|
||||||
|
track search meant five thousand NFD normalizations before a single comparison. That is
|
||||||
|
work whose answer cannot change until the library itself changes.
|
||||||
|
|
||||||
|
So it is done once, in `buildSearchIndex`, and a keystroke is reduced to
|
||||||
|
`String.includes` over strings that already exist. The same build also partitions the
|
||||||
|
albums by group and pre-computes each group's sorted category list - which `App` and
|
||||||
|
`BrowseView` were otherwise both deriving, independently, from the same albums.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Album } from "../api/types";
|
import type { Album } from "../api/types";
|
||||||
@@ -58,19 +70,118 @@ export function inGroup(album: Album, group: Group): boolean {
|
|||||||
return groupOf(album) === group;
|
return groupOf(album) === group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `null` means unrestricted - typing at the root searches every group at once. */
|
// ------------------------------------------------------------------- index --
|
||||||
export function pool(albums: Album[], group: Group | null): Album[] {
|
|
||||||
return group === null ? albums : albums.filter((album) => inGroup(album, group));
|
const GROUPS: readonly Group[] = ["music", "audiobooks", "podcasts"];
|
||||||
|
|
||||||
|
export interface AlbumEntry {
|
||||||
|
album: Album;
|
||||||
|
group: Group;
|
||||||
|
/** `normalize(title + artist)`, as `albumMatches` used to compute per keystroke. */
|
||||||
|
haystack: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowseQuery {
|
export interface TrackEntry {
|
||||||
|
album: Album;
|
||||||
|
/** Index within `album.tracks`, which is what `SongHit` and playback both want. */
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
group: Group;
|
||||||
|
haystack: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a search needs, derived once per library payload. */
|
||||||
|
export interface SearchIndex {
|
||||||
|
/** The albums exactly as the backend sent them, for callers that want the plain list. */
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
|
entries: AlbumEntry[];
|
||||||
|
byGroup: Record<Group, AlbumEntry[]>;
|
||||||
|
/** Each group's categories, already grouped and sorted. Same array identity on every
|
||||||
|
* read, so a `useMemo` downstream of it stays stable. */
|
||||||
|
categoriesByGroup: Record<Group, Category[]>;
|
||||||
|
/** Unlocked tracks only, album order then track order. A locked track shows as a
|
||||||
|
* question mark wherever it appears, so it has no title to match on and is left out
|
||||||
|
* of the index entirely rather than skipped at match time. A reward being earned
|
||||||
|
* reloads the library, which rebuilds this. */
|
||||||
|
tracks: TrackEntry[];
|
||||||
|
tracksByGroup: Record<Group, TrackEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyByGroup = <T,>(): Record<Group, T[]> => ({ music: [], audiobooks: [], podcasts: [] });
|
||||||
|
|
||||||
|
export function buildSearchIndex(albums: Album[]): SearchIndex {
|
||||||
|
const entries: AlbumEntry[] = [];
|
||||||
|
const byGroup = emptyByGroup<AlbumEntry>();
|
||||||
|
const tracks: TrackEntry[] = [];
|
||||||
|
const tracksByGroup = emptyByGroup<TrackEntry>();
|
||||||
|
const categoryMaps: Record<Group, Map<string, Category>> = {
|
||||||
|
music: new Map(),
|
||||||
|
audiobooks: new Map(),
|
||||||
|
podcasts: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const album of albums) {
|
||||||
|
const group = groupOf(album);
|
||||||
|
const entry: AlbumEntry = { album, group, haystack: normalize(album.title + album.artist) };
|
||||||
|
entries.push(entry);
|
||||||
|
byGroup[group].push(entry);
|
||||||
|
|
||||||
|
const categories = categoryMaps[group];
|
||||||
|
let category = categories.get(album.category);
|
||||||
|
if (!category) {
|
||||||
|
category = { key: album.category, albums: [] };
|
||||||
|
categories.set(album.category, category);
|
||||||
|
}
|
||||||
|
category.albums.push(album);
|
||||||
|
|
||||||
|
album.tracks.forEach((track, index) => {
|
||||||
|
if (track.locked) return;
|
||||||
|
const trackEntry: TrackEntry = {
|
||||||
|
album,
|
||||||
|
index,
|
||||||
|
title: track.title,
|
||||||
|
duration: track.duration,
|
||||||
|
group,
|
||||||
|
haystack: normalize(track.title),
|
||||||
|
};
|
||||||
|
tracks.push(trackEntry);
|
||||||
|
tracksByGroup[group].push(trackEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoriesByGroup = emptyByGroup<Category>();
|
||||||
|
for (const group of GROUPS) {
|
||||||
|
categoriesByGroup[group] = [...categoryMaps[group].values()].sort((a, b) =>
|
||||||
|
a.key.localeCompare(b.key, "de"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { albums, entries, byGroup, categoriesByGroup, tracks, tracksByGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the moment before the library has loaded, and for tests that don't need one. */
|
||||||
|
export const EMPTY_INDEX: SearchIndex = buildSearchIndex([]);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- queries --
|
||||||
|
|
||||||
|
export interface BrowseQuery {
|
||||||
|
index: SearchIndex;
|
||||||
search: string;
|
search: string;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `null` group means unrestricted - typing at the root searches every group at once. */
|
||||||
|
function albumPool(query: BrowseQuery): AlbumEntry[] {
|
||||||
|
return query.group === null ? query.index.entries : query.index.byGroup[query.group];
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackPool(query: BrowseQuery): TrackEntry[] {
|
||||||
|
return query.group === null ? query.index.tracks : query.index.tracksByGroup[query.group];
|
||||||
|
}
|
||||||
|
|
||||||
/** Which of the three lists the browse view is showing. */
|
/** Which of the three lists the browse view is showing. */
|
||||||
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
||||||
if (query.mode === "tracks") return "tracks";
|
if (query.mode === "tracks") return "tracks";
|
||||||
@@ -82,27 +193,17 @@ export function categoryMatches(query: BrowseQuery): Category[] {
|
|||||||
// `group === null` is the bare root screen, rendered as three shelves instead of a
|
// `group === null` is the bare root screen, rendered as three shelves instead of a
|
||||||
// flat category list - each shelf calls this again with its own group filled in.
|
// flat category list - each shelf calls this again with its own group filled in.
|
||||||
if (listMode(query) !== "categories" || query.group === null) return [];
|
if (listMode(query) !== "categories" || query.group === null) return [];
|
||||||
const map = new Map<string, Category>();
|
return query.index.categoriesByGroup[query.group];
|
||||||
for (const album of pool(query.albums, query.group)) {
|
|
||||||
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[] {
|
export function albumMatches(query: BrowseQuery): Album[] {
|
||||||
if (query.mode === "tracks") return [];
|
if (query.mode === "tracks") return [];
|
||||||
const words = searchWords(query.search);
|
const words = searchWords(query.search);
|
||||||
let candidates = pool(query.albums, query.group);
|
|
||||||
if (!words.length && !query.category) return [];
|
if (!words.length && !query.category) return [];
|
||||||
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
let candidates = albumPool(query);
|
||||||
if (!words.length) return candidates;
|
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
|
||||||
return candidates.filter((a) => matchesWords(normalize(a.title + a.artist), words));
|
if (words.length) candidates = candidates.filter((e) => matchesWords(e.haystack, words));
|
||||||
|
return candidates.map((e) => e.album);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
||||||
@@ -112,18 +213,19 @@ export function songMatches(query: BrowseQuery): SongHit[] {
|
|||||||
if (query.mode !== "tracks") return [];
|
if (query.mode !== "tracks") return [];
|
||||||
const words = searchWords(query.search);
|
const words = searchWords(query.search);
|
||||||
const hits: SongHit[] = [];
|
const hits: SongHit[] = [];
|
||||||
for (const album of pool(query.albums, query.group)) {
|
for (const track of trackPool(query)) {
|
||||||
album.tracks.forEach((track, index) => {
|
if (words.length && !matchesWords(track.haystack, words)) continue;
|
||||||
// A locked track has no real title to search by - it shows as a question mark
|
hits.push({
|
||||||
// wherever it appears, so it has nothing useful to match here either.
|
album: track.album,
|
||||||
if (track.locked) return;
|
index: track.index,
|
||||||
if (!words.length || matchesWords(normalize(track.title), words)) {
|
title: track.title,
|
||||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
duration: track.duration,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
// Stopping at the cap rather than filling every hit and slicing is what keeps an
|
||||||
|
// empty query in tracks mode from walking all 4969 tracks for 40 rows.
|
||||||
if (hits.length >= MAX_SONG_HITS) break;
|
if (hits.length >= MAX_SONG_HITS) break;
|
||||||
}
|
}
|
||||||
return hits.slice(0, MAX_SONG_HITS);
|
return hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Results {
|
export interface Results {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { LOW_POWER } from "./lowPower";
|
||||||
import type { Group } from "./search";
|
import type { Group } from "./search";
|
||||||
import type { UiState } from "./keyboard";
|
import type { UiState } from "./keyboard";
|
||||||
|
|
||||||
@@ -32,24 +33,46 @@ export const ROW_SPACING = 25;
|
|||||||
/** Fade the album/category grid in (with a slight upward slide) when a group or
|
/** Fade the album/category grid in (with a slight upward slide) when a group or
|
||||||
* category is entered, and the album modal in when it opens. One animation per
|
* category is entered, and the album modal in when it opens. One animation per
|
||||||
* container, not per card, so cost stays flat regardless of grid size. */
|
* container, not per card, so cost stays flat regardless of grid size. */
|
||||||
export const ANIMATE_VIEW_TRANSITIONS = true;
|
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
|
||||||
|
|
||||||
/** Frost the glass panels/cards/rows with a real backdrop blur. `backdrop-filter` is
|
/** Frost the glass panels/cards/rows with a real backdrop blur.
|
||||||
* one of the most GPU-expensive CSS effects in the app - a live backdrop copy+blur
|
*
|
||||||
* per element, repeated for every album card in the unvirtualized grid - so turn
|
* The obvious thing to cut on weak hardware, and on the Pi that is exactly wrong: it
|
||||||
* this off on weak hardware (e.g. an old Raspberry Pi) to fall back to a plain
|
* measured five times *worse* with the blur off (118% of a core against 25%). The blur
|
||||||
* translucent background with no blur. */
|
* is what promotes each panel to its own compositing layer; without it the animated
|
||||||
|
* canvas and the whole album grid above it share one layer and every canvas frame
|
||||||
|
* repaints all of it. See the table in lib/lowPower.ts before touching this. */
|
||||||
export const SHOW_GLASS_BLUR = true;
|
export const SHOW_GLASS_BLUR = true;
|
||||||
|
|
||||||
/** Render the play view's animated canvas background (gradient + bubbles, both
|
/** Render the play view's animated canvas background (gradient + bubbles, both
|
||||||
* redrawn every frame at 60fps). Off falls back to `.stage`'s own static CSS
|
* redrawn every frame). Off falls back to `.stage`'s own static CSS gradient, which is
|
||||||
* gradient, which is still underneath the canvas either way. */
|
* still underneath the canvas either way - so the screen keeps a background, it just
|
||||||
export const SHOW_AMBIENCE = true;
|
* stops being a per-track one and stops moving.
|
||||||
|
*
|
||||||
|
* Off under `?pi=1`. Halving its resolution and its frame rate first (see
|
||||||
|
* `AMBIENCE_QUALITY`) was not enough on the device this exists for: a requestAnimationFrame
|
||||||
|
* loop that repaints the full viewport is a floor you cannot get under while it runs at
|
||||||
|
* all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
|
||||||
|
* so with this off the browser has nothing to do between one keypress and the next. */
|
||||||
|
export const SHOW_AMBIENCE = !LOW_POWER;
|
||||||
|
|
||||||
|
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
|
||||||
|
* track list - as opposed to the handful of panels wrapped around them.
|
||||||
|
*
|
||||||
|
* Separate from `SHOW_GLASS_BLUR` because the two behave nothing alike, and lumping
|
||||||
|
* them together is what made the first attempt at a Pi profile five times slower. A
|
||||||
|
* panel is one live backdrop copy that buys its whole subtree a compositing layer. A
|
||||||
|
* card is one live backdrop copy out of three hundred sitting directly over the
|
||||||
|
* animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with
|
||||||
|
* a full album grid on screen, Chromium: see lib/lowPower.ts. */
|
||||||
|
export const SHOW_CARD_BLUR = !LOW_POWER;
|
||||||
|
|
||||||
/** Run the purely decorative CSS animation loops: the room page's bubble field and
|
/** Run the purely decorative CSS animation loops: the room page's bubble field and
|
||||||
* the dolphin mascot's bob/swim. Individually cheap (opacity/transform only) but
|
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
|
||||||
* free to cut on weak hardware. */
|
* noise, but "nothing on this screen moves by itself" is a property worth having
|
||||||
export const SHOW_DECORATIVE_ANIMATIONS = true;
|
* outright rather than a sum of small wins - a compositor with no animation to service
|
||||||
|
* has nothing to wake up for. */
|
||||||
|
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
|
||||||
|
|
||||||
// ------------------------------------------------------------------ colors --
|
// ------------------------------------------------------------------ colors --
|
||||||
|
|
||||||
@@ -85,7 +108,10 @@ export const TAB_RAIL_CLEARANCE = 88;
|
|||||||
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
|
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
|
||||||
* other two always show. */
|
* other two always show. */
|
||||||
export const PAGE_ICON: Record<UiState["page"], string> = {
|
export const PAGE_ICON: Record<UiState["page"], string> = {
|
||||||
music: "🎵",
|
// A plain Unicode symbol, not the 🎵 emoji: that glyph's own built-in colour is a
|
||||||
|
// muted grey-blue on at least one real platform, which is illegible against the tab
|
||||||
|
// rail's own glass background and does not respond to `color` the way text does.
|
||||||
|
music: "♪",
|
||||||
room: "💡",
|
room: "💡",
|
||||||
typing: "⌨️",
|
typing: "⌨️",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ describe("grade", () => {
|
|||||||
expect(grade(state).errors).toBe(1);
|
expect(grade(state).errors).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("awards pearls even for a bad run", () => {
|
|
||||||
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stars", () => {
|
describe("stars", () => {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { focusKeyFor, mastery } from "../progress";
|
import { focusKeyFor, mastery, progressFromApi } from "../progress";
|
||||||
import type { Progress } from "../progress";
|
import type { Progress } from "../progress";
|
||||||
|
import type { TippenProgress as ApiProgress } from "../../../api/types";
|
||||||
|
|
||||||
function basicProgress(over: Partial<Progress> = {}): Progress {
|
function basicProgress(over: Partial<Progress> = {}): Progress {
|
||||||
return {
|
return {
|
||||||
lessons: {},
|
lessons: {},
|
||||||
keyStats: {},
|
keyStats: {},
|
||||||
pearls: 0,
|
|
||||||
aquarium: [],
|
aquarium: [],
|
||||||
streak: { days: 0, lastPlayed: null },
|
streak: { days: 0, lastPlayed: null },
|
||||||
settings: { sound: true, keyboardHint: "auto" },
|
settings: { sound: true, keyboardHint: "auto" },
|
||||||
@@ -65,3 +65,26 @@ describe("mastery", () => {
|
|||||||
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
|
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("progressFromApi", () => {
|
||||||
|
/** `earned` was silently dropped here once, which is not a visible bug anywhere except
|
||||||
|
* on the lesson map, where it decides whether a reward's treasure chest is drawn open.
|
||||||
|
* A dropped field fails no type check - the mapping is written out by hand - so it
|
||||||
|
* needs a test that names it. */
|
||||||
|
it("keeps the backend's derived `earned` flag", () => {
|
||||||
|
const api = {
|
||||||
|
lessons: {
|
||||||
|
won: { unlocked: true, runs: 3, best_stars: 3, best_animal: "delfin", best_points: 9, earned: true, ghost: null },
|
||||||
|
open: { unlocked: true, runs: 1, best_stars: 1, best_animal: "krabbe", best_points: 2, earned: false, ghost: null },
|
||||||
|
},
|
||||||
|
key_stats: {},
|
||||||
|
aquarium: [],
|
||||||
|
streak: { days: 2, last_played: null },
|
||||||
|
settings: { sound: true, keyboard_hint: "auto" },
|
||||||
|
} as unknown as ApiProgress;
|
||||||
|
|
||||||
|
const progress = progressFromApi(api);
|
||||||
|
expect(progress.lessons.won?.earned).toBe(true);
|
||||||
|
expect(progress.lessons.open?.earned).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
import type { CreatureId } from "./aquarium";
|
import type { CreatureId } from "./aquarium";
|
||||||
|
|
||||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||||
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
export type ModeId = "dive" | "bubbles" | "feed" | "race";
|
||||||
|
|
||||||
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
|
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
|
||||||
* `resolved: false` means the curriculum names a path that matches nothing right now. */
|
* `resolved: false` means the curriculum names a path that matches nothing right now. */
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
||||||
* was typed, so a half-finished bubbles round still earns its pearls. */
|
* was typed, so a half-finished bubbles round still earns its stars. */
|
||||||
export function abandonRun(state: RunState, now: number): RunState {
|
export function abandonRun(state: RunState, now: number): RunState {
|
||||||
if (isFinished(state) || state.startedAt === null) return state;
|
if (isFinished(state) || state.startedAt === null) return state;
|
||||||
return { ...state, finishedAt: now };
|
return { ...state, finishedAt: now };
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function wordChunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The line a lesson should show, given what it can spell. Word lessons alternate:
|
/** The line a lesson should show, given what it can spell. Word lessons alternate:
|
||||||
* `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is
|
* `preferWords` lets a mode ask for letters even in a late lesson (bubbles mode is
|
||||||
* always single letters) or for words wherever they exist (feed mode). */
|
* always single letters) or for words wherever they exist (feed mode). */
|
||||||
export function lineFor(
|
export function lineFor(
|
||||||
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
|
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
|
||||||
@@ -222,8 +222,8 @@ export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): n
|
|||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from
|
/** Single letters for bubbles mode: one key per bubble, drawn from the same bag, so the
|
||||||
* the same bag, so the arcade modes drill the same spread as the dive mode. */
|
* arcade mode drills the same spread as the dive mode. */
|
||||||
export function letterStream(
|
export function letterStream(
|
||||||
activeKeys: readonly string[],
|
activeKeys: readonly string[],
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export interface RunResult {
|
|||||||
animal: AnimalId;
|
animal: AnimalId;
|
||||||
/** Whether this run unlocks the next lesson on its own. */
|
/** Whether this run unlocks the next lesson on its own. */
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
/** Pearls earned - the aquarium currency. */
|
|
||||||
pearls: number;
|
|
||||||
/** Kept for the race-mode ghost and the per-key stats. */
|
/** Kept for the race-mode ghost and the per-key stats. */
|
||||||
strokes: readonly Stroke[];
|
strokes: readonly Stroke[];
|
||||||
}
|
}
|
||||||
@@ -162,12 +160,6 @@ export function isPassed(accuracy: number): boolean {
|
|||||||
return accuracy >= STAR_THRESHOLDS.two;
|
return accuracy >= STAR_THRESHOLDS.two;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
|
|
||||||
* every single run, including a bad one - the aquarium should never stall. */
|
|
||||||
function pearlsFor(characters: number, stars: number): number {
|
|
||||||
return Math.floor(characters / 5) + stars * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function grade(state: RunState): RunResult {
|
export function grade(state: RunState): RunResult {
|
||||||
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
||||||
const errors = state.missed.size;
|
const errors = state.missed.size;
|
||||||
@@ -196,7 +188,6 @@ export function grade(state: RunState): RunResult {
|
|||||||
stars,
|
stars,
|
||||||
animal: animalFor(points).id,
|
animal: animalFor(points).id,
|
||||||
passed: isPassed(accuracy),
|
passed: isPassed(accuracy),
|
||||||
pearls: pearlsFor(characters, stars),
|
|
||||||
strokes: state.strokes,
|
strokes: state.strokes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { ModeId } from "./curriculum";
|
|||||||
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
||||||
dive: { emoji: "🤿", name: "Tauchgang" },
|
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||||
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||||
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
|
|
||||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,3 +54,114 @@ export function playFanfare(): void {
|
|||||||
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
|
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One scheduled note of a tune. `at`/`duration` are seconds relative to the tune's
|
||||||
|
* own start, so a melody reads as a score rather than as nested `setTimeout`s - which
|
||||||
|
* also keeps the notes sample-accurate against each other instead of drifting by
|
||||||
|
* however late the event loop happened to be. */
|
||||||
|
interface Note {
|
||||||
|
/** Hz. */
|
||||||
|
hz: number;
|
||||||
|
at: number;
|
||||||
|
duration: number;
|
||||||
|
gain?: number;
|
||||||
|
type?: OscillatorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTune(notes: readonly Note[]): void {
|
||||||
|
try {
|
||||||
|
context ??= new AudioContext();
|
||||||
|
// Resuming matters here specifically: this tune plays at the end of a run, and on
|
||||||
|
// some browsers the context created during the run's first keystroke is suspended
|
||||||
|
// again by the time the result screen opens.
|
||||||
|
void context.resume?.();
|
||||||
|
const start = context.currentTime + 0.02;
|
||||||
|
for (const note of notes) {
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const gain = context.createGain();
|
||||||
|
const peak = note.gain ?? 0.12;
|
||||||
|
const from = start + note.at;
|
||||||
|
oscillator.type = note.type ?? "triangle";
|
||||||
|
oscillator.frequency.setValueAtTime(note.hz, from);
|
||||||
|
// A short attack rather than an instant one: a hard start on a triangle wave
|
||||||
|
// clicks, and a click in a reward jingle sounds like a fault.
|
||||||
|
gain.gain.setValueAtTime(0.0001, from);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(peak, from + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, from + note.duration);
|
||||||
|
oscillator.connect(gain).connect(context.destination);
|
||||||
|
oscillator.start(from);
|
||||||
|
oscillator.stop(from + note.duration + 0.02);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Same as `blip`: no audio context, no sound, never an exception.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const C5 = 523.25;
|
||||||
|
const D5 = 587.33;
|
||||||
|
const E5 = 659.25;
|
||||||
|
const F5 = 698.46;
|
||||||
|
const G5 = 783.99;
|
||||||
|
const A5 = 880;
|
||||||
|
const C6 = 1046.5;
|
||||||
|
const E6 = 1318.5;
|
||||||
|
const G6 = 1568;
|
||||||
|
const C4 = 261.63;
|
||||||
|
const E4 = 329.63;
|
||||||
|
const G4 = 392;
|
||||||
|
const G3 = 196;
|
||||||
|
|
||||||
|
/** The unlock tune: about two seconds of unambiguous "you got something".
|
||||||
|
*
|
||||||
|
* Deliberately a whole melody rather than one more blip. `playFanfare` already marks
|
||||||
|
* every smaller win in this app - a new lesson, a new animal, a new aquarium creature -
|
||||||
|
* so if opening real music sounded like that too, the biggest reward in the game would
|
||||||
|
* be the one thing she could not hear coming. This one is longer, has a bass line under
|
||||||
|
* it and lands on a held major chord, which is what makes it read as an arrival.
|
||||||
|
*
|
||||||
|
* C major throughout: a rising C-E-G-C run, a little D-E-F-G turn over it, then the
|
||||||
|
* tonic triad plus its octave held together over a low C. */
|
||||||
|
export function playRewardJingle(): void {
|
||||||
|
playTune([
|
||||||
|
// The run up.
|
||||||
|
{ hz: C5, at: 0, duration: 0.16 },
|
||||||
|
{ hz: E5, at: 0.12, duration: 0.16 },
|
||||||
|
{ hz: G5, at: 0.24, duration: 0.16 },
|
||||||
|
{ hz: C6, at: 0.36, duration: 0.26 },
|
||||||
|
// The turn - the bit that makes it a tune instead of an arpeggio.
|
||||||
|
{ hz: A5, at: 0.62, duration: 0.13 },
|
||||||
|
{ hz: G5, at: 0.74, duration: 0.13 },
|
||||||
|
{ hz: A5, at: 0.86, duration: 0.13 },
|
||||||
|
{ hz: C6, at: 0.98, duration: 0.22 },
|
||||||
|
// The arrival: a held triad, with the bass under it.
|
||||||
|
{ hz: C5, at: 1.24, duration: 0.95, gain: 0.1 },
|
||||||
|
{ hz: E5, at: 1.24, duration: 0.95, gain: 0.09 },
|
||||||
|
{ hz: G5, at: 1.24, duration: 0.95, gain: 0.09 },
|
||||||
|
{ hz: C6, at: 1.24, duration: 0.95, gain: 0.08 },
|
||||||
|
{ hz: C4, at: 1.24, duration: 1, gain: 0.09, type: "sine" },
|
||||||
|
{ hz: G3, at: 1.24, duration: 1, gain: 0.07, type: "sine" },
|
||||||
|
// Sparkles over the held chord, as the cover comes out of the chest.
|
||||||
|
{ hz: E6, at: 1.4, duration: 0.2, gain: 0.05, type: "sine" },
|
||||||
|
{ hz: G6, at: 1.56, duration: 0.2, gain: 0.045, type: "sine" },
|
||||||
|
{ hz: C6 * 2, at: 1.72, duration: 0.3, gain: 0.04, type: "sine" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chest landing on the screen, just before its lid opens: two low thuds. Pitched
|
||||||
|
* well below the jingle so it reads as a thing arriving, not as a note. */
|
||||||
|
export function playChestThud(): void {
|
||||||
|
playTune([
|
||||||
|
{ hz: 150, at: 0, duration: 0.12, gain: 0.1, type: "sine" },
|
||||||
|
{ hz: 110, at: 0.14, duration: 0.16, gain: 0.09, type: "sine" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lid coming open: a bright upward creak-and-pop. */
|
||||||
|
export function playChestOpen(): void {
|
||||||
|
playTune([
|
||||||
|
{ hz: G4, at: 0, duration: 0.1, gain: 0.07 },
|
||||||
|
{ hz: E4 * 2, at: 0.06, duration: 0.12, gain: 0.07 },
|
||||||
|
{ hz: D5 * 2, at: 0.13, duration: 0.14, gain: 0.06 },
|
||||||
|
{ hz: F5 * 2, at: 0.2, duration: 0.2, gain: 0.05, type: "sine" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||