Typing lessons like duolingo & musicmouse cleanup

This commit is contained in:
2026-09-12 18:58:02 +02:00
parent 498243af46
commit a5210fead2
50 changed files with 3074 additions and 710 deletions

View File

@@ -297,13 +297,17 @@ def _build_app(
return app return app
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
return next((s for s in services if isinstance(s, kind)), None)
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None: def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
"""The web front-end's own hub, if it is running - so open tabs refetch the library """The web front-end's own hub, if it is running - so open tabs refetch the library
as background analysis lands, instead of only after a manual reload. `None` when as background analysis lands, instead of only after a manual reload. `None` when
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
tell". tell".
""" """
web_service = next((s for s in services if isinstance(s, WebService)), None) web_service = _service_of_type(services, WebService)
return web_service.hub.broadcast_library if web_service else None return web_service.hub.broadcast_library if web_service else None
@@ -339,7 +343,7 @@ def _build_services(
# Unconditional: a show only starts downloading once someone drops a `feed.txt` # Unconditional: a show only starts downloading once someone drops a `feed.txt`
# into its folder, so there is nothing to gate here with its own config section. # into its folder, so there is nothing to gate here with its own config section.
web_service = next((s for s in services if isinstance(s, WebService)), None) web_service = _service_of_type(services, WebService)
services.append( services.append(
PodcastFeedService( PodcastFeedService(
app.library, app.library,

View File

@@ -12,6 +12,7 @@ from musicmouse.config import Config, FigureColors
from musicmouse.devices.mouse import MusicMouseDevice from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player from musicmouse.devices.player import Player
from musicmouse.library import MusicLibrary from musicmouse.library import MusicLibrary
from musicmouse.library.models import Album
from musicmouse.media import Playlist from musicmouse.media import Playlist
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -55,6 +56,10 @@ class App:
_log.warning("No playlist for figure %r", figure) _log.warning("No playlist for figure %r", figure)
return playlist return playlist
def album_for(self, playlist: Playlist | None) -> Album | None:
"""The library album a playlist came from, if any."""
return self.library.get(playlist.album_id if playlist else None)
async def rescan_library( async def rescan_library(
self, *, broadcast: Callable[[], Awaitable[None]] | None = None self, *, broadcast: Callable[[], Awaitable[None]] | None = None
) -> None: ) -> None:

View File

@@ -267,6 +267,8 @@ class VlcPlayer(PlayerBase):
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist)) _log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
def play(self) -> None: def play(self) -> None:
if self._playing:
return
self._list_player.play() self._list_player.play()
def play_from_start(self) -> None: def play_from_start(self) -> None:
@@ -279,6 +281,8 @@ class VlcPlayer(PlayerBase):
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1))) self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
def pause(self) -> None: def pause(self) -> None:
if not self._playing:
return
self._media_player.set_pause(1) self._media_player.set_pause(1)
def stop(self) -> None: def stop(self) -> None:

View File

@@ -41,7 +41,7 @@ __all__ = [
"PlaySeriesLatestRequested", "PlaySeriesLatestRequested",
"PlaybackChanged", "PlaybackChanged",
"PlaylistFinished", "PlaylistFinished",
"PrevTrackRequested", "PreviousTrackRequested",
"RfidTokenRead", "RfidTokenRead",
"RotaryTurned", "RotaryTurned",
"SetVolumeRequested", "SetVolumeRequested",
@@ -135,8 +135,6 @@ class RotaryTurned(InputEvent):
class PlaylistFinished(InputEvent): class PlaylistFinished(InputEvent):
"""The player reached the end of the playlist.""" """The player reached the end of the playlist."""
figure: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class DeviceConnected(InputEvent): class DeviceConnected(InputEvent):
@@ -168,7 +166,7 @@ class NextTrackRequested(IntentEvent):
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class PrevTrackRequested(IntentEvent): class PreviousTrackRequested(IntentEvent):
pass pass

View File

@@ -74,7 +74,7 @@ def web_playback(event: PlaybackChanged, app: App) -> None:
off_animation(app) off_animation(app)
return return
album = app.library.get(event.playlist.album_id if event.playlist else None) album = app.album_for(event.playlist)
if album is None: if album is None:
return return
web_animation(app, parse_color(album.colors[0])) web_animation(app, parse_color(album.colors[0]))

View File

@@ -20,7 +20,7 @@ from musicmouse.events import (
PlaylistFinished, PlaylistFinished,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PrevTrackRequested, PreviousTrackRequested,
RotaryTurned, RotaryTurned,
SeekRequested, SeekRequested,
SetVolumeRequested, SetVolumeRequested,
@@ -118,7 +118,7 @@ def button_pressed(event: ButtonEvent, app: App) -> None:
if event.action is not ButtonAction.PRESSED: if event.action is not ButtonAction.PRESSED:
return return
if event.button is Button.LEFT and app.player.is_playing: if event.button is Button.LEFT and app.player.is_playing:
app.bus.emit(PrevTrackRequested(source="device")) app.bus.emit(PreviousTrackRequested(source="device"))
elif event.button is Button.RIGHT and app.player.is_playing: elif event.button is Button.RIGHT and app.player.is_playing:
app.bus.emit(NextTrackRequested(source="device")) app.bus.emit(NextTrackRequested(source="device"))
# The rotary press is published to Home Assistant by the MQTT service; what it # The rotary press is published to Home Assistant by the MQTT service; what it
@@ -143,8 +143,8 @@ def next_track(event: NextTrackRequested, app: App) -> None:
app.player.next_track() app.player.next_track()
@on(PrevTrackRequested) @on(PreviousTrackRequested)
def previous_track(event: PrevTrackRequested, app: App) -> None: def previous_track(event: PreviousTrackRequested, app: App) -> None:
_log.debug("Previous track (%s)", event.source) _log.debug("Previous track (%s)", event.source)
app.player.previous_track() app.player.previous_track()

View File

@@ -23,7 +23,7 @@ from musicmouse.events import (
PlayAlbumRequested, PlayAlbumRequested,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PrevTrackRequested, PreviousTrackRequested,
SetVolumeRequested, SetVolumeRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
@@ -40,8 +40,8 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
# pauses, like its pause button. # pauses, like its pause button.
"KEY_PAUSE": lambda: PauseRequested(source="lirc"), "KEY_PAUSE": lambda: PauseRequested(source="lirc"),
"KEY_STOP": lambda: PauseRequested(source="lirc"), "KEY_STOP": lambda: PauseRequested(source="lirc"),
"KEY_PREVIOUS": lambda: PrevTrackRequested(source="lirc"), "KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
"KEY_REWIND": lambda: PrevTrackRequested(source="lirc"), "KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"), "KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
} }

View File

@@ -13,6 +13,7 @@ from typing import Any, ClassVar
from musicmouse.bus import EventBus from musicmouse.bus import EventBus
from musicmouse.config import MqttConfig from musicmouse.config import MqttConfig
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player from musicmouse.devices.player import Player
from musicmouse.events import ( from musicmouse.events import (
ActiveFigureChanged, ActiveFigureChanged,
@@ -22,7 +23,7 @@ from musicmouse.events import (
PauseRequested, PauseRequested,
PlaybackChanged, PlaybackChanged,
PlayRequested, PlayRequested,
PrevTrackRequested, PreviousTrackRequested,
SetVolumeRequested, SetVolumeRequested,
TrackChanged, TrackChanged,
VolumeChanged, VolumeChanged,
@@ -37,9 +38,11 @@ __all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"]
class PlayerSensor(Entity): class PlayerSensor(Entity):
component = "sensor" component = "sensor"
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None: def __init__(
self, bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> None:
self.mouse = mouse
self.player = player self.player = player
self._figure: str | None = None
super().__init__(bus, config, object_id="player", name="Music Mouse Player") super().__init__(bus, config, object_id="player", name="Music Mouse Player")
def discovery_payload(self) -> dict[str, Any]: def discovery_payload(self) -> dict[str, Any]:
@@ -56,9 +59,7 @@ class PlayerSensor(Entity):
for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged): for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged):
self.bus.subscribe(event_type, self._on_change) self.bus.subscribe(event_type, self._on_change)
async def _on_change(self, event: Event) -> None: async def _on_change(self, _event: Event) -> None:
if isinstance(event, ActiveFigureChanged):
self._figure = event.figure
await self.publish_state() await self.publish_state()
async def publish_state(self) -> None: async def publish_state(self) -> None:
@@ -68,7 +69,7 @@ class PlayerSensor(Entity):
await self.publish( await self.publish(
f"{self.base_topic}/attributes", f"{self.base_topic}/attributes",
{ {
"figure": self._figure, "figure": self.mouse.active_figure,
"playlist": playlist.name if playlist else None, "playlist": playlist.name if playlist else None,
"track_index": self.player.track_index, "track_index": self.player.track_index,
"track_count": len(playlist) if playlist else 0, "track_count": len(playlist) if playlist else 0,
@@ -128,7 +129,7 @@ class TransportButton(Entity):
#: Button object id -> the intent pressing it emits. #: Button object id -> the intent pressing it emits.
INTENTS: ClassVar[dict[str, type[IntentEvent]]] = { INTENTS: ClassVar[dict[str, type[IntentEvent]]] = {
"next": NextTrackRequested, "next": NextTrackRequested,
"previous": PrevTrackRequested, "previous": PreviousTrackRequested,
"play": PlayRequested, "play": PlayRequested,
"pause": PauseRequested, "pause": PauseRequested,
} }
@@ -154,7 +155,9 @@ class TransportButton(Entity):
self.bus.emit(self.INTENTS[self.action](source="mqtt")) self.bus.emit(self.INTENTS[self.action](source="mqtt"))
def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[Entity]: def player_entities(
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> list[Entity]:
names = { names = {
"next": "Music Mouse Next", "next": "Music Mouse Next",
"previous": "Music Mouse Previous", "previous": "Music Mouse Previous",
@@ -162,7 +165,7 @@ def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[E
"pause": "Music Mouse Pause", "pause": "Music Mouse Pause",
} }
return [ return [
PlayerSensor(bus, config, player), PlayerSensor(bus, config, mouse, player),
VolumeNumber(bus, config, player), VolumeNumber(bus, config, player),
*(TransportButton(bus, config, action, name) for action, name in names.items()), *(TransportButton(bus, config, action, name) for action, name in names.items()),
] ]

View File

@@ -41,7 +41,7 @@ def build_entities(
"""Everything this backend exposes to Home Assistant.""" """Everything this backend exposes to Home Assistant."""
return [ return [
*(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone), *(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone),
*player_entities(bus, config, player), *player_entities(bus, config, mouse, player),
*trigger_entities(bus, config), *trigger_entities(bus, config),
] ]

View File

@@ -27,7 +27,7 @@ from musicmouse.events import (
PauseRequested, PauseRequested,
PlayAlbumRequested, PlayAlbumRequested,
PlayRequested, PlayRequested,
PrevTrackRequested, PreviousTrackRequested,
SeekRequested, SeekRequested,
SetVolumeRequested, SetVolumeRequested,
) )
@@ -152,7 +152,7 @@ def build_router(
@router.post("/previous", status_code=204) @router.post("/previous", status_code=204)
def previous_track() -> Response: def previous_track() -> Response:
return emit(PrevTrackRequested(source="web")) return emit(PreviousTrackRequested(source="web"))
@router.post("/seek", status_code=204) @router.post("/seek", status_code=204)
def seek(body: SeekIn) -> Response: def seek(body: SeekIn) -> Response:

View File

@@ -34,6 +34,8 @@ __all__ = [
def to_device_volume(percent: int, general: GeneralConfig) -> int: def to_device_volume(percent: int, general: GeneralConfig) -> int:
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``.""" """Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
span = general.max_volume - general.min_volume span = general.max_volume - general.min_volume
if span <= 0:
return general.min_volume
return general.min_volume + round(span * max(0, min(100, percent)) / 100) return general.min_volume + round(span * max(0, min(100, percent)) / 100)

View File

@@ -17,7 +17,7 @@ __all__ = ["snapshot"]
def snapshot(app: App) -> PlayerStateOut: def snapshot(app: App) -> PlayerStateOut:
player = app.player player = app.player
playlist = player.playlist playlist = player.playlist
album = app.library.get(playlist.album_id if playlist else None) album = app.album_for(playlist)
index = player.track_index index = player.track_index
# The player only knows a file path, and a tagged file's name is rarely its title # The player only knows a file path, and a tagged file's name is rarely its title
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time. # ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.

View File

@@ -26,7 +26,7 @@ from musicmouse.events import (
PauseRequested, PauseRequested,
PlayAlbumRequested, PlayAlbumRequested,
PlayRequested, PlayRequested,
PrevTrackRequested, PreviousTrackRequested,
RfidTokenRead, RfidTokenRead,
RotaryTurned, RotaryTurned,
SeekRequested, SeekRequested,
@@ -164,7 +164,7 @@ class SimulatorDriver:
await self.settle() await self.settle()
async def emit_prev(self) -> None: async def emit_prev(self) -> None:
self.app.bus.emit(PrevTrackRequested(source="simulator")) self.app.bus.emit(PreviousTrackRequested(source="simulator"))
await self.settle() await self.settle()
async def set_volume(self, volume: int) -> None: async def set_volume(self, volume: int) -> None:
@@ -235,9 +235,7 @@ class SimulatorDriver:
case "playlist": case "playlist":
return player.playlist.name if player.playlist else "none" return player.playlist.name if player.playlist else "none"
case "album": case "album":
album = self.app.library.get( album = self.app.album_for(player.playlist)
player.playlist.album_id if player.playlist else None
)
return album.title if album else "none" return album.title if album else "none"
case "position": case "position":
return f"{player.position:.1f}" return f"{player.position:.1f}"

View File

@@ -42,7 +42,6 @@ class FakePlayer(PlayerBase):
self._remaining = track_duration self._remaining = track_duration
self._started_at: float | None = None self._started_at: float | None = None
self._timer: asyncio.Task[None] | None = None self._timer: asyncio.Task[None] | None = None
self.closed = False
# -------------------------------------------------------------------- state # -------------------------------------------------------------------- state
@@ -112,12 +111,8 @@ class FakePlayer(PlayerBase):
if self._playing: if self._playing:
self._start_timer() self._start_timer()
async def run(self) -> None:
return
def close(self) -> None: def close(self) -> None:
self._cancel_timer() self._cancel_timer()
self.closed = True
# ---------------------------------------------------------------- internals # ---------------------------------------------------------------- internals

View File

@@ -20,7 +20,7 @@ from musicmouse.events import (
PlayAlbumRequested, PlayAlbumRequested,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PrevTrackRequested, PreviousTrackRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
from musicmouse.services.lirc import LircService from musicmouse.services.lirc import LircService
@@ -160,7 +160,7 @@ async def test_transport_and_volume_buttons_emit_intents(
PlayRequested, PlayRequested,
PauseRequested, PauseRequested,
PauseRequested, PauseRequested,
PrevTrackRequested, PreviousTrackRequested,
NextTrackRequested, NextTrackRequested,
VolumeChangeRequested, VolumeChangeRequested,
] ]

View File

@@ -419,14 +419,18 @@ async def test_transport_buttons_emit_intents(
async def test_the_player_sensor_reports_the_current_track( async def test_the_player_sensor_reports_the_current_track(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher bus: EventBus,
mqtt_config: MqttConfig,
mouse: MusicMouseDevice,
player: FakePlayer,
publisher: RecordingPublisher,
) -> None: ) -> None:
from pathlib import Path from pathlib import Path
from musicmouse.media import Playlist, Track from musicmouse.media import Playlist, Track
from musicmouse.services.mqtt.player import PlayerSensor from musicmouse.services.mqtt.player import PlayerSensor
entity = PlayerSensor(bus, mqtt_config, player) entity = PlayerSensor(bus, mqtt_config, mouse, player)
entity.attach(publisher) entity.attach(publisher)
player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),))) player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),)))
player.play_from_start() player.play_from_start()
@@ -439,6 +443,29 @@ async def test_the_player_sensor_reports_the_current_track(
assert attributes["volume"] == 40 assert attributes["volume"] == 40
async def test_the_player_sensor_reports_the_mouse_s_active_figure(
bus: EventBus,
mqtt_config: MqttConfig,
transport: FakeTransport,
mouse: MusicMouseDevice,
player: FakePlayer,
publisher: RecordingPublisher,
) -> None:
"""The sensor reads ``mouse.active_figure`` rather than keeping its own copy of it,
so it agrees with the mouse even if it missed the event that changed it."""
from musicmouse.services.mqtt.player import PlayerSensor
entity = PlayerSensor(bus, mqtt_config, mouse, player)
entity.attach(publisher)
transport.inject(RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"))
await bus.drain()
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
assert attributes["figure"] == "fuchs"
assert mouse.active_figure == "fuchs"
# --------------------------------------------------------------------- triggers # --------------------------------------------------------------------- triggers

View File

@@ -334,20 +334,76 @@ class _FakeMedia:
class _FakeMediaPlayer: class _FakeMediaPlayer:
"""Just enough of libVLC's media player to exercise index syncing.""" """Just enough of libVLC's media player to exercise index syncing, pause/seek and
the volume/playing poll."""
def __init__(self) -> None: def __init__(self) -> None:
self.media: _FakeMedia | None = None self.media: _FakeMedia | None = None
self.paused = False
self.time_ms = 0
self.length_ms = 0
self.volume = 50
def get_media(self) -> _FakeMedia | None: def get_media(self) -> _FakeMedia | None:
return self.media return self.media
def set_pause(self, flag: int) -> None:
self.paused = bool(flag)
def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer: def set_time(self, ms: int) -> None:
self.time_ms = ms
def get_time(self) -> int:
return self.time_ms
def get_length(self) -> int:
return self.length_ms
def audio_set_volume(self, volume: int) -> None:
self.volume = volume
def audio_get_volume(self) -> int:
return self.volume
class _FakeListPlayer:
"""Just enough of libVLC's list player to exercise play/stop and the is-playing
half of the poll."""
def __init__(self) -> None:
self.playing = False
self.play_calls = 0
self.stop_calls = 0
self.played_index: int | None = None
def play(self) -> None:
self.play_calls += 1
self.playing = True
def stop(self) -> None:
self.stop_calls += 1
self.playing = False
def is_playing(self) -> bool:
return self.playing
def play_item_at_index(self, index: int) -> None:
self.played_index = index
self.playing = True
def _vlc_like(
bus: EventBus,
playlist: Playlist,
*,
media_player: _FakeMediaPlayer | None = None,
list_player: _FakeListPlayer | None = None,
) -> VlcPlayer:
"""A VlcPlayer with its libVLC parts stubbed out, without calling __init__.""" """A VlcPlayer with its libVLC parts stubbed out, without calling __init__."""
player = object.__new__(VlcPlayer) player = object.__new__(VlcPlayer)
PlayerBase.__init__(player, bus) PlayerBase.__init__(player, bus)
player._media_player = _FakeMediaPlayer() # type: ignore[assignment] player._media_player = media_player or _FakeMediaPlayer()
player._list_player = list_player or _FakeListPlayer()
player._playlist = playlist player._playlist = playlist
player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)} player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)}
return player return player
@@ -365,7 +421,7 @@ async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> Non
events: list[Event] = [] events: list[Event] = []
bus.subscribe(TrackChanged, events.append) bus.subscribe(TrackChanged, events.append)
player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined] player._media_player.media = _FakeMedia("file:///music/2.mp3")
player._on_next_item(object()) player._on_next_item(object())
await bus.drain() await bus.drain()
@@ -379,10 +435,193 @@ async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None:
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3))) playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
player = _vlc_like(bus, playlist) player = _vlc_like(bus, playlist)
player._media_player.media = None # type: ignore[attr-defined] player._media_player.media = None
player._on_next_item(object()) player._on_next_item(object())
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined] player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3")
player._on_next_item(object()) player._on_next_item(object())
await bus.drain() await bus.drain()
assert player.track_index == 0 assert player.track_index == 0
# ------------------------------------------------------ VlcPlayer actions, stubbed
async def test_vlc_play_starts_playback_when_idle(bus: EventBus) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(), list_player=list_player)
player.play()
assert list_player.play_calls == 1
async def test_vlc_play_is_a_no_op_once_already_playing(bus: EventBus) -> None:
"""Mirrors `FakePlayer.play()`'s own guard, so the two `Player` implementations
agree on what a repeated play() does instead of leaving it to libVLC."""
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(), list_player=list_player)
player._set_playing(True)
player.play()
assert list_player.play_calls == 0
async def test_vlc_pause_pauses_when_playing(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player._set_playing(True)
player.pause()
assert media_player.paused is True
async def test_vlc_pause_is_a_no_op_when_not_playing(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.pause()
assert media_player.paused is False
async def test_vlc_play_track_clamps_to_the_playlist_bounds(bus: EventBus) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(count=3), list_player=list_player)
player.play_track(99)
assert list_player.played_index == 2
async def test_vlc_play_track_on_an_empty_playlist_does_nothing(
bus: EventBus, caplog: pytest.LogCaptureFixture
) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, Playlist(name="leer", tracks=()), list_player=list_player)
with caplog.at_level("WARNING"):
player.play_track(0)
assert list_player.played_index is None
assert "playlist is empty" in caplog.text
async def test_vlc_seek_sets_the_time_in_milliseconds(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.seek(12.5)
assert media_player.time_ms == 12500
async def test_vlc_seek_clamps_negative_positions_to_zero(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.seek(-5.0)
assert media_player.time_ms == 0
async def test_vlc_position_and_duration_read_the_media_player(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
media_player.time_ms = 4_000
media_player.length_ms = 180_000
player = _vlc_like(bus, playlist(), media_player=media_player)
assert player.position == 4.0
assert player.duration == 180.0
async def test_vlc_position_floors_libvlcs_minus_one_at_zero(bus: EventBus) -> None:
"""libVLC reports -1 for both until a media is actually opened."""
media_player = _FakeMediaPlayer()
media_player.time_ms = -1
media_player.length_ms = -1
player = _vlc_like(bus, playlist(), media_player=media_player)
assert player.position == 0.0
assert player.duration == 0.0
async def test_vlc_apply_volume_pushes_to_the_media_player(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
player.set_volume(77)
assert media_player.volume == 77
async def test_vlc_poll_announces_a_volume_change_made_outside_the_app(
bus: EventBus, seen: list[Event]
) -> None:
"""The physical volume can move without going through `set_volume` - a hardware
knob on the amp, or another process - so the poll is what catches libVLC drifting
from what `PlayerBase` last announced."""
media_player = _FakeMediaPlayer()
media_player.volume = 50
player = _vlc_like(bus, playlist(), media_player=media_player)
media_player.volume = 65
player._poll()
await bus.drain()
assert player.volume == 65
assert only(seen, VolumeChanged) == [VolumeChanged(volume=65, source="player")]
async def test_vlc_poll_ignores_a_negative_volume_reading(bus: EventBus) -> None:
media_player = _FakeMediaPlayer()
player = _vlc_like(bus, playlist(), media_player=media_player)
media_player.volume = -1
player._poll()
assert player.volume == 50
async def test_vlc_poll_syncs_is_playing_from_the_list_player(
bus: EventBus, seen: list[Event]
) -> None:
list_player = _FakeListPlayer()
player = _vlc_like(bus, playlist(), list_player=list_player)
list_player.playing = True
player._poll()
await bus.drain()
assert player.is_playing
assert only(seen, PlaybackChanged)[-1].playing is True
async def test_vlc_on_playing_and_on_stopped_announce_playback_changed(
bus: EventBus, seen: list[Event]
) -> None:
player = _vlc_like(bus, playlist())
player._on_playing(object())
await bus.drain()
assert player.is_playing
player._on_stopped(object())
await bus.drain()
assert not player.is_playing
assert [e.playing for e in only(seen, PlaybackChanged)] == [True, False]
async def test_vlc_on_playlist_end_stops_and_announces_playlist_finished(
bus: EventBus, seen: list[Event]
) -> None:
player = _vlc_like(bus, playlist())
player._set_playing(True)
player._on_playlist_end(object())
await bus.drain()
assert not player.is_playing
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]

View File

@@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"yaml": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
@@ -2328,6 +2329,21 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
} }
} }
} }

