Merge the typing game into the music player as a tab, with lock/unlock UI

Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 21:25:42 +02:00
parent f7a5d24d8d
commit 7f5e2733c2
84 changed files with 1577 additions and 4642 deletions

View File

@@ -9,6 +9,11 @@ import type {
RemoteMapping,
RemoteSlotInput,
Settings,
TippenCurriculum,
TippenProgress,
TippenRunInput,
TippenRunResult,
TippenSettings,
TrackDetail,
} from "./types";
@@ -53,6 +58,11 @@ const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>
/** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
/** `null` means the typing game isn't configured, not an error - the tab still shows,
* just with a "not set up" placeholder instead of a lesson map. */
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
fetchOrNullOn404<TippenCurriculum>("/tippen/curriculum");
/** `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
* device with no state the same way either way, rather than crashing on a poll. */
@@ -103,6 +113,13 @@ export const api = {
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
tippenCurriculum: fetchTippenCurriculum,
tippenProgress: () => request<TippenProgress>("/tippen/progress"),
saveTippenSettings: (settings: TippenSettings) =>
request<TippenSettings>("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }),
recordTippenRun: (body: TippenRunInput) =>
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
};
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;

View File

@@ -34,11 +34,24 @@ export interface TrackDetail {
curve: TrackCurves | null;
}
/** Which typing lesson unlocks a still-locked track - the browse view's "unlocks
* when..." hint. Mirrors `UnlockHintOut`. */
export interface UnlockHint {
lesson_id: string;
lesson_title: string;
world_number: number;
world_title: string;
}
export interface Track {
title: string;
/** Seconds, read from the file's tags at scan time. */
duration: number;
analysis: TrackAnalysis | null;
/** A typing-reward track not yet earned. Only ever `true` when the typing game is
* configured on the backend. */
locked: boolean;
unlock_hint: UnlockHint | null;
}
export interface Album {
@@ -56,6 +69,8 @@ export interface Album {
has_cover: boolean;
duration: number;
tracks: Track[];
/** Every track is still locked - show a question mark instead of cover art. */
locked: boolean;
}
export interface PlayerState {
@@ -135,3 +150,131 @@ export interface RemoteSlotInput {
export interface LircConfig {
connected: boolean;
}
// --------------------------------------------------------------------------- tippen
//
// The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't
// configured, like `HaConfig` above - the tab still shows, just with nothing behind it.
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
* configured path matches nothing in the current library. */
export interface TippenReward {
resolved: boolean;
album_id: string | null;
has_cover: boolean;
kind: "tracks" | "episode" | null;
}
export interface TippenLesson {
id: string;
world: number;
number: number;
title: string;
subtitle: string;
kind: TippenLessonKind;
new_keys: string[];
spotlight_keys: string[];
emphasis: "isolated" | "mixed" | null;
active_keys: string[];
primary_mode: TippenModeId;
bonus_modes: TippenModeId[];
words: string[];
is_drill: boolean;
chunks: number;
chunk_size: number;
reward: TippenReward;
}
export interface TippenWorld {
number: number;
title: string;
emoji: string;
reward: string;
}
export interface TippenCurriculum {
worlds: TippenWorld[];
lessons: TippenLesson[];
}
export interface TippenGhostStroke {
key: string;
at: number;
}
export interface TippenLessonProgress {
unlocked: boolean;
runs: number;
best_stars: 0 | 1 | 2 | 3;
best_animal: string | null;
best_points: number;
/** Derived server-side, not stored - two stars, or five attempts regardless. */
earned: boolean;
ghost: TippenGhostStroke[] | null;
}
export interface TippenKeyStat {
ema: number;
attempts: number;
errors: number;
}
export interface TippenStreak {
days: number;
last_played: string | null;
}
export interface TippenSettings {
sound: boolean;
keyboard_hint: "auto" | "on" | "off";
}
export interface TippenProgress {
lessons: Record<string, TippenLessonProgress>;
key_stats: Record<string, TippenKeyStat>;
pearls: number;
aquarium: string[];
streak: TippenStreak;
settings: TippenSettings;
}
export interface TippenStroke {
key: string;
expected: string;
correct: boolean;
at: number;
}
/** A run the client already graded - see `lib/tippen/grading.ts`. Grading stays
* client-side; the backend only owns progress bookkeeping. */
export interface TippenRunInput {
lesson_id: string;
stars: 0 | 1 | 2 | 3;
animal: string;
points: number;
passed: boolean;
pearls: number;
strokes: TippenStroke[];
}
/** The literal track/episode this run's lesson names in its own `unlocks:` - what the
* unlock animation shows, via the existing `/api/albums/{id}/cover`. */
export interface TippenUnlockedReward {
album_id: string;
title: string;
has_cover: boolean;
kind: "album" | "book" | "podcast_episode";
}
export interface TippenRunResult {
progress: TippenProgress;
unlocked_lesson_id: string | null;
unlocked_lesson_title: string | null;
new_creature: string | null;
is_new_best: boolean;
unlocked_reward: TippenUnlockedReward | null;
}