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

@@ -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" }))}