From a5210fead2a83b1b4f75874abc040bf570765211 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sat, 12 Sep 2026 18:58:02 +0200 Subject: [PATCH] Typing lessons like duolingo & musicmouse cleanup --- python-backend/musicmouse/__main__.py | 8 +- python-backend/musicmouse/app.py | 5 + python-backend/musicmouse/devices/player.py | 4 + python-backend/musicmouse/events.py | 6 +- .../musicmouse/reactions/lighting.py | 2 +- .../musicmouse/reactions/playback.py | 8 +- .../musicmouse/services/lirc/service.py | 6 +- .../musicmouse/services/mqtt/player.py | 23 +- .../musicmouse/services/mqtt/service.py | 2 +- python-backend/musicmouse/services/web/api.py | 4 +- .../musicmouse/services/web/settings.py | 2 + .../musicmouse/services/web/state.py | 2 +- python-backend/musicmouse/simulator/driver.py | 8 +- .../musicmouse/simulator/fake_player.py | 5 - python-backend/tests/test_lirc.py | 4 +- python-backend/tests/test_mqtt.py | 31 +- python-backend/tests/test_player.py | 251 ++- tippen/package-lock.json | 18 +- tippen/package.json | 3 +- tippen/src/App.tsx | 66 +- tippen/src/components/LessonMap.tsx | 214 ++- tippen/src/components/ModePicker.tsx | 40 - tippen/src/components/ResultSheet.tsx | 35 + tippen/src/components/modes/DiveRun.tsx | 8 +- tippen/src/components/modes/PearlsRun.tsx | 155 -- tippen/src/data/curriculum.yaml | 422 +++++ tippen/src/lib/__tests__/curriculum.test.ts | 55 +- tippen/src/lib/__tests__/lessonPath.test.ts | 44 + tippen/src/lib/__tests__/progress.test.ts | 29 +- tippen/src/lib/curriculum.ts | 419 ++--- tippen/src/lib/generator.ts | 30 +- tippen/src/lib/lessonPath.ts | 45 + tippen/src/lib/modeInfo.ts | 12 + tippen/src/lib/progress.ts | 48 +- tippen/tsconfig.tsbuildinfo | 2 +- web/eslint.config.js | 24 + web/package-lock.json | 1501 +++++++++++++++++ web/package.json | 8 +- web/src/App.tsx | 75 +- web/src/api/client.ts | 45 +- web/src/components/Ambience.tsx | 1 - web/src/components/BrowseView.tsx | 11 +- web/src/components/Cover.tsx | 6 +- web/src/components/PlayView.tsx | 11 +- web/src/components/PlayerBar.tsx | 12 +- web/src/components/ProgressBar.tsx | 3 +- web/src/hooks/useHomeAssistant.ts | 5 + web/src/hooks/usePlayerState.ts | 21 +- web/src/lib/covers.ts | 29 +- web/src/lib/theme.ts | 16 +- 50 files changed, 3074 insertions(+), 710 deletions(-) delete mode 100644 tippen/src/components/ModePicker.tsx delete mode 100644 tippen/src/components/modes/PearlsRun.tsx create mode 100644 tippen/src/data/curriculum.yaml create mode 100644 tippen/src/lib/__tests__/lessonPath.test.ts create mode 100644 tippen/src/lib/lessonPath.ts create mode 100644 tippen/src/lib/modeInfo.ts create mode 100644 web/eslint.config.js diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index bb231c1..cdcbabe 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -297,13 +297,17 @@ def _build_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: """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 there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to 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 @@ -339,7 +343,7 @@ def _build_services( # 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. - web_service = next((s for s in services if isinstance(s, WebService)), None) + web_service = _service_of_type(services, WebService) services.append( PodcastFeedService( app.library, diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py index 3f6f167..288b7ee 100644 --- a/python-backend/musicmouse/app.py +++ b/python-backend/musicmouse/app.py @@ -12,6 +12,7 @@ from musicmouse.config import Config, FigureColors from musicmouse.devices.mouse import MusicMouseDevice from musicmouse.devices.player import Player from musicmouse.library import MusicLibrary +from musicmouse.library.models import Album from musicmouse.media import Playlist _log = logging.getLogger(__name__) @@ -55,6 +56,10 @@ class App: _log.warning("No playlist for figure %r", figure) return playlist + def album_for(self, playlist: Playlist | None) -> Album | None: + """The library album a playlist came from, if any.""" + return self.library.get(playlist.album_id if playlist else None) + async def rescan_library( self, *, broadcast: Callable[[], Awaitable[None]] | None = None ) -> None: diff --git a/python-backend/musicmouse/devices/player.py b/python-backend/musicmouse/devices/player.py index 8acf7ce..fb4d717 100644 --- a/python-backend/musicmouse/devices/player.py +++ b/python-backend/musicmouse/devices/player.py @@ -267,6 +267,8 @@ class VlcPlayer(PlayerBase): _log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist)) def play(self) -> None: + if self._playing: + return self._list_player.play() 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))) def pause(self) -> None: + if not self._playing: + return self._media_player.set_pause(1) def stop(self) -> None: diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index b967765..099670c 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -41,7 +41,7 @@ __all__ = [ "PlaySeriesLatestRequested", "PlaybackChanged", "PlaylistFinished", - "PrevTrackRequested", + "PreviousTrackRequested", "RfidTokenRead", "RotaryTurned", "SetVolumeRequested", @@ -135,8 +135,6 @@ class RotaryTurned(InputEvent): class PlaylistFinished(InputEvent): """The player reached the end of the playlist.""" - figure: str | None = None - @dataclass(frozen=True, slots=True, kw_only=True) class DeviceConnected(InputEvent): @@ -168,7 +166,7 @@ class NextTrackRequested(IntentEvent): @dataclass(frozen=True, slots=True, kw_only=True) -class PrevTrackRequested(IntentEvent): +class PreviousTrackRequested(IntentEvent): pass diff --git a/python-backend/musicmouse/reactions/lighting.py b/python-backend/musicmouse/reactions/lighting.py index 59f91a7..1fd0881 100644 --- a/python-backend/musicmouse/reactions/lighting.py +++ b/python-backend/musicmouse/reactions/lighting.py @@ -74,7 +74,7 @@ def web_playback(event: PlaybackChanged, app: App) -> None: off_animation(app) return - album = app.library.get(event.playlist.album_id if event.playlist else None) + album = app.album_for(event.playlist) if album is None: return web_animation(app, parse_color(album.colors[0])) diff --git a/python-backend/musicmouse/reactions/playback.py b/python-backend/musicmouse/reactions/playback.py index e9e53cc..2ad9d90 100644 --- a/python-backend/musicmouse/reactions/playback.py +++ b/python-backend/musicmouse/reactions/playback.py @@ -20,7 +20,7 @@ from musicmouse.events import ( PlaylistFinished, PlayRequested, PlaySeriesLatestRequested, - PrevTrackRequested, + PreviousTrackRequested, RotaryTurned, SeekRequested, SetVolumeRequested, @@ -118,7 +118,7 @@ def button_pressed(event: ButtonEvent, app: App) -> None: if event.action is not ButtonAction.PRESSED: return if event.button is Button.LEFT and app.player.is_playing: - app.bus.emit(PrevTrackRequested(source="device")) + app.bus.emit(PreviousTrackRequested(source="device")) elif event.button is Button.RIGHT and app.player.is_playing: app.bus.emit(NextTrackRequested(source="device")) # The rotary press is published to Home Assistant by the MQTT service; what it @@ -143,8 +143,8 @@ def next_track(event: NextTrackRequested, app: App) -> None: app.player.next_track() -@on(PrevTrackRequested) -def previous_track(event: PrevTrackRequested, app: App) -> None: +@on(PreviousTrackRequested) +def previous_track(event: PreviousTrackRequested, app: App) -> None: _log.debug("Previous track (%s)", event.source) app.player.previous_track() diff --git a/python-backend/musicmouse/services/lirc/service.py b/python-backend/musicmouse/services/lirc/service.py index 9f9079c..9963fba 100644 --- a/python-backend/musicmouse/services/lirc/service.py +++ b/python-backend/musicmouse/services/lirc/service.py @@ -23,7 +23,7 @@ from musicmouse.events import ( PlayAlbumRequested, PlayRequested, PlaySeriesLatestRequested, - PrevTrackRequested, + PreviousTrackRequested, SetVolumeRequested, VolumeChangeRequested, ) @@ -40,8 +40,8 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = { # pauses, like its pause button. "KEY_PAUSE": lambda: PauseRequested(source="lirc"), "KEY_STOP": lambda: PauseRequested(source="lirc"), - "KEY_PREVIOUS": lambda: PrevTrackRequested(source="lirc"), - "KEY_REWIND": lambda: PrevTrackRequested(source="lirc"), + "KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"), + "KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"), "KEY_FORWARD": lambda: NextTrackRequested(source="lirc"), } diff --git a/python-backend/musicmouse/services/mqtt/player.py b/python-backend/musicmouse/services/mqtt/player.py index 20b1501..aab8f3f 100644 --- a/python-backend/musicmouse/services/mqtt/player.py +++ b/python-backend/musicmouse/services/mqtt/player.py @@ -13,6 +13,7 @@ from typing import Any, ClassVar from musicmouse.bus import EventBus from musicmouse.config import MqttConfig +from musicmouse.devices.mouse import MusicMouseDevice from musicmouse.devices.player import Player from musicmouse.events import ( ActiveFigureChanged, @@ -22,7 +23,7 @@ from musicmouse.events import ( PauseRequested, PlaybackChanged, PlayRequested, - PrevTrackRequested, + PreviousTrackRequested, SetVolumeRequested, TrackChanged, VolumeChanged, @@ -37,9 +38,11 @@ __all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"] class PlayerSensor(Entity): 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._figure: str | None = None super().__init__(bus, config, object_id="player", name="Music Mouse Player") def discovery_payload(self) -> dict[str, Any]: @@ -56,9 +59,7 @@ class PlayerSensor(Entity): for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged): self.bus.subscribe(event_type, self._on_change) - async def _on_change(self, event: Event) -> None: - if isinstance(event, ActiveFigureChanged): - self._figure = event.figure + async def _on_change(self, _event: Event) -> None: await self.publish_state() async def publish_state(self) -> None: @@ -68,7 +69,7 @@ class PlayerSensor(Entity): await self.publish( f"{self.base_topic}/attributes", { - "figure": self._figure, + "figure": self.mouse.active_figure, "playlist": playlist.name if playlist else None, "track_index": self.player.track_index, "track_count": len(playlist) if playlist else 0, @@ -128,7 +129,7 @@ class TransportButton(Entity): #: Button object id -> the intent pressing it emits. INTENTS: ClassVar[dict[str, type[IntentEvent]]] = { "next": NextTrackRequested, - "previous": PrevTrackRequested, + "previous": PreviousTrackRequested, "play": PlayRequested, "pause": PauseRequested, } @@ -154,7 +155,9 @@ class TransportButton(Entity): 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 = { "next": "Music Mouse Next", "previous": "Music Mouse Previous", @@ -162,7 +165,7 @@ def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[E "pause": "Music Mouse Pause", } return [ - PlayerSensor(bus, config, player), + PlayerSensor(bus, config, mouse, player), VolumeNumber(bus, config, player), *(TransportButton(bus, config, action, name) for action, name in names.items()), ] diff --git a/python-backend/musicmouse/services/mqtt/service.py b/python-backend/musicmouse/services/mqtt/service.py index 5dcb081..499117d 100644 --- a/python-backend/musicmouse/services/mqtt/service.py +++ b/python-backend/musicmouse/services/mqtt/service.py @@ -41,7 +41,7 @@ def build_entities( """Everything this backend exposes to Home Assistant.""" return [ *(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), ] diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index d81c162..5937a45 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -27,7 +27,7 @@ from musicmouse.events import ( PauseRequested, PlayAlbumRequested, PlayRequested, - PrevTrackRequested, + PreviousTrackRequested, SeekRequested, SetVolumeRequested, ) @@ -152,7 +152,7 @@ def build_router( @router.post("/previous", status_code=204) def previous_track() -> Response: - return emit(PrevTrackRequested(source="web")) + return emit(PreviousTrackRequested(source="web")) @router.post("/seek", status_code=204) def seek(body: SeekIn) -> Response: diff --git a/python-backend/musicmouse/services/web/settings.py b/python-backend/musicmouse/services/web/settings.py index 2315f2b..5e964a9 100644 --- a/python-backend/musicmouse/services/web/settings.py +++ b/python-backend/musicmouse/services/web/settings.py @@ -34,6 +34,8 @@ __all__ = [ def to_device_volume(percent: int, general: GeneralConfig) -> int: """Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``.""" span = general.max_volume - general.min_volume + if span <= 0: + return general.min_volume return general.min_volume + round(span * max(0, min(100, percent)) / 100) diff --git a/python-backend/musicmouse/services/web/state.py b/python-backend/musicmouse/services/web/state.py index 9813205..576cfe2 100644 --- a/python-backend/musicmouse/services/web/state.py +++ b/python-backend/musicmouse/services/web/state.py @@ -17,7 +17,7 @@ __all__ = ["snapshot"] def snapshot(app: App) -> PlayerStateOut: player = app.player playlist = player.playlist - album = app.library.get(playlist.album_id if playlist else None) + album = app.album_for(playlist) index = player.track_index # The player only knows a file path, and a tagged file's name is rarely its title # ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time. diff --git a/python-backend/musicmouse/simulator/driver.py b/python-backend/musicmouse/simulator/driver.py index d7e713a..29e0dbe 100644 --- a/python-backend/musicmouse/simulator/driver.py +++ b/python-backend/musicmouse/simulator/driver.py @@ -26,7 +26,7 @@ from musicmouse.events import ( PauseRequested, PlayAlbumRequested, PlayRequested, - PrevTrackRequested, + PreviousTrackRequested, RfidTokenRead, RotaryTurned, SeekRequested, @@ -164,7 +164,7 @@ class SimulatorDriver: await self.settle() async def emit_prev(self) -> None: - self.app.bus.emit(PrevTrackRequested(source="simulator")) + self.app.bus.emit(PreviousTrackRequested(source="simulator")) await self.settle() async def set_volume(self, volume: int) -> None: @@ -235,9 +235,7 @@ class SimulatorDriver: case "playlist": return player.playlist.name if player.playlist else "none" case "album": - album = self.app.library.get( - player.playlist.album_id if player.playlist else None - ) + album = self.app.album_for(player.playlist) return album.title if album else "none" case "position": return f"{player.position:.1f}" diff --git a/python-backend/musicmouse/simulator/fake_player.py b/python-backend/musicmouse/simulator/fake_player.py index f62e3ab..22ac80a 100644 --- a/python-backend/musicmouse/simulator/fake_player.py +++ b/python-backend/musicmouse/simulator/fake_player.py @@ -42,7 +42,6 @@ class FakePlayer(PlayerBase): self._remaining = track_duration self._started_at: float | None = None self._timer: asyncio.Task[None] | None = None - self.closed = False # -------------------------------------------------------------------- state @@ -112,12 +111,8 @@ class FakePlayer(PlayerBase): if self._playing: self._start_timer() - async def run(self) -> None: - return - def close(self) -> None: self._cancel_timer() - self.closed = True # ---------------------------------------------------------------- internals diff --git a/python-backend/tests/test_lirc.py b/python-backend/tests/test_lirc.py index 2682a4e..4349e36 100644 --- a/python-backend/tests/test_lirc.py +++ b/python-backend/tests/test_lirc.py @@ -20,7 +20,7 @@ from musicmouse.events import ( PlayAlbumRequested, PlayRequested, PlaySeriesLatestRequested, - PrevTrackRequested, + PreviousTrackRequested, VolumeChangeRequested, ) from musicmouse.services.lirc import LircService @@ -160,7 +160,7 @@ async def test_transport_and_volume_buttons_emit_intents( PlayRequested, PauseRequested, PauseRequested, - PrevTrackRequested, + PreviousTrackRequested, NextTrackRequested, VolumeChangeRequested, ] diff --git a/python-backend/tests/test_mqtt.py b/python-backend/tests/test_mqtt.py index 88023bf..bda7e73 100644 --- a/python-backend/tests/test_mqtt.py +++ b/python-backend/tests/test_mqtt.py @@ -419,14 +419,18 @@ async def test_transport_buttons_emit_intents( 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: from pathlib import Path from musicmouse.media import Playlist, Track from musicmouse.services.mqtt.player import PlayerSensor - entity = PlayerSensor(bus, mqtt_config, player) + entity = PlayerSensor(bus, mqtt_config, mouse, player) entity.attach(publisher) player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),))) player.play_from_start() @@ -439,6 +443,29 @@ async def test_the_player_sensor_reports_the_current_track( 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 diff --git a/python-backend/tests/test_player.py b/python-backend/tests/test_player.py index 679530f..7987a30 100644 --- a/python-backend/tests/test_player.py +++ b/python-backend/tests/test_player.py @@ -334,20 +334,76 @@ class _FakeMedia: 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: 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: 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__.""" player = object.__new__(VlcPlayer) 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._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)} return player @@ -365,7 +421,7 @@ async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> Non events: list[Event] = [] 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()) 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))) 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._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()) await bus.drain() 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")] diff --git a/tippen/package-lock.json b/tippen/package-lock.json index a284872..57d4136 100644 --- a/tippen/package-lock.json +++ b/tippen/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "yaml": "^2.6.0" }, "devDependencies": { "@types/react": "^19.2.0", @@ -2328,6 +2329,21 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "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" + } } } } diff --git a/tippen/package.json b/tippen/package.json index 7b1c4eb..a664250 100644 --- a/tippen/package.json +++ b/tippen/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "yaml": "^2.6.0" }, "devDependencies": { "@types/react": "^19.2.0", diff --git a/tippen/src/App.tsx b/tippen/src/App.tsx index 78ad2d0..dbfb118 100644 --- a/tippen/src/App.tsx +++ b/tippen/src/App.tsx @@ -15,12 +15,10 @@ import { Aquarium } from "./components/Aquarium"; import { AppHeader } from "./components/AppHeader"; import { HelpOverlay } from "./components/HelpOverlay"; import { LessonMap } from "./components/LessonMap"; -import { ModePicker } from "./components/ModePicker"; import { ResultSheet } from "./components/ResultSheet"; import { Stage } from "./components/Stage"; import { BubblesRun } from "./components/modes/BubblesRun"; import { FeedRun } from "./components/modes/FeedRun"; -import { PearlsRun } from "./components/modes/PearlsRun"; import { JellyfishRun } from "./components/modes/JellyfishRun"; import { RaceRun } from "./components/modes/RaceRun"; 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 * 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 * to add - they are presentations of one of two targets, all driven by the same engine. */ type RunTarget = @@ -49,6 +47,11 @@ type RunTarget = /** Modes that drill one key at a time rather than a line. */ 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 { result: RunResult; unlockedTitle: string | null; @@ -89,9 +92,17 @@ export function App() { const spaceActive = true; if (LETTER_ONLY_MODES.includes(mode)) { + const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined; return { 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, { @@ -106,16 +117,13 @@ export function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [lesson, mode, round]); - const start = useCallback( - (lesson: Lesson) => { - setLessonId(lesson.id); - setMode(lesson.modes.includes(mode) ? mode : "dive"); - setOutcome(null); - setRound((r) => r + 1); - setScreen("run"); - }, - [mode], - ); + const start = useCallback((lesson: Lesson) => { + setLessonId(lesson.id); + setMode(lesson.primaryMode); + setOutcome(null); + setRound((r) => r + 1); + setScreen("run"); + }, []); const onFinished = useCallback( (result: RunResult) => { @@ -142,6 +150,16 @@ export function App() { 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 next = lessonId ? nextLesson(lessonId) : null; setOutcome(null); @@ -201,8 +219,8 @@ export function App() { } if (current.screen !== "map") return; - const step = - event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + // The map is a vertical path now, so "next" is down rather than to the right. + const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; if (step !== 0) { event.preventDefault(); playPop(340); @@ -290,33 +308,23 @@ export function App() { ) ) : mode === "feed" ? ( - ) : mode === "pearls" ? ( - ) : mode === "race" ? ( ) : ( )} -
- { - setMode(chosen); - setRound((r) => r + 1); - }} - /> -
)} - {outcome && ( + {outcome && lesson && (