View File

@@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"yaml": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",

View File

@@ -15,12 +15,10 @@ import { Aquarium } from "./components/Aquarium";
import { AppHeader } from "./components/AppHeader"; import { AppHeader } from "./components/AppHeader";
import { HelpOverlay } from "./components/HelpOverlay"; import { HelpOverlay } from "./components/HelpOverlay";
import { LessonMap } from "./components/LessonMap"; import { LessonMap } from "./components/LessonMap";
import { ModePicker } from "./components/ModePicker";
import { ResultSheet } from "./components/ResultSheet"; import { ResultSheet } from "./components/ResultSheet";
import { Stage } from "./components/Stage"; import { Stage } from "./components/Stage";
import { BubblesRun } from "./components/modes/BubblesRun"; import { BubblesRun } from "./components/modes/BubblesRun";
import { FeedRun } from "./components/modes/FeedRun"; import { FeedRun } from "./components/modes/FeedRun";
import { PearlsRun } from "./components/modes/PearlsRun";
import { JellyfishRun } from "./components/modes/JellyfishRun"; import { JellyfishRun } from "./components/modes/JellyfishRun";
import { RaceRun } from "./components/modes/RaceRun"; import { RaceRun } from "./components/modes/RaceRun";
import { DiveRun } from "./components/modes/DiveRun"; import { DiveRun } from "./components/modes/DiveRun";
@@ -39,7 +37,7 @@ type Screen = "aquarium" | "map" | "run";
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render /** What a mode is handed to draw. Tagged rather than optional-fielded so the render
* below narrows on `kind` instead of guessing from which keys are present. * below narrows on `kind` instead of guessing from which keys are present.
* *
* Only two shapes, for six modes: the arcade modes want a stream of single letters, and * Only two shapes, for five modes: the arcade modes want a stream of single letters, and
* everything else wants a line of chunks. That is the whole reason the modes are cheap * everything else wants a line of chunks. That is the whole reason the modes are cheap
* to add - they are presentations of one of two targets, all driven by the same engine. */ * to add - they are presentations of one of two targets, all driven by the same engine. */
type RunTarget = type RunTarget =
@@ -49,6 +47,11 @@ type RunTarget =
/** Modes that drill one key at a time rather than a line. */ /** Modes that drill one key at a time rather than a line. */
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"]; const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
/** How much of a letters round the spotlighted key(s) should take. An isolated round -
* the key's very first lesson - drills it hard; the mixed round right after blends it
* back in with everything else, which is the whole point of "isolated then mixed". */
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
interface Outcome { interface Outcome {
result: RunResult; result: RunResult;
unlockedTitle: string | null; unlockedTitle: string | null;
@@ -89,9 +92,17 @@ export function App() {
const spaceActive = true; const spaceActive = true;
if (LETTER_ONLY_MODES.includes(mode)) { if (LETTER_ONLY_MODES.includes(mode)) {
const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined;
return { return {
kind: "letters", kind: "letters",
letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys), letters: letterStream(
lesson.activeKeys,
rng,
bubbleCountFor(lesson.world),
focusKey,
lesson.spotlightKeys,
share,
),
}; };
} }
const chunks = lineFor(lesson, rng, { const chunks = lineFor(lesson, rng, {
@@ -106,16 +117,13 @@ export function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson, mode, round]); }, [lesson, mode, round]);
const start = useCallback( const start = useCallback((lesson: Lesson) => {
(lesson: Lesson) => { setLessonId(lesson.id);
setLessonId(lesson.id); setMode(lesson.primaryMode);
setMode(lesson.modes.includes(mode) ? mode : "dive"); setOutcome(null);
setOutcome(null); setRound((r) => r + 1);
setRound((r) => r + 1); setScreen("run");
setScreen("run"); }, []);
},
[mode],
);
const onFinished = useCallback( const onFinished = useCallback(
(result: RunResult) => { (result: RunResult) => {
@@ -142,6 +150,16 @@ export function App() {
setRound((r) => r + 1); setRound((r) => r + 1);
}, []); }, []);
/** A bonus replay in a mode this lesson didn't gate progress on - feed or race,
* offered on the result sheet once the lesson is passed. Never touches the unlock:
* `onFinished` still runs underneath, so a great bonus run can only improve the best
* score, not change what is unlocked. */
const playBonus = useCallback((bonusMode: ModeId) => {
setMode(bonusMode);
setOutcome(null);
setRound((r) => r + 1);
}, []);
const continueAfterResult = useCallback(() => { const continueAfterResult = useCallback(() => {
const next = lessonId ? nextLesson(lessonId) : null; const next = lessonId ? nextLesson(lessonId) : null;
setOutcome(null); setOutcome(null);
@@ -201,8 +219,8 @@ export function App() {
} }
if (current.screen !== "map") return; if (current.screen !== "map") return;
const step = // The map is a vertical path now, so "next" is down rather than to the right.
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
if (step !== 0) { if (step !== 0) {
event.preventDefault(); event.preventDefault();
playPop(340); playPop(340);
@@ -290,33 +308,23 @@ export function App() {
) )
) : mode === "feed" ? ( ) : mode === "feed" ? (
<FeedRun {...textProps(run)} /> <FeedRun {...textProps(run)} />
) : mode === "pearls" ? (
<PearlsRun {...textProps(run)} />
) : mode === "race" ? ( ) : mode === "race" ? (
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} /> <RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
) : ( ) : (
<DiveRun {...textProps(run)} /> <DiveRun {...textProps(run)} />
)} )}
<div style={{ padding: "0 32px 18px", flex: "none" }}>
<ModePicker
modes={lesson.modes}
active={mode}
onPick={(chosen) => {
setMode(chosen);
setRound((r) => r + 1);
}}
/>
</div>
</> </>
)} )}
{outcome && ( {outcome && lesson && (
<ResultSheet <ResultSheet
result={outcome.result} result={outcome.result}
unlockedTitle={outcome.unlockedTitle} unlockedTitle={outcome.unlockedTitle}
newCreature={outcome.newCreature} newCreature={outcome.newCreature}
isNewBest={outcome.isNewBest} isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)} bestEver={overallBestAnimal(progress)}
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus}
onRetry={retry} onRetry={retry}
onContinue={continueAfterResult} onContinue={continueAfterResult}
continueLabel={ continueLabel={

View File

@@ -1,13 +1,15 @@
/** The map: four worlds, each a row of lesson cards. /** The map: a single winding path, one world section at a time.
* *
* Locked lessons are dimmed rather than hidden - seeing that there is a world called * Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
* "Große Buchstaben" waiting is half the reason to finish the one that is open. Each * waiting is half the reason to finish the world that is open. Each node carries its own
* card carries its own best animal and star count, so the map doubles as the trophy * best animal, star count and a badge for which game it plays, so the map doubles as
* cabinet. */ * both a path forward and a trophy cabinet. */
import { LESSONS, WORLDS } from "../lib/curriculum"; import { LESSONS, WORLDS } from "../lib/curriculum";
import type { Lesson } from "../lib/curriculum"; import type { Lesson } from "../lib/curriculum";
import { animalById } from "../lib/grading"; import { animalById } from "../lib/grading";
import { NODE_SPACING, pathD, pointFor } from "../lib/lessonPath";
import { MODE_INFO } from "../lib/modeInfo";
import type { Progress } from "../lib/progress"; import type { Progress } from "../lib/progress";
interface Props { interface Props {
@@ -17,88 +19,152 @@ interface Props {
onPick: (lesson: Lesson) => void; onPick: (lesson: Lesson) => void;
} }
const NODE_SIZE = 88;
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
const PATH_HALF_WIDTH = 160;
/** What a consolidation node's key-label line says when it has no keys of its own to
* show - a child-friendly word rather than the raw `LessonKind`. */
const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
fragments: "Wörter",
words: "Wörter",
sentences: "Sätze",
};
export function LessonMap({ progress, selected, onPick }: Props) { export function LessonMap({ progress, selected, onPick }: Props) {
return ( return (
<div <div
className="view-enter" className="view-enter"
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }} style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
> >
<div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
{WORLDS.map((world) => ( {WORLDS.map((world) => {
<div key={world.number} className="glass-panel" style={{ padding: "16px 20px 20px" }}> const lessons = LESSONS.filter((lesson) => lesson.world === world.number);
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}> const done = lessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
<span style={{ fontSize: 22 }}>{world.emoji}</span> const height = lessons.length * NODE_SPACING;
<span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}> const points = lessons.map((_, i) => pointFor(i));
Welt {world.number} {world.title}
</span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>
{LESSONS.filter((l) => l.world === world.number && (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length}
/{LESSONS.filter((l) => l.world === world.number).length}
</span>
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> return (
{LESSONS.filter((lesson) => lesson.world === world.number).map((lesson) => { <section key={world.number}>
const entry = progress.lessons[lesson.id]; <div
const locked = !entry?.unlocked; className="glass-panel"
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null; style={{
const index = LESSONS.indexOf(lesson); position: "sticky",
top: 0,
zIndex: 2,
padding: "10px 18px",
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 8,
}}
>
<span style={{ fontSize: 22 }}>{world.emoji}</span>
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
Welt {world.number} {world.title}
</span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
{done}/{lessons.length}
</span>
</div>
return ( <div style={{ position: "relative", height, margin: "0 auto" }}>
<button <svg
key={lesson.id} style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
className="card glass-panel" width={PATH_HALF_WIDTH * 2}
data-selected={index === selected} height={height}
data-locked={locked} viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
disabled={locked} >
onClick={() => onPick(lesson)} <path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
style={{ width: 150, padding: 12, flex: "none" }} </svg>
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}>
{lesson.number}
</span>
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
</div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}> {lessons.map((lesson, i) => {
{lesson.title} const entry = progress.lessons[lesson.id];
</div> const locked = !entry?.unlocked;
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
const index = LESSONS.indexOf(lesson);
const point = points[i]!;
const modeInfo = MODE_INFO[lesson.primaryMode];
{/* The keys themselves, big: for a pre-reader this is the real label return (
and the title is decoration. A drill has no new keys, so it says <button
so with a symbol instead of showing an empty line. */} key={lesson.id}
<div className="card glass-panel"
data-selected={index === selected}
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
title={lesson.title}
style={{ style={{
fontSize: 15, position: "absolute",
fontWeight: 800, left: `calc(50% + ${point.x}px)`,
color: "var(--paper)", top: point.y,
opacity: 0.75, transform: "translate(-50%, -50%)",
letterSpacing: lesson.isDrill ? "normal" : "0.14em", width: NODE_SIZE,
marginTop: 3, height: NODE_SIZE,
minHeight: 20, borderRadius: "50%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 0,
textAlign: "center",
}} }}
> >
{lesson.isDrill <span
? "🔁 Übung" aria-hidden
: lesson.newKeys style={{
.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())) position: "absolute",
.join(" ")} top: -4,
</div> right: -4,
fontSize: 15,
filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
<div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}> <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
</div> style={{
</button> fontSize: 11,
); fontWeight: 800,
})} color: "var(--paper)",
</div> opacity: 0.8,
</div> 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>
);
})}
</div>
</section>
);
})}
</div> </div>
</div> </div>
); );

View File

@@ -1,40 +0,0 @@
/** Which game a lesson is played as. Dive mode is the measured one; the rest are
* the same drill wearing a costume, which is how variety gets discovered without
* splitting the curriculum. */
import type { ModeId } from "../lib/curriculum";
interface Props {
modes: readonly ModeId[];
active: ModeId;
onPick: (mode: ModeId) => void;
}
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" },
pearls: { emoji: "🦪", name: "Perlentaucher" },
};
export function ModePicker({ modes, active, onPick }: Props) {
return (
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
{modes.map((mode) => {
const info = MODE_INFO[mode];
return (
<button
key={mode}
className="pill"
data-active={mode === active}
onClick={() => onPick(mode)}
>
{info.emoji} {info.name}
</button>
);
})}
</div>
);
}

View File

@@ -9,9 +9,11 @@ import { useEffect, useRef } from "react";
import { creatureById } from "../lib/aquarium"; import { creatureById } from "../lib/aquarium";
import type { CreatureId } from "../lib/aquarium"; import type { CreatureId } from "../lib/aquarium";
import type { ModeId } from "../lib/curriculum";
import type { RunResult } from "../lib/grading"; import type { RunResult } from "../lib/grading";
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading"; import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
import type { AnimalId } from "../lib/grading"; import type { AnimalId } from "../lib/grading";
import { MODE_INFO } from "../lib/modeInfo";
interface Props { interface Props {
result: RunResult; result: RunResult;
@@ -23,6 +25,10 @@ interface Props {
/** The fastest animal earned on any lesson so far - decides how much of the ladder /** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */ * may be revealed. */
bestEver: AnimalId | null; bestEver: AnimalId | null;
/** Extra games this lesson didn't gate progress on - feed or race, offered only once
* the lesson is passed. Playing one never changes the unlock, only the best score. */
bonusModes: readonly ModeId[];
onPlayBonus: (mode: ModeId) => void;
onRetry: () => void; onRetry: () => void;
onContinue: () => void; onContinue: () => void;
/** Null at the end of the curriculum. */ /** Null at the end of the curriculum. */
@@ -35,6 +41,8 @@ export function ResultSheet({
newCreature, newCreature,
isNewBest, isNewBest,
bestEver, bestEver,
bonusModes,
onPlayBonus,
onRetry, onRetry,
onContinue, onContinue,
continueLabel, continueLabel,
@@ -159,6 +167,33 @@ export function ResultSheet({
</button> </button>
)} )}
</div> </div>
{/* Extra games this lesson didn't need to pass - a treat, not a requirement, so
they only appear once the lesson is already behind her. `.pill` is styled for
the dark stage background, not this light sheet, so these get their own
(smaller, quieter) version of the sheet's own button look instead. */}
{bonusModes.length > 0 && (
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center", flexWrap: "wrap" }}>
{bonusModes.map((mode) => (
<button
key={mode}
onClick={() => onPlayBonus(mode)}
style={{
border: "none",
borderRadius: 999,
padding: "8px 16px",
fontSize: 14,
fontWeight: 800,
cursor: "pointer",
background: "oklch(90% 0.02 175)",
color: "var(--ink)",
}}
>
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
/** Dive mode - the core drill, and the run that counts for the unlock. /** Dive mode - the plain drill: a line of chunks with a moving cursor above the
* * keyboard. Most fragments/words/sentences lessons play this by default, though feed
* A line of chunks with a moving cursor above the keyboard. Everything else in the * and race periodically take a turn as the required mode instead - any of them can
* game is a variation on this; this is the one that is measured. */ * unlock the next lesson, since `recordRun` doesn't care which mode produced the run. */
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../lib/engine";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../lib/grading";

View File

@@ -1,155 +0,0 @@
/** Pearls mode - the mode with no clock.
*
* Every other mode measures something. This one deliberately does not show a timer, a
* progress bar, a streak or a speed: an oyster opens, a word is inside it, and each
* correct letter is a pearl on the string. A mistake costs a pearl, and that is the
* only pressure there is.
*
* It exists because a six-year-old having a bad afternoon needs somewhere to go that is
* still the same lesson and still counts, but cannot be failed at quickly. Every lesson
* offers it, always. The run is still graded the same way underneath - a calm round and
* a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
import { useState } from "react";
import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine";
import { chunkOffsets } from "../../lib/generator";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { useRun } from "../../hooks/useRun";
import { Keyboard } from "../Keyboard";
interface Props {
chunks: readonly string[];
text: string;
spaceActive: boolean;
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
export function PearlsRun({
chunks,
text,
spaceActive,
activeKeys,
progress,
paused,
onFinished,
}: Props) {
const [pearls, setPearls] = useState(0);
const onEvent = (event: RunEvent) => {
// A pearl per correct letter, one lost per mistake - but never below zero. Watching
// the string shrink past empty is the kind of punishment this mode exists to avoid.
if (event.type === "correct") setPearls((n) => n + 1);
else if (event.type === "wrong" && event.firstAt) setPearls((n) => Math.max(0, n - 1));
};
const { state, wrong } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
onEvent,
});
const next = currentChar(state);
const offsets = chunkOffsets(chunks, spaceActive);
// Which word the oyster is holding right now.
const currentChunkIndex = offsets.findIndex((start, i) => {
const end = start + (chunks[i]?.length ?? 0);
return state.index <= end;
});
const word = chunks[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? "";
const wordStart = offsets[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? 0;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px",
minHeight: 0,
}}
>
{/* The pearl string. It only ever grows, one bead per letter. */}
<div
style={{
display: "flex",
gap: 5,
flexWrap: "wrap",
justifyContent: "center",
maxWidth: "70%",
minHeight: 26,
alignItems: "center",
}}
aria-label={`${pearls} Perlen`}
>
{Array.from({ length: pearls }, (_, i) => (
<span
key={i}
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))",
boxShadow: "0 2px 7px var(--shadow)",
animation: i === pearls - 1 ? "correctPop 240ms ease-out" : undefined,
}}
/>
))}
</div>
{/* The oyster, holding one word at a time. */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
padding: "26px 42px",
borderRadius: 26,
background: "linear-gradient(160deg, oklch(97% 0.01 175 / .2), oklch(97% 0.01 175 / .07))",
border: "1px solid oklch(97% 0.01 175 / .22)",
animation: wrong ? "wrongShake 260ms ease" : undefined,
}}
>
<div style={{ fontSize: 34 }}>🦪</div>
<div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}>
{[...word].map((char, i) => {
const at = wordStart + i;
const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
return (
<span
key={i}
className="target-char"
data-state={charState}
data-blank={char === " "}
data-wrong={charState === "current" && wrong}
>
{char === " " ? (charState === "current" ? "␣" : "") : char}
</span>
);
})}
</div>
</div>
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={38}
/>
</div>
);
}

View File

@@ -0,0 +1,422 @@
# The lesson plan, as data rather than code, so wording, word lists and pacing can be
# tweaked here without touching curriculum.ts.
#
# Every lesson: title, subtitle (read aloud). Then either:
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
# already active: the first time a key appears its lesson is "isolated" (heavy
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
# blended with everything learned so far). 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.
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
# default; `mode:` overrides it, see curriculum.ts for which modes fit which kind).
#
# Letter order follows German letter frequency, adapted to a home-row-first, mirrored
# pace for a six-year-old - unchanged from the original course.
worlds:
- number: 1
title: "Die Grundstellung"
emoji: "🏝️"
reward: clownfish
lessons:
- title: "F und J"
subtitle: "Die Zeigefinger - die Tasten mit den Punkten"
keys: [f, j]
- title: "F und J üben"
subtitle: "Die neuen Tasten festigen"
keys: [f, j]
- title: "D und K"
subtitle: "Die Mittelfinger"
keys: [d, k]
- title: "D und K üben"
subtitle: "Die neuen Tasten festigen"
keys: [d, k]
- title: "Übung: F J D K"
subtitle: "Die vier Tasten zusammen"
drill: true
- title: "S und L"
subtitle: "Die Ringfinger"
keys: [s, l]
- title: "S und L üben"
subtitle: "Die neuen Tasten festigen"
keys: [s, l]
- title: "Übung: sechs Tasten"
subtitle: "Alles bisher zusammen"
drill: true
- title: "A und Ö"
subtitle: "Die kleinen Finger"
keys: [a, ö]
- title: "A und Ö üben"
subtitle: "Die neuen Tasten festigen"
keys: [a, ö]
- title: "Übung: die Grundstellung"
subtitle: "Alle acht Finger"
drill: true
- title: "Erste kleine Wörter"
subtitle: "Echte Wörter mit acht Tasten"
kind: fragments
words: [da, ja, das, dass, als, all, fall, falls, lass, saal, kalk, salsa, jass]
- title: "Die Leertaste"
subtitle: "Der Daumen kommt dazu"
keys: [" "]
- title: "Übung: Grundstellung mit Leertaste"
subtitle: "Jetzt mit dem Daumen"
drill: true
- title: "Wörter mit Leertaste"
subtitle: "Kleine Wörter, kleine Sätze"
kind: fragments
words: ["lass das", "das da", "da ja", "fall da", "kalk da", "saal da", "ja lass das", "als da"]
- number: 2
title: "Nach oben"
emoji: "🌊"
reward: octopus
lessons:
- title: "Das E"
subtitle: "Mittelfinger links nach oben"
keys: [e]
- title: "E üben"
subtitle: "Die neue Taste festigen"
keys: [e]
- title: "Das I"
subtitle: "Mittelfinger rechts nach oben"
keys: [i]
- title: "I üben"
subtitle: "Die neue Taste festigen"
keys: [i]
- title: "Übung: E und I"
subtitle: "Beide Mittelfinger nach oben"
drill: true
- title: "Das R"
subtitle: "Zeigefinger links nach oben"
keys: [r]
- title: "R üben"
subtitle: "Die neue Taste festigen"
keys: [r]
- title: "Das U"
subtitle: "Zeigefinger rechts nach oben"
keys: [u]
- title: "U üben"
subtitle: "Die neue Taste festigen"
keys: [u]
- title: "Übung: R und U"
subtitle: "Beide Zeigefinger nach oben"
drill: true
- title: "Kleine Wörter: E I R U"
subtitle: "Erste echte Wörter mit der oberen Reihe"
kind: fragments
words: [die, sie, elf, eis, esel, see, keks, fiel, lied, rad, reis, eier, rufe, feuer, sauer, lauf]
- title: "Das T"
subtitle: "Zeigefinger links weit nach oben"
keys: [t]
- title: "T üben"
subtitle: "Die neue Taste festigen"
keys: [t]
- title: "Das Z"
subtitle: "Zeigefinger rechts weit nach oben"
keys: [z]
- title: "Z üben"
subtitle: "Die neue Taste festigen"
keys: [z]
- title: "Übung: T und Z"
subtitle: "Weit nach oben greifen"
drill: true
- title: "Das O"
subtitle: "Ringfinger rechts nach oben"
keys: [o]
- title: "O üben"
subtitle: "Die neue Taste festigen"
keys: [o]
- title: "Das W"
subtitle: "Ringfinger links nach oben"
keys: [w]
- title: "W üben"
subtitle: "Die neue Taste festigen"
keys: [w]
- title: "Übung: O und W"
subtitle: "Beide Ringfinger nach oben"
drill: true
- title: "Kleine Wörter: T Z O W"
subtitle: "Noch mehr echte Wörter"
kind: fragments
mode: feed
words: [tier, tafel, kette, leiter, zeit, salz, zelt, katze, rot, tor, los, foto, wo, wald, zwei, wolke]
- title: "Das P"
subtitle: "Kleiner Finger rechts nach oben"
keys: [p]
- title: "P üben"
subtitle: "Die neue Taste festigen"
keys: [p]
- title: "Das Q"
subtitle: "Kleiner Finger links nach oben"
keys: [q]
- title: "Q üben"
subtitle: "Die neue Taste festigen"
keys: [q]
- title: "Das Ü"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: [ü]
- title: "Ü üben"
subtitle: "Die neue Taste festigen"
keys: [ü]
- title: "Übung: P Q Ü"
subtitle: "Die kleinen Finger nach oben"
drill: true
- title: "Übung: die obere Reihe"
subtitle: "Die ganze Reihe zusammen"
kind: words
mode: feed
words: [wolke, zeit, pause, prüfe, qualle, torte, reiter, würfel]
- title: "Übung: Welt 2 komplett"
subtitle: "Alles aus der oberen Reihe"
drill: true
- number: 3
title: "Nach unten"
emoji: "🪸"
reward: seahorse
lessons:
- title: "Das N"
subtitle: "Zeigefinger rechts nach unten"
keys: [n]
- title: "N üben"
subtitle: "Die neue Taste festigen"
keys: [n]
- title: "Das M"
subtitle: "Zeigefinger rechts, neben dem N"
keys: [m]
- title: "M üben"
subtitle: "Die neue Taste festigen"
keys: [m]
- title: "Übung: N und M"
subtitle: "Die neuen Tasten festigen"
drill: true
- title: "Das G"
subtitle: "Zeigefinger links, in der Mitte"
keys: [g]
- title: "G üben"
subtitle: "Die neue Taste festigen"
keys: [g]
- title: "Das H"
subtitle: "Zeigefinger rechts, in der Mitte"
keys: [h]
- title: "H üben"
subtitle: "Die neue Taste festigen"
keys: [h]
- title: "Übung: G und H"
subtitle: "Die Mitte der Grundreihe"
drill: true
- title: "Kleine Wörter: N M G H"
subtitle: "Erste echte Wörter nach unten"
kind: fragments
words: [nase, nein, kind, wind, sonne, mama, mond, meer, maus, gut, gans, regen, hase, haus, hund, hupe]
- title: "Das C"
subtitle: "Mittelfinger links nach unten"
keys: [c]
- title: "C üben"
subtitle: "Die neue Taste festigen"
keys: [c]
- title: "Das V"
subtitle: "Zeigefinger links nach unten"
keys: [v]
- title: "V üben"
subtitle: "Die neue Taste festigen"
keys: [v]
- title: "Übung: C und V"
subtitle: "Nach unten greifen"
drill: true
- title: "Das B"
subtitle: "Zeigefinger links, neben dem V"
keys: [b]
- title: "B üben"
subtitle: "Die neue Taste festigen"
keys: [b]
- title: "Das Y"
subtitle: "Kleiner Finger links nach unten"
keys: [y]
- title: "Y üben"
subtitle: "Die neue Taste festigen"
keys: [y]
- title: "Übung: B und Y"
subtitle: "Ganz unten links"
drill: true
- title: "Kleine Wörter: C V B Y"
subtitle: "Noch mehr echte Wörter"
kind: fragments
words: [koch, milch, schule, chaos, vier, vase, voll, vater, baum, boot, bunt, brot, yoga, baby, typ, pony]
- title: "Das X"
subtitle: "Ringfinger links nach unten"
keys: [x]
- title: "X üben"
subtitle: "Die neue Taste festigen"
keys: [x]
- title: "Das Ä"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: [ä]
- title: "Ä üben"
subtitle: "Die neue Taste festigen"
keys: [ä]
- title: "Übung: X und Ä"
subtitle: "Die letzten beiden Tasten"
drill: true
- title: "Übung: alle Buchstaben"
subtitle: "Das ganze Alphabet"
kind: words
mode: race
words: [delfin, wasser, xylofon, bäume, vogel, qualle, muschel, tauchen]
- title: "Übung: Welt 3 komplett"
subtitle: "Alles aus der unteren Reihe"
drill: true
- number: 4
title: "Große Buchstaben"
emoji: "👑"
reward: turtle
lessons:
- title: "Umschalttaste rechts, Teil 1"
subtitle: "Große Buchstaben der linken Hand"
keys: ["⇧"]
kind: words
words: [Delfin, Wal, Fisch, Baum]
- title: "Umschalttaste rechts, Teil 2"
subtitle: "Noch mehr große Buchstaben"
kind: words
words: [Garten, Ente, Vogel, Riff]
- title: "Umschalttaste links, Teil 1"
subtitle: "Große Buchstaben der rechten Hand"
kind: words
words: [Haus, Kind, Mond, Nase]
- title: "Umschalttaste links, Teil 2"
subtitle: "Noch mehr große Buchstaben"
kind: words
words: [Lampe, Onkel, Uhr, Puppe]
- title: "Übung: Namen, Teil 1"
subtitle: "Namen fangen groß an"
kind: words
words: [Anna, Lena, Paul, Mia, Emil, Jonas, Tom, Lisa]
- title: "Übung: Namen, Teil 2"
subtitle: "Noch mehr Namen"
kind: words
mode: feed
words: [Ben, Nora, Finn, Ida, Max, Ella, Oskar, Greta]
- title: "Übung: große und kleine"
subtitle: "Beides gemischt"
kind: words
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"]
- title: "Übung: Welt 4 komplett"
subtitle: "Groß und klein zusammen"
kind: words
drill: true
words: [Delfin, Haus, Anna, Ben, "Das Meer", "Der Wal", Mond, Riff]
- number: 5
title: "Ganze Sätze"
emoji: "📖"
reward: pearlmussel
lessons:
- title: "Der Punkt"
subtitle: "Ringfinger rechts nach unten"
keys: ["."]
- title: "Punkt üben"
subtitle: "Die neue Taste festigen"
keys: ["."]
- title: "Erste Sätze mit Punkt"
subtitle: "Ein Satz, ein Punkt"
kind: sentences
words:
- "Das Meer ist tief."
- "Der Hund bellt."
- "Ich mag Kekse."
- "Die Sonne scheint."
- "Der Wal ist riesig."
- "Wir gehen baden."
- "Mama liest ein Buch."
- "Der Fisch schwimmt."
- "Heute ist es warm."
- "Ich habe einen Ball."
- "Die Welle ist hoch."
- "Papa kocht Suppe."
- title: "Das Komma"
subtitle: "Mittelfinger rechts nach unten"
keys: [","]
- title: "Komma üben"
subtitle: "Die neue Taste festigen"
keys: [","]
- title: "Sätze mit Komma"
subtitle: "Zwei Gedanken, ein Satz"
kind: sentences
mode: race
words:
- "Ich mag Wale, Delfine und Fische."
- "Erst lesen, dann tippen."
- "Es ist warm, also baden wir."
- "Rot, gelb und blau sind Farben."
- "Wenn es regnet, bleiben wir drinnen."
- "Der Delfin springt, taucht und spielt."
- "Morgen, sagt Papa, fahren wir los."
- "Eins, zwei, drei, vier."
- "Oma, Opa und ich gehen schwimmen."
- "Die Sonne scheint, das Meer glitzert."
- "Muscheln, Steine und Sand liegen am Strand."
- title: "Der Bindestrich"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: ["-"]
- title: "Bindestrich üben"
subtitle: "Die neue Taste festigen"
keys: ["-"]
- title: "Sätze mit Bindestrich"
subtitle: "Zwei Wörter, ein Strich"
kind: sentences
words:
- "Wir spielen mit dem Wasser-Ball."
- "Das ist ein Delfin-Baby."
- "Meine Ur-Oma kommt heute."
- "Wir bauen eine Sand-Burg."
- "Der Fisch-Schwarm ist riesig."
- "Ich trage mein T-Shirt."
- "Das Schwimm-Bad ist offen."
- "Die Bade-Hose ist nass."
- "Wir essen ein Eis-Hörnchen."
- "Das Segel-Boot ist blau."
- "Mein Lieblings-Tier ist der Delfin."
- title: "Fragezeichen und Ausrufezeichen"
subtitle: "Mit der Umschalttaste"
keys: ["ß", "1"]
- title: "Fragezeichen und Ausrufezeichen üben"
subtitle: "Die neuen Tasten festigen"
keys: ["ß", "1"]
- title: "Fragen und Rufe"
subtitle: "Wie klingt ein Satz?"
kind: sentences
words:
- "Wo ist der Delfin?"
- "Das war toll!"
- "Wie geht es dir?"
- "Pass auf!"
- "Kommst du mit?"
- "Der Wal ist so groß!"
- "Hast du Hunger?"
- "Hurra, Ferien!"
- "Was schwimmt da?"
- "Schau mal, ein Hai!"
- "Wie tief ist das Meer?"
- "Wir haben es geschafft!"
- title: "Übung: ganze Sätze"
subtitle: "Alles zusammen"
kind: sentences
drill: true
mode: race
words:
- "Der Delfin schwimmt sehr schnell."
- "Wo ist mein Boot?"
- "Ich tippe jetzt mit zehn Fingern!"
- "Das Meer ist blau, tief und kalt."
- "Kannst du das auch?"
- "Wir bauen eine Sand-Burg am Strand."
- "Die Möwe fliegt über das Wasser."
- "Oma, Opa und ich gehen schwimmen."
- "Das ist ja super!"
- "Wie heißt der große Wal?"
- "Im Riff wohnen bunte Fische."
- "Der Krake hat acht Arme."

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum"; import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers"; import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => { describe("LESSONS", () => {
@@ -125,15 +125,52 @@ describe("LESSONS", () => {
} }
}); });
it("only offers word modes where words exist", () => { it("plays a mode that actually fits its kind", () => {
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race"); expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(lesson.primaryMode);
expect(wordModes).toBe(lesson.words.length > 0); for (const mode of lesson.bonusModes) {
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(mode);
}
} }
}); });
it("always offers the pressure-free pearl-diving mode", () => { it("never plays the same arcade game twice in a row", () => {
for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls"); const arcade = LESSONS.filter((lesson) => lesson.kind === "letters");
for (let i = 1; i < arcade.length; i++) {
expect(arcade[i]!.primaryMode, `${arcade[i]!.number}: ${arcade[i]!.title}`).not.toBe(arcade[i - 1]!.primaryMode);
}
});
it("offers every eligible mode this lesson isn't gated on, as a bonus", () => {
for (const lesson of LESSONS) {
const expected = ELIGIBLE_MODES[lesson.kind].filter(
(mode) => (mode === "feed" || mode === "race") && mode !== lesson.primaryMode,
);
expect([...lesson.bonusModes].sort()).toEqual([...expected].sort());
}
});
it("mixes the games instead of always diving - feed and race take a turn as the required mode too", () => {
const words = LESSONS.filter((l) => l.kind === "words");
const sentences = LESSONS.filter((l) => l.kind === "sentences");
expect(words.some((l) => l.primaryMode === "feed")).toBe(true);
expect(words.some((l) => l.primaryMode === "race")).toBe(true);
expect(sentences.some((l) => l.primaryMode === "race")).toBe(true);
});
it("distinguishes a mixed round from a drill - both have no new keys, only one is a review", () => {
const mixed = LESSONS.filter((lesson) => lesson.emphasis === "mixed");
expect(mixed.length).toBeGreaterThan(0);
for (const lesson of mixed) {
expect(lesson.newKeys).toEqual([]);
expect(lesson.isDrill).toBe(false);
}
});
it("gives every isolated round something new to drill", () => {
for (const lesson of LESSONS) {
if (lesson.emphasis === "isolated") expect(lesson.newKeys.length).toBeGreaterThan(0);
}
}); });
it("teaches capitals only once shift exists", () => { it("teaches capitals only once shift exists", () => {
@@ -154,7 +191,7 @@ describe("LESSONS", () => {
it("follows every pair of new keys with a drill", () => { it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill); const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(drills.length).toBeGreaterThanOrEqual(12); expect(drills.length).toBeGreaterThanOrEqual(15);
// A drill never introduces anything, and always has something to practise. // A drill never introduces anything, and always has something to practise.
for (const lesson of drills) { for (const lesson of drills) {
expect(lesson.newKeys).toEqual([]); expect(lesson.newKeys).toEqual([]);
@@ -162,8 +199,8 @@ describe("LESSONS", () => {
} }
}); });
it("is long enough to be a real course", () => { it("is long enough to be a real, Duolingo-length course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40); expect(LESSONS.length).toBeGreaterThanOrEqual(80);
expect(WORLDS.length).toBe(5); expect(WORLDS.length).toBe(5);
}); });

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { PATH_AMPLITUDE, pathD, pointFor, xOffsetFor } from "../lessonPath";
describe("xOffsetFor", () => {
it("starts centred", () => {
expect(xOffsetFor(0)).toBeCloseTo(0);
});
it("never strays further than the amplitude", () => {
for (let i = 0; i < 40; i++) {
expect(Math.abs(xOffsetFor(i))).toBeLessThanOrEqual(PATH_AMPLITUDE + 1e-9);
}
});
it("is deterministic", () => {
expect(xOffsetFor(5)).toBe(xOffsetFor(5));
});
});
describe("pointFor", () => {
it("places nodes strictly further down as the index grows", () => {
let previousY = -Infinity;
for (let i = 0; i < 10; i++) {
const point = pointFor(i);
expect(point.y).toBeGreaterThan(previousY);
previousY = point.y;
}
});
});
describe("pathD", () => {
it("draws nothing for fewer than two points", () => {
expect(pathD([])).toBe("");
expect(pathD([{ x: 0, y: 0 }])).toBe("");
});
it("starts at the first point and mentions every point", () => {
const points = [pointFor(0), pointFor(1), pointFor(2)];
const d = pathD(points);
expect(d.startsWith(`M ${points[0]!.x} ${points[0]!.y}`)).toBe(true);
for (const point of points) expect(d).toContain(String(point.x));
});
});

View File

@@ -150,7 +150,7 @@ describe("migrate", () => {
it("returns a fresh profile for anything unusable", () => { it("returns a fresh profile for anything unusable", () => {
for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) { for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) {
const progress = migrate(raw); const progress = migrate(raw);
expect(progress.version).toBe(1); expect(progress.version).toBe(2);
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true); expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
} }
}); });
@@ -177,6 +177,33 @@ describe("migrate", () => {
broken.lessons[FIRST_LESSON_ID] = { ...broken.lessons[FIRST_LESSON_ID]!, unlocked: false }; broken.lessons[FIRST_LESSON_ID] = { ...broken.lessons[FIRST_LESSON_ID]!, unlocked: false };
expect(migrate(broken).lessons[FIRST_LESSON_ID]!.unlocked).toBe(true); expect(migrate(broken).lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
}); });
it("restarts the lesson path from an old version, but keeps the aquarium, pearls and streak", () => {
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum,
// so there is nothing sensible to remap them onto - she starts the path over, but
// does not lose what she already earned.
const old = {
version: 1,
lessons: { l09: { unlocked: true, runs: 3, bestStars: 3, bestAnimal: "orca", bestPoints: 999, ghost: null } },
keyStats: { a: { ema: 400, attempts: 10, errors: 1 } },
pearls: 42,
aquarium: ["clownfish", "octopus"],
streak: { days: 5, lastPlayed: "2024-01-01" },
settings: { sound: false, keyboardHint: "off" },
};
const restored = migrate(JSON.parse(JSON.stringify(old)));
expect(restored.version).toBe(2);
// "l09" exists in the new curriculum too, just as a different lesson - its old
// three-star, level-99 progress must not carry over onto whatever l09 means now.
expect(restored.lessons["l09"]).toEqual({ unlocked: false, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null });
expect(restored.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
expect(Object.values(restored.lessons).filter((l) => l.unlocked)).toHaveLength(1);
expect(restored.pearls).toBe(42);
expect(restored.aquarium).toEqual(["clownfish", "octopus"]);
expect(restored.keyStats["a"]?.attempts).toBe(10);
expect(restored.streak).toEqual({ days: 5, lastPlayed: "2024-01-01" });
expect(restored.settings).toEqual({ sound: false, keyboardHint: "off" });
});
}); });
describe("focusKeyFor", () => { describe("focusKeyFor", () => {

View File

@@ -1,41 +1,36 @@
/** The lesson plan: 48 lessons in 5 worlds. /** The lesson plan: content lives in data/curriculum.yaml, this file only derives and
* validates it.
* *
* The shape follows what every serious ten-finger course does - start on the * The shape follows what every serious ten-finger course does - start on the home row
* home row `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S, * `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S, R, A, T, D, H,
* R, A, T, D, H, U, L …), and drill everything learned so far each time. TIPP10's * U, L …), and drill everything learned so far each time. TIPP10's German course is the
* German course is the same idea in 18 lessons. * same idea in 18 lessons.
* *
* Where this deviates, it deviates for the age, and the deviation is *pace*. A * Where this deviates, it deviates for the age and for the Duolingo-style path this app
* six-year-old gets: * wants: every new key gets an **isolated** round (heavy weight, alone) before a
* **mixed** round (lighter weight, blended with everything learned so far) - the same
* new-key / mixed-key / real-content progression typing.com and typingstudy.com use,
* just written as short rounds a six-year-old can climb one at a time. A drill lesson
* follows every couple of keys, and periodically a **fragments** round turns the
* newly-active keys into real short German words - the bridge between drilling letters
* and typing whole words or sentences.
* *
* - **one new key per lesson** from world 2 onward (world 1 pairs the same finger on * `activeKeys` is cumulative on purpose: lesson 80 still drills `f`, or the first
* both hands, which is one motion, not two);
* - **a drill lesson after every pair of new keys** - no new keys at all, just the
* ones she has. Consolidation is where typing actually becomes automatic, and a
* course that only ever moves forward never gives it room;
* - **short rounds**, growing from twelve characters in world 1 to whole sentences in
* world 5.
*
* That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
* steps between are small enough to climb.
*
* `activeKeys` is cumulative on purpose: lesson 30 still drills `f`, or the first
* lessons rot while the last ones are learned. */ * lessons rot while the last ones are learned. */
import { parse } from "yaml";
import curriculumYaml from "../data/curriculum.yaml?raw";
import type { CreatureId } from "./aquarium"; import type { CreatureId } from "./aquarium";
import { HOME_ROW, SPACE_KEY } from "./fingers"; import { HOME_ROW, SPACE_KEY } from "./fingers";
export type ModeId = /** What a lesson's targets are made of. `fragments` is short real syllables/mini-words -
| "dive" * the bridge between drilling isolated letters and typing whole words or sentences. */
| "bubbles" export type LessonKind = "letters" | "fragments" | "words" | "sentences";
| "jellyfish"
| "feed"
| "race"
| "pearls";
/** What a lesson's targets are made of. Decides which modes make sense: jellyfish mode /** Which game a lesson can be played as. `pearls` is gone - every other mode measures
* cannot show a sentence, and feed mode cannot put one on a fish. */ * something, and that one deliberately didn't. */
export type LessonKind = "letters" | "words" | "sentences"; export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
export interface Lesson { export interface Lesson {
id: string; id: string;
@@ -44,16 +39,31 @@ export interface Lesson {
title: string; title: string;
/** What this lesson is about, in words a six-year-old hears read aloud. */ /** What this lesson is about, in words a six-year-old hears read aloud. */
subtitle: string; subtitle: string;
/** The keys introduced here - what the generator weights toward. Empty for a drill. */ kind: LessonKind;
/** The keys that became active *this* lesson - empty for a mixed round or a drill.
* Used to size how gently a round starts (see World 1's mirrored pairs) and to check
* the course never introduces more than it should. */
newKeys: readonly string[]; newKeys: readonly string[];
/** The keys this round is about, whether or not they are new - what the generator
* over-represents. Equal to `newKeys` for an isolated round, the same keys again
* (already active) for the mixed round that follows it, empty for a drill. */
spotlightKeys: readonly string[];
/** Isolated: newly-active keys, drilled alone. Mixed: the same keys, blended with
* everything else. `null` for a drill or for anything that isn't a letters round. */
emphasis: "isolated" | "mixed" | null;
/** Everything typable in this lesson, cumulative. */ /** Everything typable in this lesson, cumulative. */
activeKeys: readonly string[]; activeKeys: readonly string[];
/** Which game modes this lesson offers, in carousel order. */ /** The one mode that gates progress this round - passing a run in this mode is what
modes: readonly ModeId[]; * can unlock the next lesson. Mostly `dive` for non-letters kinds, but `feed`/`race`
/** Real German words (or sentences) for the word modes. */ * take a periodic turn so the games stay mixed, not just `dive` end to end. */
primaryMode: ModeId;
/** Extra replays, offered on the result sheet once this lesson is passed. Never gate
* anything - they exist so a favourite game can be played again. */
bonusModes: readonly ModeId[];
/** Real German words, fragments or sentences for the non-letters kinds. */
words: readonly string[]; words: readonly string[];
kind: LessonKind; /** True for a pure review round - no fresh content, just practice. Independent of
/** True for a consolidation lesson - no new keys, just practice. */ * `newKeys`: a mixed round also has no new keys but is not a drill. */
isDrill: boolean; isDrill: boolean;
/** How long one run is. Grows with the curriculum - see `lengthFor`. */ /** How long one run is. Grows with the curriculum - see `lengthFor`. */
chunks: number; chunks: number;
@@ -70,233 +80,168 @@ export interface World {
/** A world's creature is a pet that stays - a drawing that swims behind every screen /** A world's creature is a pet that stays - a drawing that swims behind every screen
* from then on. An animal in grading.ts is a speed trophy that changes, and is an * from then on. An animal in grading.ts is a speed trophy that changes, and is an
* emoji. lib/aquarium.ts has why the two are kept in different visual languages. The * emoji. lib/aquarium.ts has why the two are kept in different visual languages. */
* pets grow with the worlds: a small fish first, the pearl mussel - the pearls' own const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"];
* home - last. */
export const WORLDS: readonly World[] = [
{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
{ number: 2, title: "Nach oben", emoji: "🌊", reward: "octopus" },
{ number: 3, title: "Nach unten", emoji: "🪸", reward: "seahorse" },
{ number: 4, title: "Große Buchstaben", emoji: "👑", reward: "turtle" },
{ number: 5, title: "Ganze Sätze", emoji: "📖", reward: "pearlmussel" },
];
/** Letters only - no words can be spelled yet. */ /** Which modes make sense for a kind. Letters rounds are single keys, so only the
const LETTER_MODES: readonly ModeId[] = ["dive", "bubbles", "jellyfish", "pearls"]; * arcade modes fit; fragments/words can be fed to a fish; only words and sentences are
/** Real words exist: the word modes and the race join in. */ * long enough for a race. */
const WORD_MODES: readonly ModeId[] = ["dive", "feed", "bubbles", "race", "jellyfish", "pearls"]; export const ELIGIBLE_MODES: Record<LessonKind, readonly ModeId[]> = {
/** Sentences do not fit on a bubble or a fish. */ letters: ["bubbles", "jellyfish"],
const SENTENCE_MODES: readonly ModeId[] = ["dive", "race", "pearls"]; fragments: ["dive", "feed"],
words: ["dive", "feed", "race"],
sentences: ["dive", "race"],
};
/** Line length by world - a full block of text per round, at least five lines of it. /** Line length by world and kind - a full block of text per round. Letters rounds are
* * never rendered through `dive` any more (they are bubbles/jellyfish), but the length is
* Deliberately five times what the first version used. Twelve characters was long * still computed for type uniformity and so the "still starts gentler than it ends"
* enough to *finish*, which is what the first day needs, but far too short to build * shape is preserved if a letters round is ever asked for one. */
* anything: a round that ends before the hands settle measures reaction time rather
* than typing, and the score bounces around so much that getting better is invisible.
* Sixty-plus characters is long enough for a rhythm to appear and for the characters
* per minute to mean something.
*
* World 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } { function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } {
if (world === 1) return kind === "words" ? { chunks: 25, chunkSize: 3 } : { chunks: 24, chunkSize: 3 }; // 72 characters if (kind === "fragments") return { chunks: 16, chunkSize: 4 }; // 64
if (kind === "sentences") return { chunks: 10, chunkSize: 4 }; // ten whole sentences
if (kind === "words") return { chunks: 25, chunkSize: 4 };
// kind === "letters"
if (world === 1) return { chunks: 24, chunkSize: 3 }; // 72 characters
if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100 if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100
if (world === 3) return { chunks: 30, chunkSize: 4 }; // 120 return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward
if (world === 4) return { chunks: 25, chunkSize: 4 }; // 25 words
return { chunks: 10, chunkSize: 4 }; // world 5: ten whole sentences
} }
interface PlanEntry { interface YamlLesson {
world: number;
title: string; title: string;
subtitle: string; subtitle: string;
/** Empty marks a drill - consolidation, no new keys. */
newKeys: readonly string[];
words?: readonly string[];
kind?: LessonKind; kind?: LessonKind;
keys?: readonly string[];
drill?: boolean;
mode?: ModeId;
words?: readonly string[];
} }
/** The plan. Everything else is derived from this, so a curriculum change is one edit. */ interface YamlWorld {
const PLAN: readonly PlanEntry[] = [ number: number;
// ---------------------------------------------------------------- World 1 -- title: string;
// Two keys per lesson, always the same finger on each hand - one motion, mirrored. emoji: string;
// F and J first because they carry the tactile bumps: they are the two keys a child reward: CreatureId;
// can find without looking, and every other key is taught as an offset from them. lessons: readonly YamlLesson[];
{ world: 1, title: "F und J", subtitle: "Die Zeigefinger - die Tasten mit den Punkten", newKeys: ["f", "j"] }, }
{ world: 1, title: "D und K", subtitle: "Die Mittelfinger", newKeys: ["d", "k"] },
{ world: 1, title: "Übung: F J D K", subtitle: "Die vier Tasten zusammen", newKeys: [] },
{ world: 1, title: "S und L", subtitle: "Die Ringfinger", newKeys: ["s", "l"] },
{ world: 1, title: "Übung: sechs Tasten", subtitle: "Alles bisher zusammen", newKeys: [] },
{ world: 1, title: "A und Ö", subtitle: "Die kleinen Finger", newKeys: ["a", "ö"] },
{ world: 1, title: "Übung: die Grundstellung", subtitle: "Alle acht Finger", newKeys: [],
words: [
"da", "das", "dass", "ja", "als", "all", "fall", "falls", "lass",
"aal", "aas", "as", "ass", "fass", "saal", "kalk", "salsa", "jass",
"asa", "sas", "sad", "sal", "dal", "fad", "fal", "jak", "jas", "kal",
"kas", "lak", "las", "lad", "dasa", "sala", "kala", "jala", "fasa",
"daka", "kasa", "salla", "dalla", "jassa", "fassa", "kalla", "falla",
"salak", "dalas", "jasal", "kalas",
"lö", "döl", "söl", "jöl", "köl", "löl",
] },
{ world: 1, title: "Die Leertaste", subtitle: "Der Daumen kommt dazu", newKeys: [SPACE_KEY] },
// ---------------------------------------------------------------- World 2 -- interface YamlRoot {
// One key per lesson from here on, an Übung after every two. worlds: readonly YamlWorld[];
{ world: 2, title: "Das E", subtitle: "Mittelfinger links nach oben", newKeys: ["e"], }
words: ["elf", "alle", "esel", "see", "keks", "fell"] },
{ world: 2, title: "Das I", subtitle: "Mittelfinger rechts nach oben", newKeys: ["i"],
words: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
{ world: 2, title: "Übung: E und I", subtitle: "Die neuen Tasten festigen", newKeys: [],
words: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
{ world: 2, title: "Das R", subtitle: "Zeigefinger links nach oben", newKeys: ["r"],
words: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
{ world: 2, title: "Das U", subtitle: "Zeigefinger rechts nach oben", newKeys: ["u"],
words: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
{ world: 2, title: "Übung: R und U", subtitle: "Die Zeigefinger nach oben", newKeys: [],
words: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
{ world: 2, title: "Das T", subtitle: "Zeigefinger links weit nach oben", newKeys: ["t"],
words: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
{ world: 2, title: "Das Z", subtitle: "Zeigefinger rechts weit nach oben", newKeys: ["z"],
words: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
{ world: 2, title: "Übung: T und Z", subtitle: "Weit nach oben greifen", newKeys: [],
words: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
{ world: 2, title: "Das O", subtitle: "Ringfinger rechts nach oben", newKeys: ["o"],
words: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
{ world: 2, title: "Das W", subtitle: "Ringfinger links nach oben", newKeys: ["w"],
words: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
{ world: 2, title: "Übung: W und O", subtitle: "Die Ringfinger nach oben", newKeys: [],
words: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
{ world: 2, title: "Das P", subtitle: "Kleiner Finger rechts nach oben", newKeys: ["p"],
words: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
{ world: 2, title: "Das Q", subtitle: "Kleiner Finger links nach oben", newKeys: ["q"],
words: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
{ world: 2, title: "Das Ü", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ü"],
words: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
{ world: 2, title: "Übung: die obere Reihe", subtitle: "Die ganze Reihe zusammen", newKeys: [],
words: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
// ---------------------------------------------------------------- World 3 -- /** Turns a YAML typo into a clear startup error instead of a silently wrong lesson. */
{ world: 3, title: "Das N", subtitle: "Zeigefinger rechts nach unten", newKeys: ["n"], function validatePlan(worlds: readonly YamlWorld[]): void {
words: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] }, if (worlds.length !== CREATURE_IDS.length) {
{ world: 3, title: "Das M", subtitle: "Zeigefinger rechts, neben dem N", newKeys: ["m"], throw new Error(`curriculum.yaml: expected ${CREATURE_IDS.length} worlds, found ${worlds.length}`);
words: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] }, }
{ world: 3, title: "Übung: N und M", subtitle: "Die neuen Tasten festigen", newKeys: [], const rewards = new Set<CreatureId>();
words: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] }, for (const world of worlds) {
{ world: 3, title: "Das G", subtitle: "Zeigefinger links, in der Mitte", newKeys: ["g"], if (rewards.has(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} reuses reward "${world.reward}"`);
words: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] }, if (!CREATURE_IDS.includes(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} has an unknown reward "${world.reward}"`);
{ world: 3, title: "Das H", subtitle: "Zeigefinger rechts, in der Mitte", newKeys: ["h"], rewards.add(world.reward);
words: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
{ world: 3, title: "Übung: G und H", subtitle: "Die Mitte der Grundreihe", newKeys: [],
words: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
{ world: 3, title: "Das C", subtitle: "Mittelfinger links nach unten", newKeys: ["c"],
words: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
{ world: 3, title: "Das V", subtitle: "Zeigefinger links nach unten", newKeys: ["v"],
words: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
{ world: 3, title: "Übung: C und V", subtitle: "Nach unten greifen", newKeys: [],
words: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
{ world: 3, title: "Das B", subtitle: "Zeigefinger links, neben dem V", newKeys: ["b"],
words: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
{ world: 3, title: "Das Y", subtitle: "Kleiner Finger links nach unten", newKeys: ["y"],
words: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
{ world: 3, title: "Übung: B und Y", subtitle: "Ganz unten links", newKeys: [],
words: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
{ world: 3, title: "Das X", subtitle: "Ringfinger links nach unten", newKeys: ["x"],
words: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
{ world: 3, title: "Das Ä", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ä"],
words: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
{ world: 3, title: "Übung: alle Buchstaben", subtitle: "Das ganze Alphabet", newKeys: [],
words: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
// ---------------------------------------------------------------- World 4 -- world.lessons.forEach((entry, i) => {
{ world: 4, title: "Umschalttaste rechts", subtitle: "Große Buchstaben der linken Hand", newKeys: ["⇧"], const where = `world ${world.number}, lesson ${i + 1} (${entry.title})`;
words: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff", const words = entry.words ?? [];
"Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] }, const kind: LessonKind = entry.kind ?? "letters";
{ world: 4, title: "Umschalttaste links", subtitle: "Große Buchstaben der rechten Hand", newKeys: [], if (words.length > 0 && entry.kind === undefined) {
words: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe", throw new Error(`${where}: has words but no explicit kind`);
"Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] }, }
{ world: 4, title: "Übung: Namen", subtitle: "Namen fangen groß an", newKeys: [], if (kind === "letters" && words.length > 0) throw new Error(`${where}: kind "letters" cannot have words`);
words: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa", if (kind !== "letters" && words.length === 0) throw new Error(`${where}: kind "${kind}" needs a non-empty words list`);
"Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] }, if (entry.drill && entry.keys?.length) throw new Error(`${where}: a drill cannot also introduce keys`);
{ world: 4, title: "Übung: große und kleine", subtitle: "Beides gemischt", newKeys: [], if ((entry.keys?.length ?? 0) > 2) throw new Error(`${where}: at most two keys per lesson`);
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", if (entry.mode && !ELIGIBLE_MODES[kind].includes(entry.mode)) {
"Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] }, throw new Error(`${where}: mode "${entry.mode}" does not fit kind "${kind}"`);
}
});
}
}
// ---------------------------------------------------------------- World 5 -- function loadPlan(): readonly YamlWorld[] {
{ world: 5, title: "Der Punkt", subtitle: "Ringfinger rechts nach unten", newKeys: ["."], kind: "sentences", const root = parse(curriculumYaml) as YamlRoot;
words: [ validatePlan(root.worlds);
"Das Meer ist tief.", "Der Hund bellt.", "Ich mag Kekse.", "Die Sonne scheint.", return root.worlds;
"Der Wal ist riesig.", "Wir gehen baden.", "Mama liest ein Buch.", "Der Fisch schwimmt.", }
"Heute ist es warm.", "Ich habe einen Ball.", "Die Welle ist hoch.", "Papa kocht Suppe.",
] },
{ world: 5, title: "Das Komma", subtitle: "Mittelfinger rechts nach unten", newKeys: [","], kind: "sentences",
words: [
"Ich mag Wale, Delfine und Fische.", "Erst lesen, dann tippen.", "Es ist warm, also baden wir.",
"Rot, gelb und blau sind Farben.", "Wenn es regnet, bleiben wir drinnen.",
"Der Delfin springt, taucht und spielt.", "Morgen, sagt Papa, fahren wir los.",
"Eins, zwei, drei, vier.", "Oma, Opa und ich gehen schwimmen.",
"Die Sonne scheint, das Meer glitzert.", "Muscheln, Steine und Sand liegen am Strand.",
] },
{ world: 5, title: "Der Bindestrich", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["-"], kind: "sentences",
words: [
"Wir spielen mit dem Wasser-Ball.", "Das ist ein Delfin-Baby.", "Meine Ur-Oma kommt heute.",
"Wir bauen eine Sand-Burg.", "Der Fisch-Schwarm ist riesig.", "Ich trage mein T-Shirt.",
"Das Schwimm-Bad ist offen.", "Die Bade-Hose ist nass.", "Wir essen ein Eis-Hörnchen.",
"Das Segel-Boot ist blau.", "Mein Lieblings-Tier ist der Delfin.",
] },
{ world: 5, title: "Fragezeichen und Ausrufezeichen", subtitle: "Mit der Umschalttaste", newKeys: ["ß", "1"], kind: "sentences",
words: [
"Wo ist der Delfin?", "Das war toll!", "Wie geht es dir?", "Pass auf!",
"Kommst du mit?", "Der Wal ist so groß!", "Hast du Hunger?", "Hurra, Ferien!",
"Was schwimmt da?", "Schau mal, ein Hai!", "Wie tief ist das Meer?", "Wir haben es geschafft!",
] },
{ world: 5, title: "Übung: ganze Sätze", subtitle: "Alles zusammen", newKeys: [], kind: "sentences",
words: [
"Der Delfin schwimmt sehr schnell.", "Wo ist mein Boot?", "Ich tippe jetzt mit zehn Fingern!",
"Das Meer ist blau, tief und kalt.", "Kannst du das auch?", "Wir bauen eine Sand-Burg am Strand.",
"Die Möwe fliegt über das Wasser.", "Oma, Opa und ich gehen schwimmen.", "Das ist ja super!",
"Wie heißt der große Wal?", "Im Riff wohnen bunte Fische.", "Der Krake hat acht Arme.",
] },
];
function buildLessons(): Lesson[] { function buildLessons(worlds: readonly YamlWorld[]): Lesson[] {
const lessons: Lesson[] = []; const lessons: Lesson[] = [];
const active = new Set<string>(); const active = new Set<string>();
const seenBefore = new Set<string>();
// Letters rounds alternate bubbles/jellyfish across the *whole* course, not per world
// or per pair - a fragments/words lesson in between does not reset the count, so the
// arcade game never repeats twice in a row even across a consolidation gap.
let lastArcade: "bubbles" | "jellyfish" = "jellyfish"; // so lesson 1 opens on bubbles
PLAN.forEach((entry, i) => { for (const world of worlds) {
for (const key of entry.newKeys) active.add(key); for (const entry of world.lessons) {
// The space-bar lesson is where the whole home row comes together, so it const keys = entry.keys ?? [];
// activates every home key - a belt-and-braces guarantee that the four finger-pair const newKeys = keys.filter((key) => !seenBefore.has(key));
// lessons before it really did cover all eight. for (const key of keys) {
if (entry.newKeys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key); seenBefore.add(key);
active.add(key);
}
// The space-bar lesson is where the whole home row comes together, so it
// activates every home key - belt-and-braces confirmation that the four
// finger-pair lessons before it really did cover all eight.
if (keys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
// Shift is not a character the generator can emit - the capitals in the word list // Shift is not a character the generator can emit - the capitals in the word
// are what teaches it - so it never enters activeKeys. The question-mark lesson // list are what teaches it - so it never enters activeKeys.
// reaches its marks with Shift too, so ß and 1 are active as *keys* even though the const activeKeys = [...active].filter((key) => key !== "⇧").sort();
// characters that appear are ? and !. const words = entry.words ?? [];
const activeKeys = [...active].filter((key) => key !== "⇧").sort(); const kind: LessonKind = entry.kind ?? "letters";
const words = entry.words ?? []; const isDrill = entry.drill === true;
const kind: LessonKind = entry.kind ?? (words.length > 0 ? "words" : "letters");
lessons.push({ const emphasis: Lesson["emphasis"] =
id: `l${String(i + 1).padStart(2, "0")}`, kind !== "letters" || isDrill ? null : newKeys.length > 0 ? "isolated" : "mixed";
world: entry.world,
number: i + 1, let primaryMode: ModeId;
title: entry.title, if (kind === "letters") {
subtitle: entry.subtitle, primaryMode = entry.mode ?? (lastArcade === "bubbles" ? "jellyfish" : "bubbles");
newKeys: entry.newKeys, lastArcade = primaryMode as "bubbles" | "jellyfish";
activeKeys, } else {
modes: kind === "sentences" ? SENTENCE_MODES : kind === "words" ? WORD_MODES : LETTER_MODES, primaryMode = entry.mode ?? "dive";
words, }
kind, // Bonus replays are only ever feed/race - dive is the plain default, not a treat
isDrill: entry.newKeys.length === 0, // worth offering separately, and bubbles/jellyfish already alternate on their own.
...lengthFor(entry.world, kind), const bonusModes = ELIGIBLE_MODES[kind].filter(
}); (mode) => (mode === "feed" || mode === "race") && mode !== primaryMode,
}); );
lessons.push({
id: `l${String(lessons.length + 1).padStart(2, "0")}`,
world: world.number,
number: lessons.length + 1,
title: entry.title,
subtitle: entry.subtitle,
kind,
newKeys,
spotlightKeys: kind === "letters" && !isDrill ? keys : [],
emphasis,
activeKeys,
primaryMode,
bonusModes,
words,
isDrill,
...lengthFor(world.number, kind),
});
}
}
return lessons; return lessons;
} }
export const LESSONS: readonly Lesson[] = buildLessons(); const PLAN = loadPlan();
export const WORLDS: readonly World[] = PLAN.map((world) => ({
number: world.number,
title: world.title,
emoji: world.emoji,
reward: world.reward,
}));
export const LESSONS: readonly Lesson[] = buildLessons(PLAN);
const BY_ID = new Map(LESSONS.map((lesson) => [lesson.id, lesson])); const BY_ID = new Map(LESSONS.map((lesson) => [lesson.id, lesson]));

View File

@@ -38,29 +38,33 @@ export interface LineOptions {
chunkSize?: number; chunkSize?: number;
/** The key to over-represent, if any. */ /** The key to over-represent, if any. */
focusKey?: string | null; focusKey?: string | null;
/** The keys this lesson introduces. They take the majority of the line; everything /** The keys this lesson spotlights. They take the majority of the line; everything
* learned earlier keeps appearing as review. */ * learned earlier keeps appearing as review. */
newKeys?: readonly string[]; newKeys?: readonly string[];
/** How much of the line the spotlighted keys should take, in place of the default
* (see `NEW_KEY_SHARE`). An isolated round wants this high; a mixed round - the same
* keys again, but blended with everything else - wants it lower. */
newKeyShare?: number;
} }
/** The most of a line one key may ever occupy. Above this it stops being practice and /** The most of a line one key may ever occupy. Above this it stops being practice and
* starts being a stutter - and on a small key set it starves the other fingers. */ * starts being a stutter - and on a small key set it starves the other fingers. */
const MAX_FOCUS_SHARE = 0.3; const MAX_FOCUS_SHARE = 0.3;
/** How much of a line the lesson's *new* keys should take. The rest is review of /** How much of a line the lesson's spotlighted keys should take by default. The rest is
* everything learned so far, which is what stops the early lessons rotting while the * review of everything learned so far, which is what stops the early lessons rotting
* late ones are learned. * while the late ones are learned.
* *
* Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and * Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
* spent half the line on the left hand it had already taught - which is not what a * spent half the line on the left hand it had already taught - which is not what a
* lesson called "the right hand" should drill. */ * lesson called "the right hand" should drill. */
const NEW_KEY_SHARE = 0.6; const NEW_KEY_SHARE = 0.6;
/** Copies of each new key needed to reach `NEW_KEY_SHARE` of the pool, clamped so a /** Copies of each spotlighted key needed to reach `share` of the pool, clamped so a
* lesson with one new key and many old ones does not bury the review entirely. */ * lesson with one spotlighted key and many old ones does not bury the review entirely. */
function newKeyCopies(newCount: number, oldCount: number): number { function newKeyCopies(newCount: number, oldCount: number, share: number): number {
if (newCount === 0 || oldCount === 0) return 1; if (newCount === 0 || oldCount === 0) return 1;
const exact = (NEW_KEY_SHARE * oldCount) / (newCount * (1 - NEW_KEY_SHARE)); const exact = (share * oldCount) / (newCount * (1 - share));
return Math.max(1, Math.min(6, Math.round(exact))); return Math.max(1, Math.min(6, Math.round(exact)));
} }
@@ -81,6 +85,7 @@ function weighted(
activeKeys: readonly string[], activeKeys: readonly string[],
focusKey: string | null | undefined, focusKey: string | null | undefined,
newKeys: readonly string[] = [], newKeys: readonly string[] = [],
newKeyShare: number = NEW_KEY_SHARE,
): string[] { ): string[] {
const letters = activeKeys.filter((key) => key !== " "); const letters = activeKeys.filter((key) => key !== " ");
if (letters.length === 0) return []; if (letters.length === 0) return [];
@@ -91,7 +96,7 @@ function weighted(
const pool = [...letters]; const pool = [...letters];
if (newActive.length > 0 && oldActive.length > 0) { if (newActive.length > 0 && oldActive.length > 0) {
const copies = newKeyCopies(newActive.length, oldActive.length); const copies = newKeyCopies(newActive.length, oldActive.length, newKeyShare);
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key); for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
} }
@@ -137,8 +142,8 @@ export function drillChunks(
rng: Rng, rng: Rng,
options: LineOptions = {}, options: LineOptions = {},
): string[] { ): string[] {
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [] } = options; const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [], newKeyShare } = options;
const pool = weighted(activeKeys, focusKey, newKeys); const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return []; if (pool.length === 0) return [];
const letters = bagDraw(pool, chunks * chunkSize, rng); const letters = bagDraw(pool, chunks * chunkSize, rng);
@@ -225,8 +230,9 @@ export function letterStream(
count: number, count: number,
focusKey?: string | null, focusKey?: string | null,
newKeys: readonly string[] = [], newKeys: readonly string[] = [],
newKeyShare?: number,
): string[] { ): string[] {
const pool = weighted(activeKeys, focusKey, newKeys); const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return []; if (pool.length === 0) return [];
return bagDraw(pool, count, rng); return bagDraw(pool, count, rng);
} }

View File

@@ -0,0 +1,45 @@
/** The geometry of the zigzag lesson path - kept pure and out of the component so it can
* be tested the way `aquarium.ts`'s swim physics are. */
/** How many nodes make one full left-to-right-to-left swing. */
export const PATH_PERIOD = 6;
/** The furthest a node strays from the centre line, in px. */
export const PATH_AMPLITUDE = 110;
/** Vertical spacing between two nodes, in px. */
export const NODE_SPACING = 108;
/** Horizontal offset for the nth node of a world's path, centred on 0. A sine wave
* rather than a zigzag of straight segments, so the path reads as one smooth ribbon
* instead of a jagged staircase. */
export function xOffsetFor(indexInWorld: number): number {
return Math.sin((indexInWorld / PATH_PERIOD) * 2 * Math.PI) * PATH_AMPLITUDE;
}
export interface PathPoint {
x: number;
y: number;
}
/** The centre of the nth node, for both its own placement and the connector line. */
export function pointFor(indexInWorld: number): PathPoint {
return { x: xOffsetFor(indexInWorld), y: indexInWorld * NODE_SPACING + NODE_SPACING / 2 };
}
/** An SVG path string threading every node centre with a smooth curve - a straight
* polyline through a sine wave looks faceted; a vertical Bezier through each segment
* does not. Empty/one-point paths draw nothing, which is fine: a one-lesson world needs
* no connector. */
export function pathD(points: readonly PathPoint[]): string {
if (points.length < 2) return "";
const [first, ...rest] = points;
let d = `M ${first!.x} ${first!.y}`;
for (let i = 0; i < rest.length; i++) {
const from = points[i]!;
const to = rest[i]!;
const midY = (from.y + to.y) / 2;
d += ` C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`;
}
return d;
}

View File

@@ -0,0 +1,12 @@
/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
* lesson map's mode badge. */
import type { ModeId } from "./curriculum";
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" },
};

View File

@@ -46,7 +46,7 @@ export interface Settings {
} }
export interface Progress { export interface Progress {
version: 1; version: 2;
lessons: Record<string, LessonProgress>; lessons: Record<string, LessonProgress>;
keyStats: Record<string, KeyStat>; keyStats: Record<string, KeyStat>;
pearls: number; pearls: number;
@@ -66,7 +66,7 @@ export function freshProgress(): Progress {
lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID); lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
} }
return { return {
version: 1, version: 2,
lessons, lessons,
keyStats: {}, keyStats: {},
pearls: 0, pearls: 0,
@@ -76,18 +76,43 @@ export function freshProgress(): Progress {
}; };
} }
/** Everything that survives a curriculum rebuild - the pets, the currency, the per-key
* stats and the streak - pulled defensively off whatever shape was stored, old or new. */
function carryForward(stored: Record<string, unknown>, fresh: Progress): Omit<Progress, "version" | "lessons"> {
return {
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? (stored.keyStats as Progress["keyStats"]) : {},
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
streak:
typeof stored.streak === "object" && stored.streak !== null
? {
days: (stored.streak as Progress["streak"]).days ?? 0,
lastPlayed: (stored.streak as Progress["streak"]).lastPlayed ?? null,
}
: fresh.streak,
settings: { ...fresh.settings, ...((stored.settings as Partial<Settings>) ?? {}) },
};
}
/** Bring any stored value up to the current shape. Anything unrecognisable is thrown /** Bring any stored value up to the current shape. Anything unrecognisable is thrown
* away rather than trusted - a half-valid Progress would crash the lesson map, and a * away rather than trusted - a half-valid Progress would crash the lesson map, and a
* fresh one merely means starting over. */ * fresh one merely means starting over. */
export function migrate(raw: unknown): Progress { export function migrate(raw: unknown): Progress {
const fresh = freshProgress(); const fresh = freshProgress();
if (typeof raw !== "object" || raw === null) return fresh; if (typeof raw !== "object" || raw === null) return fresh;
const stored = raw as Partial<Progress>; const stored = raw as Record<string, unknown>;
if (stored.version !== 1) return fresh;
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum -
// "l09" used to be "Das E" and might be anything now - so there is nothing sensible to
// remap. She keeps her aquarium, pearls, key stats and streak, and starts the (longer,
// gentler) path over from the first lesson.
if (stored.version !== 2) {
return { version: 2, lessons: fresh.lessons, ...carryForward(stored, fresh) };
}
const lessons = { ...fresh.lessons }; const lessons = { ...fresh.lessons };
if (typeof stored.lessons === "object" && stored.lessons !== null) { if (typeof stored.lessons === "object" && stored.lessons !== null) {
for (const [id, value] of Object.entries(stored.lessons)) { for (const [id, value] of Object.entries(stored.lessons as Record<string, unknown>)) {
// Lessons that no longer exist in the curriculum are dropped silently. // Lessons that no longer exist in the curriculum are dropped silently.
if (!(id in lessons) || typeof value !== "object" || value === null) continue; if (!(id in lessons) || typeof value !== "object" || value === null) continue;
lessons[id] = { ...emptyLesson(false), ...value }; lessons[id] = { ...emptyLesson(false), ...value };
@@ -96,18 +121,7 @@ export function migrate(raw: unknown): Progress {
// The first lesson is unlocked by definition; a save that says otherwise is wrong. // The first lesson is unlocked by definition; a save that says otherwise is wrong.
lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true }; lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true };
return { return { version: 2, lessons, ...carryForward(stored, fresh) };
version: 1,
lessons,
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
streak:
typeof stored.streak === "object" && stored.streak !== null
? { days: stored.streak.days ?? 0, lastPlayed: stored.streak.lastPlayed ?? null }
: fresh.streak,
settings: { ...fresh.settings, ...(stored.settings ?? {}) },
};
} }
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature /** Saves from before the pets were drawings hold emoji here. Those map onto the creature

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumTiere.tsx","./src/components/Bubbles.tsx","./src/components/HandHint.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ModePicker.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BlasenRun.tsx","./src/components/modes/FuetternRun.tsx","./src/components/modes/PerlenRun.tsx","./src/components/modes/QuallenRun.tsx","./src/components/modes/RennenRun.tsx","./src/components/modes/TauchgangRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/speech.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumCreatures.tsx","./src/components/Bubbles.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BubblesRun.tsx","./src/components/modes/DiveRun.tsx","./src/components/modes/FeedRun.tsx","./src/components/modes/JellyfishRun.tsx","./src/components/modes/RaceRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/lessonPath.ts","./src/lib/modeInfo.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/lessonPath.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}

24
web/eslint.config.js Normal file
View File

@@ -0,0 +1,24 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config([
{
ignores: ["dist"],
},
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
},
]);

1501
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b", "check": "tsc -b && eslint .",
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
@@ -15,10 +15,16 @@
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.15.0",
"typescript": "^5.9.0", "typescript": "^5.9.0",
"typescript-eslint": "^8.70.0",
"vite": "^7.1.0", "vite": "^7.1.0",
"vitest": "^3.2.0" "vitest": "^3.2.0"
}, },

View File

@@ -154,6 +154,33 @@ export function App() {
void (state.playing ? api.pause() : api.resume()); void (state.playing ? api.pause() : api.resume());
}, [connection, library.albums, play, results.albums, state]); }, [connection, library.albums, play, results.albums, state]);
const onMute = useCallback(
() => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT),
[setVolume, state],
);
/** The one seek primitive: an optimistic position patch plus the backend call.
* Shared by dragging the progress bar (an absolute target) and Shift+Arrow /
* podcast skip (a target computed from the current position). */
const seekTo = useCallback(
(target: number) => {
connection.optimistic({ position: target });
void api.seek(target);
},
[connection],
);
const skipPodcast = useCallback(
(direction: 1 | -1) => {
const target =
direction > 0
? Math.min(state?.duration ?? 0, (state?.position ?? 0) + PODCAST_SKIP_SECONDS)
: Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
seekTo(target);
},
[seekTo, state],
);
/** The one place a keyboard action, a click or a tap all end up. */ /** The one place a keyboard action, a click or a tap all end up. */
// Read-modify-write the whole mapping, like ParentPanel does for settings: the // Read-modify-write the whole mapping, like ParentPanel does for settings: the
// backend takes a full replacement, not a per-slot patch. // backend takes a full replacement, not a per-slot patch.
@@ -218,12 +245,7 @@ export function App() {
case "next": case "next":
playPop(260); playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") { if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.min( skipPodcast(1);
state?.duration ?? 0,
(state?.position ?? 0) + PODCAST_SKIP_SECONDS,
);
connection.optimistic({ position: target });
void api.seek(target);
} else { } else {
connection.optimistic({ position: 0 }); connection.optimistic({ position: 0 });
void api.next(); void api.next();
@@ -232,9 +254,7 @@ export function App() {
case "previous": case "previous":
playPop(260); playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") { if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS); skipPodcast(-1);
connection.optimistic({ position: target });
void api.seek(target);
} else { } else {
connection.optimistic({ position: 0 }); connection.optimistic({ position: 0 });
void api.previous(); void api.previous();
@@ -249,8 +269,7 @@ export function App() {
0, 0,
Math.min(state.duration, (state?.position ?? 0) + action.delta), Math.min(state.duration, (state?.position ?? 0) + action.delta),
); );
connection.optimistic({ position: target }); seekTo(target);
void api.seek(target);
} }
break; break;
} }
@@ -258,12 +277,23 @@ export function App() {
playPop(action.freq); playPop(action.freq);
break; break;
case "mute": case "mute":
setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT); onMute();
break; break;
} }
} }
}, },
[assignCurrentTo, connection, currentAlbum, play, setVolume, state, toggle], [
assignCurrentTo,
connection,
currentAlbum,
onMute,
play,
seekTo,
setVolume,
skipPodcast,
state,
toggle,
],
); );
// Held in a ref so the listener is installed once rather than on every state change. // Held in a ref so the listener is installed once rather than on every state change.
@@ -356,11 +386,8 @@ export function App() {
}; };
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index); const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
const onSeek = (target: number) => { const onNext = useCallback(() => run([{ type: "next" }]), [run]);
connection.optimistic({ position: target }); const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
void api.seek(target);
};
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
// The single top-left "back" button, shared by every level of the music page - // The single top-left "back" button, shared by every level of the music page -
// search, categories, play view - so it always means "one step out" no matter what // search, categories, play view - so it always means "one step out" no matter what
@@ -425,9 +452,9 @@ export function App() {
state={state} state={state}
album={currentAlbum} album={currentAlbum}
onToggle={toggle} onToggle={toggle}
onNext={() => run([{ type: "next" }])} onNext={onNext}
onPrevious={() => run([{ type: "previous" }])} onPrevious={onPrevious}
onSeek={onSeek} onSeek={seekTo}
onVolume={setVolume} onVolume={setVolume}
onMute={onMute} onMute={onMute}
onOpenAlbum={onOpenCurrentAlbum} onOpenAlbum={onOpenCurrentAlbum}
@@ -514,9 +541,9 @@ export function App() {
state={state} state={state}
album={currentAlbum} album={currentAlbum}
onToggle={toggle} onToggle={toggle}
onNext={() => run([{ type: "next" }])} onNext={onNext}
onPrevious={() => run([{ type: "previous" }])} onPrevious={onPrevious}
onSeek={onSeek} onSeek={seekTo}
onVolume={setVolume} onVolume={setVolume}
onMute={onMute} onMute={onMute}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))} onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}

View File

@@ -30,32 +30,34 @@ function post(path: string, body?: unknown): Promise<void> {
}); });
} }
/** `null` means the room-control page isn't configured, not an error - unlike /** GET, treating a 404 as an expected "not configured" rather than an error - unlike
* `request()`, a 404 here is expected and shouldn't throw. */ * `request()`, which throws on it. Anything else that isn't ok still throws. */
async function fetchHaConfig(): Promise<HaConfig | null> { async function fetchOrNullOn404<T>(path: string): Promise<T | null> {
const response = await fetch("/api/ha"); const response = await fetch(`/api${path}`);
if (response.status === 404) return null; if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET /ha failed: ${response.status}`); if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`);
return (await response.json()) as HaConfig; return (await response.json()) as T;
} }
/** `null` means the IR remote isn't configured, not an error - same convention as /** GET, treating *any* non-ok response as "nothing to show" rather than an error - for
* `fetchHaConfig`. */ * callers that poll and would rather fall back quietly than crash the poll loop. */
async function fetchLircConfig(): Promise<LircConfig | null> { async function fetchOrNullOnError<T>(path: string): Promise<T | null> {
const response = await fetch("/api/lirc"); const response = await fetch(`/api${path}`);
if (response.status === 404) return null; if (!response.ok) return null;
if (!response.ok) throw new Error(`GET /lirc failed: ${response.status}`); return (await response.json()) as T;
return (await response.json()) as LircConfig;
} }
/** `null` means the room-control page isn't configured, not an error. */
const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha");
/** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable /** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
* right now" (the backend answers the latter with a 502) - the room page treats a * right now" (the backend answers the latter with a 502) - the room page treats a
* device with no state the same way either way, rather than crashing on a poll. */ * device with no state the same way either way, rather than crashing on a poll. */
async function fetchHaState(entityId: string): Promise<HaEntityState | null> { const fetchHaState = (entityId: string): Promise<HaEntityState | null> =>
const response = await fetch(`/api/ha/states/${entityId}`); fetchOrNullOnError<HaEntityState>(`/ha/states/${entityId}`);
if (!response.ok) return null;
return (await response.json()) as HaEntityState;
}
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> { async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
const results = await Promise.all(entityIds.map(fetchHaState)); const results = await Promise.all(entityIds.map(fetchHaState));
@@ -68,11 +70,8 @@ async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEnti
/** `null` means "not analyzed" (the backend's expected 404 for this), not an error - /** `null` means "not analyzed" (the backend's expected 404 for this), not an error -
* the ambient background just falls back to its un-analyzed baseline for that track. */ * the ambient background just falls back to its un-analyzed baseline for that track. */
async function fetchTrackDetail(albumId: string, trackIndex: number): Promise<TrackDetail | null> { const fetchTrackDetail = (albumId: string, trackIndex: number): Promise<TrackDetail | null> =>
const response = await fetch(`/api/tracks/${albumId}/${trackIndex}/analysis`); fetchOrNullOnError<TrackDetail>(`/tracks/${albumId}/${trackIndex}/analysis`);
if (!response.ok) return null;
return (await response.json()) as TrackDetail;
}
export const api = { export const api = {
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums), library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),

View File

@@ -234,7 +234,6 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
const baseRef = useRef(ambienceBaseFor(album, trackAnalysis)); const baseRef = useRef(ambienceBaseFor(album, trackAnalysis));
useEffect(() => { useEffect(() => {
baseRef.current = ambienceBaseFor(album, trackAnalysis); baseRef.current = ambienceBaseFor(album, trackAnalysis);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album, trackAnalysis]); }, [album, trackAnalysis]);
useEffect(() => { useEffect(() => {

View File

@@ -6,6 +6,7 @@
* the whole page. */ * the whole page. */
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
import type { Album } from "../api/types"; import type { Album } from "../api/types";
import { import {
@@ -546,7 +547,7 @@ function ShelfRow({
children, children,
focusedIndex, focusedIndex,
}: { }: {
children: React.ReactNode; children: ReactNode;
/** Which tile in this row the keyboard is on, or `null` while some other row has /** Which tile in this row the keyboard is on, or `null` while some other row has
* focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the * focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the
* horizontal equivalent of the outer scroller's own keep-in-view effect above. */ * horizontal equivalent of the outer scroller's own keep-in-view effect above. */
@@ -580,7 +581,7 @@ function ShelfRow({
return () => element.removeEventListener("wheel", onWheel); return () => element.removeEventListener("wheel", onWheel);
}, []); }, []);
const onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => { const onMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
const element = ref.current; const element = ref.current;
if (event.button !== 0 || !element) return; if (event.button !== 0 || !element) return;
event.preventDefault(); // no text-selection/ghost-drag while panning event.preventDefault(); // no text-selection/ghost-drag while panning
@@ -632,7 +633,7 @@ function CategoryTile({
entry: Category; entry: Category;
navIndex?: number; navIndex?: number;
selected?: boolean; selected?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void; onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
}) { }) {
const books = entry.albums.filter(isBook).length; const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length; const allBooks = books === entry.albums.length;
@@ -728,7 +729,7 @@ function SectionTitle({
children, children,
centered, centered,
}: { }: {
children: React.ReactNode; children: ReactNode;
centered?: boolean; centered?: boolean;
}) { }) {
return ( return (
@@ -750,7 +751,7 @@ function SectionTitle({
); );
} }
function Muted({ children }: { children: React.ReactNode }) { function Muted({ children }: { children: ReactNode }) {
return ( return (
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}> <span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
{children} {children}

View File

@@ -7,6 +7,8 @@
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio. * taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
*/ */
import type { CSSProperties } from "react";
import { coverUrl } from "../api/client"; import { coverUrl } from "../api/client";
import type { Album } from "../api/types"; import type { Album } from "../api/types";
import { aspectOf, coverBackground, isBook } from "../lib/covers"; import { aspectOf, coverBackground, isBook } from "../lib/covers";
@@ -21,7 +23,7 @@ interface Props {
fit?: "width" | "height"; fit?: "width" | "height";
radius?: string; radius?: string;
className?: string; className?: string;
style?: React.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;
} }
@@ -29,7 +31,7 @@ interface Props {
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) { export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
const book = isBook(album); const book = isBook(album);
const defaultRadius = book ? "6px 18px 18px 6px" : "16px"; const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
const box: React.CSSProperties = const box: CSSProperties =
fit === "height" fit === "height"
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) } ? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
: { width: "100%", aspectRatio: aspectOf(album) }; : { width: "100%", aspectRatio: aspectOf(album) };

View File

@@ -2,7 +2,7 @@
import type { Album, PlayerState } from "../api/types"; import type { Album, PlayerState } from "../api/types";
import { usePlaybackClock } from "../hooks/usePlaybackClock"; import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { albumLine, isBook } from "../lib/covers"; import { isBook, nowPlayingText } from "../lib/covers";
import { clock, remainingInAlbum } from "../lib/format"; import { clock, remainingInAlbum } from "../lib/format";
import { groupOf } from "../lib/search"; import { groupOf } from "../lib/search";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme"; import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
@@ -41,6 +41,7 @@ export function PlayView({
assignPending, assignPending,
}: Props) { }: Props) {
const position = usePlaybackClock(state.position, state.playing); const position = usePlaybackClock(state.position, state.playing);
const now = nowPlayingText(state, album);
const book = album ? isBook(album) : false; const book = album ? isBook(album) : false;
const podcast = album ? groupOf(album) === "podcasts" : false; const podcast = album ? groupOf(album) === "podcasts" : false;
const remaining = album const remaining = album
@@ -138,7 +139,7 @@ export function PlayView({
textWrap: "pretty", textWrap: "pretty",
}} }}
> >
{state.track_title ?? "Wähl ein Album!"} {now.title}
</div> </div>
<div <div
style={{ style={{
@@ -148,7 +149,7 @@ export function PlayView({
marginTop: 8, marginTop: 8,
}} }}
> >
{album ? albumLine(album) : "Tippen oder klicken"} {now.subtitle}
</div> </div>
{album && ( {album && (
<div <div
@@ -159,7 +160,7 @@ export function PlayView({
marginTop: 6, marginTop: 6,
}} }}
> >
{book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count} {now.progress}
</div> </div>
)} )}
</div> </div>
@@ -169,7 +170,7 @@ export function PlayView({
position={position} position={position}
duration={state.duration} duration={state.duration}
onSeek={onSeek} onSeek={onSeek}
resetKey={`${state.album_id ?? ""}:${state.track_index}`} resetKey={now.resetKey}
height={14} height={14}
interactive interactive
/> />

View File

@@ -2,7 +2,7 @@
import type { Album, PlayerState } from "../api/types"; import type { Album, PlayerState } from "../api/types";
import { usePlaybackClock } from "../hooks/usePlaybackClock"; import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { albumLine, isBook } from "../lib/covers"; import { nowPlayingText } from "../lib/covers";
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme"; import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
import { Cover } from "./Cover"; import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
@@ -33,6 +33,7 @@ export function PlayerBar({
onOpenPlayView, onOpenPlayView,
}: Props) { }: Props) {
const position = usePlaybackClock(state.position, state.playing); const position = usePlaybackClock(state.position, state.playing);
const now = nowPlayingText(state, album);
return ( return (
<div <div
style={{ style={{
@@ -100,7 +101,7 @@ export function PlayerBar({
textOverflow: "ellipsis", textOverflow: "ellipsis",
}} }}
> >
{state.track_title ?? "Wähl ein Album!"} {now.title}
</div> </div>
<div <div
style={{ style={{
@@ -112,7 +113,7 @@ export function PlayerBar({
textOverflow: "ellipsis", textOverflow: "ellipsis",
}} }}
> >
{album ? albumLine(album) : "Tippen oder klicken"} {now.subtitle}
</div> </div>
{album && ( {album && (
<div <div
@@ -123,8 +124,7 @@ export function PlayerBar({
marginTop: 1, marginTop: 1,
}} }}
> >
{isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "} {now.progress}
{state.track_count}
</div> </div>
)} )}
</div> </div>
@@ -134,7 +134,7 @@ export function PlayerBar({
position={position} position={position}
duration={state.duration} duration={state.duration}
onSeek={onSeek} onSeek={onSeek}
resetKey={`${state.album_id ?? ""}:${state.track_index}`} resetKey={now.resetKey}
height={10} height={10}
interactive interactive
/> />

View File

@@ -1,6 +1,7 @@
/** The progress bar, and the one thing the mockup could not do: scrubbing. */ /** The progress bar, and the one thing the mockup could not do: scrubbing. */
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import type { PointerEvent } from "react";
interface Props { interface Props {
position: number; position: number;
@@ -45,7 +46,7 @@ export function ProgressBar({
const shown = dragging ?? position; const shown = dragging ?? position;
const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0; const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0;
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!interactive || !duration) return; if (!interactive || !duration) return;
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
setDragging(positionAt(event.clientX)); setDragging(positionAt(event.clientX));

View File

@@ -29,6 +29,11 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
// when the patch landed resolves with pre-click data - without this, that stale // when the patch landed resolves with pre-click data - without this, that stale
// response clobbers the optimistic "on" back to "off" until the *next* poll catches // response clobbers the optimistic "on" back to "off" until the *next* poll catches
// up, which is what made toggling feel laggy despite the optimistic update existing. // up, which is what made toggling feel laggy despite the optimistic update existing.
//
// `usePlayerState`'s `optimistic()` solves the same "local guess vs. eventual truth"
// problem far more simply, by just patching the whole object - it can get away with
// that because the backend pushes over a websocket rather than being polled, so
// there's no in-flight request that can resolve late and stomp on a newer patch.
const optimisticAt = useRef<Record<string, number>>({}); const optimisticAt = useRef<Record<string, number>>({});
const entityIds = useMemo( const entityIds = useMemo(

View File

@@ -15,7 +15,12 @@ const RECONNECT_DELAY_MS = 1500;
export interface Connection { export interface Connection {
state: PlayerState | null; state: PlayerState | null;
online: boolean; online: boolean;
/** Apply a change locally so a keypress feels instant; the next frame reconciles. */ /** Apply a change locally so a keypress feels instant; the next frame reconciles.
*
* A plain whole-object patch is enough here because state only ever arrives pushed
* over the websocket - there's no in-flight poll that could resolve late and stomp
* on it. `useHomeAssistant`'s `optimistic()` solves the same problem for a *polled*
* source, where that race is real, with a per-entity timestamp guard instead. */
optimistic: (patch: Partial<PlayerState>) => void; optimistic: (patch: Partial<PlayerState>) => void;
} }
@@ -34,13 +39,21 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
const connect = () => { const connect = () => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const protocol = location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${location.host}/api/ws`); const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);
socket = ws;
socket.onopen = () => { socket.onopen = () => {
setOnline(true); setOnline(true);
// The server sends a snapshot on connect, but a reconnect may have missed // The server sends a snapshot on connect, but a reconnect may have missed
// changes in between, so ask for the truth as well. // changes in between, so ask for the truth as well. Guarded against a slow
void api.state().then(setState).catch(() => undefined); // response landing after a *later* reconnect already replaced this socket -
// every other fetch-on-mount hook in this codebase guards the same way.
void api
.state()
.then((fetched) => {
if (!closed && socket === ws) setState(fetched);
})
.catch(() => undefined);
}; };
socket.onmessage = (event) => { socket.onmessage = (event) => {

View File

@@ -6,7 +6,7 @@
* artwork agree - and so do the LED strips, which run the same primary colour. * artwork agree - and so do the LED strips, which run the same primary colour.
*/ */
import type { Album, AlbumKind } from "../api/types"; import type { Album, AlbumKind, PlayerState } from "../api/types";
import { GROUP_HUE } from "./theme"; import { GROUP_HUE } from "./theme";
export const isBook = (album: Album): boolean => album.kind === "book"; export const isBook = (album: Album): boolean => album.kind === "book";
@@ -14,8 +14,8 @@ export const isBook = (album: Album): boolean => album.kind === "book";
/** Shape is how you tell the two apart without reading anything: albums are square, /** Shape is how you tell the two apart without reading anything: albums are square,
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview, * audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */ * player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
export const ALBUM_ASPECT = 1; const ALBUM_ASPECT = 1;
export const BOOK_ASPECT = 0.82; const BOOK_ASPECT = 0.82;
export const aspectOfKind = (kind: AlbumKind): number => export const aspectOfKind = (kind: AlbumKind): number =>
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT; kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
@@ -29,7 +29,7 @@ const colours = (album: Album): [string, string, string] => [
]; ];
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */ /** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
export function stripes(album: Album, width: number): string { function stripes(album: Album, width: number): string {
const [primary, secondary] = colours(album); const [primary, secondary] = colours(album);
return ( return (
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` + `repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
@@ -82,3 +82,24 @@ export const albumLine = (album: Album): string => {
? `${prefix}${album.title} · ${album.artist}` ? `${prefix}${album.title} · ${album.artist}`
: `${prefix}${album.title}`; : `${prefix}${album.title}`;
}; };
export interface NowPlayingText {
title: string;
subtitle: string;
/** "Kapitel 3 von 8" or "Song 3 von 8" - only meaningful while an album is loaded. */
progress: string;
/** Changes whenever the track itself changes, so a progress bar can reset its drag
* state instead of animating across two unrelated tracks. */
resetKey: string;
}
/** The title/subtitle/progress text shared by the player bar and the full-screen
* now-playing view, so the two cannot drift apart on a copy change. */
export function nowPlayingText(state: PlayerState, album: Album | null): NowPlayingText {
return {
title: state.track_title ?? "Wähl ein Album!",
subtitle: album ? albumLine(album) : "Tippen oder klicken",
progress: `${album && isBook(album) ? "Kapitel" : "Song"} ${state.track_index + 1} von ${state.track_count}`,
resetKey: `${state.album_id ?? ""}:${state.track_index}`,
};
}

View File

@@ -1,10 +1,16 @@
/** The browse screen's shelf/row look, in one place: per-group colors and identity, /** Per-group colors and identity, and the feature toggles that have gone back and
* and the feature toggles that have gone back and forth while this design was under * forth while this design was under review - flip one here rather than hunting
* review. Flip a toggle here rather than hunting through BrowseView.tsx/covers.ts. * through the components that read it. Most toggles are about the browse screen's
* shelf/row look, but some (`SHOW_GLASS_BLUR`, `SHOW_AMBIENCE`,
* `SHOW_DECORATIVE_ANIMATIONS`) reach further: the play view, the player bar, the
* room page and `App.tsx` all read this module too, wherever they share the same
* "cut this on weak hardware" knob.
* *
* `SHOW_ROW_TITLES` and `SHOW_ROW_ICONS` both `true` reproduces the original shelf * `SHOW_ROW_TITLES` and `SHOW_ROW_ICONS` both `true` reproduces the original shelf
* header - an icon badge plus label button above each row's tiles. */ * header - an icon badge plus label button above each row's tiles. */
import type { CSSProperties } from "react";
import type { Group } from "./search"; import type { Group } from "./search";
// ---------------------------------------------------------------- toggles -- // ---------------------------------------------------------------- toggles --
@@ -70,7 +76,7 @@ export const GROUP_LABEL: Record<Group, string> = {
/** A shelf/row's frosted background and border, tinted with its group's hue - or, /** A shelf/row's frosted background and border, tinted with its group's hue - or,
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */ * with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
export function glassTint(hue: number): React.CSSProperties { export function glassTint(hue: number): CSSProperties {
if (!SHOW_ROW_TINT) return {}; if (!SHOW_ROW_TINT) return {};
return { return {
background: `linear-gradient(160deg, oklch(55% 0.1 ${hue} / .32), oklch(30% 0.06 ${hue} / .14))`, background: `linear-gradient(160deg, oklch(55% 0.1 ${hue} / .32), oklch(30% 0.06 ${hue} / .14))`,
@@ -81,7 +87,7 @@ export function glassTint(hue: number): React.CSSProperties {
/** One list row's background, tinted with its group's hue and brighter/more opaque /** One list row's background, tinted with its group's hue and brighter/more opaque
* while it's the currently-playing row - or, with `SHOW_ROW_TINT` off, the same * while it's the currently-playing row - or, with `SHOW_ROW_TINT` off, the same
* neutral highlight the row used before tinting existed. */ * neutral highlight the row used before tinting existed. */
export function rowTint(hue: number, highlighted: boolean): React.CSSProperties { export function rowTint(hue: number, highlighted: boolean): CSSProperties {
if (!SHOW_ROW_TINT) { if (!SHOW_ROW_TINT) {
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` }; return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
} }