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:
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user