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

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",
"build": "tsc -b && vite build",
"preview": "vite preview",
"check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b",
"check": "tsc -b && eslint .",
"test": "vitest run"
},
"dependencies": {
@@ -15,10 +15,16 @@
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.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-eslint": "^8.70.0",
"vite": "^7.1.0",
"vitest": "^3.2.0"
},

View File

@@ -154,6 +154,33 @@ export function App() {
void (state.playing ? api.pause() : api.resume());
}, [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. */
// Read-modify-write the whole mapping, like ParentPanel does for settings: the
// backend takes a full replacement, not a per-slot patch.
@@ -218,12 +245,7 @@ export function App() {
case "next":
playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.min(
state?.duration ?? 0,
(state?.position ?? 0) + PODCAST_SKIP_SECONDS,
);
connection.optimistic({ position: target });
void api.seek(target);
skipPodcast(1);
} else {
connection.optimistic({ position: 0 });
void api.next();
@@ -232,9 +254,7 @@ export function App() {
case "previous":
playPop(260);
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
connection.optimistic({ position: target });
void api.seek(target);
skipPodcast(-1);
} else {
connection.optimistic({ position: 0 });
void api.previous();
@@ -249,8 +269,7 @@ export function App() {
0,
Math.min(state.duration, (state?.position ?? 0) + action.delta),
);
connection.optimistic({ position: target });
void api.seek(target);
seekTo(target);
}
break;
}
@@ -258,12 +277,23 @@ export function App() {
playPop(action.freq);
break;
case "mute":
setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
onMute();
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.
@@ -356,11 +386,8 @@ export function App() {
};
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
const onSeek = (target: number) => {
connection.optimistic({ position: target });
void api.seek(target);
};
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
// 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
@@ -425,9 +452,9 @@ export function App() {
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
onSeek={onSeek}
onNext={onNext}
onPrevious={onPrevious}
onSeek={seekTo}
onVolume={setVolume}
onMute={onMute}
onOpenAlbum={onOpenCurrentAlbum}
@@ -514,9 +541,9 @@ export function App() {
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
onSeek={onSeek}
onNext={onNext}
onPrevious={onPrevious}
onSeek={seekTo}
onVolume={setVolume}
onMute={onMute}
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
* `request()`, a 404 here is expected and shouldn't throw. */
async function fetchHaConfig(): Promise<HaConfig | null> {
const response = await fetch("/api/ha");
/** GET, treating a 404 as an expected "not configured" rather than an error - unlike
* `request()`, which throws on it. Anything else that isn't ok still throws. */
async function fetchOrNullOn404<T>(path: string): Promise<T | null> {
const response = await fetch(`/api${path}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET /ha failed: ${response.status}`);
return (await response.json()) as HaConfig;
if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`);
return (await response.json()) as T;
}
/** `null` means the IR remote isn't configured, not an error - same convention as
* `fetchHaConfig`. */
async function fetchLircConfig(): Promise<LircConfig | null> {
const response = await fetch("/api/lirc");
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET /lirc failed: ${response.status}`);
return (await response.json()) as LircConfig;
/** GET, treating *any* non-ok response as "nothing to show" rather than an error - for
* callers that poll and would rather fall back quietly than crash the poll loop. */
async function fetchOrNullOnError<T>(path: string): Promise<T | null> {
const response = await fetch(`/api${path}`);
if (!response.ok) return null;
return (await response.json()) as T;
}
/** `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
* 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. */
async function fetchHaState(entityId: string): Promise<HaEntityState | null> {
const response = await fetch(`/api/ha/states/${entityId}`);
if (!response.ok) return null;
return (await response.json()) as HaEntityState;
}
const fetchHaState = (entityId: string): Promise<HaEntityState | null> =>
fetchOrNullOnError<HaEntityState>(`/ha/states/${entityId}`);
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
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 -
* 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 response = await fetch(`/api/tracks/${albumId}/${trackIndex}/analysis`);
if (!response.ok) return null;
return (await response.json()) as TrackDetail;
}
const fetchTrackDetail = (albumId: string, trackIndex: number): Promise<TrackDetail | null> =>
fetchOrNullOnError<TrackDetail>(`/tracks/${albumId}/${trackIndex}/analysis`);
export const api = {
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));
useEffect(() => {
baseRef.current = ambienceBaseFor(album, trackAnalysis);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album, trackAnalysis]);
useEffect(() => {

View File

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

View File

@@ -7,6 +7,8 @@
* 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 type { Album } from "../api/types";
import { aspectOf, coverBackground, isBook } from "../lib/covers";
@@ -21,7 +23,7 @@ interface Props {
fit?: "width" | "height";
radius?: string;
className?: string;
style?: React.CSSProperties;
style?: CSSProperties;
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
label?: boolean;
}
@@ -29,7 +31,7 @@ interface Props {
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
const book = isBook(album);
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
const box: React.CSSProperties =
const box: CSSProperties =
fit === "height"
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
: { width: "100%", aspectRatio: aspectOf(album) };

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
/** The progress bar, and the one thing the mockup could not do: scrubbing. */
import { useCallback, useRef, useState } from "react";
import type { PointerEvent } from "react";
interface Props {
position: number;
@@ -45,7 +46,7 @@ export function ProgressBar({
const shown = dragging ?? position;
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;
event.currentTarget.setPointerCapture(event.pointerId);
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
// 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.
//
// `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 entityIds = useMemo(

View File

@@ -15,7 +15,12 @@ const RECONNECT_DELAY_MS = 1500;
export interface Connection {
state: PlayerState | null;
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;
}
@@ -34,13 +39,21 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
const connect = () => {
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 = () => {
setOnline(true);
// The server sends a snapshot on connect, but a reconnect may have missed
// changes in between, so ask for the truth as well.
void api.state().then(setState).catch(() => undefined);
// changes in between, so ask for the truth as well. Guarded against a slow
// 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) => {

View File

@@ -6,7 +6,7 @@
* 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";
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,
* 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. */
export const ALBUM_ASPECT = 1;
export const BOOK_ASPECT = 0.82;
const ALBUM_ASPECT = 1;
const BOOK_ASPECT = 0.82;
export const aspectOfKind = (kind: AlbumKind): number =>
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. */
export function stripes(album: Album, width: number): string {
function stripes(album: Album, width: number): string {
const [primary, secondary] = colours(album);
return (
`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}`;
};
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,
* and the feature toggles that have gone back and forth while this design was under
* review. Flip a toggle here rather than hunting through BrowseView.tsx/covers.ts.
/** Per-group colors and identity, and the feature toggles that have gone back and
* forth while this design was under review - flip one here rather than hunting
* 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
* header - an icon badge plus label button above each row's tiles. */
import type { CSSProperties } from "react";
import type { Group } from "./search";
// ---------------------------------------------------------------- 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,
* 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 {};
return {
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
* while it's the currently-playing row - or, with `SHOW_ROW_TINT` off, the same
* 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) {
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
}