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

@@ -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));