Add a vim-style keyboard layer: Ctrl navigates, Shift controls transport

Ctrl+h/j/k/l move the highlight in whatever list is on screen - the browse
grid, search results, or an open album's track list - the same job the
arrow keys already do, just reachable without leaving the home row.
Ctrl+d/u add vim's own half-page jump. Shift+h/j/k/l/m are transport
(previous/next, volume, mute) from anywhere, including the room page,
matching the muscle memory of other vim-ish media apps; every other
Shift+letter still reaches search untouched; only the shifted letter
form of those five keys is intercepted.

Shift+Enter opens an album's track list (the keyboard equivalent of
clicking its cover, which had no key of its own until now) instead of
playing it outright; Ctrl+j/k then move a visible highlight through that
list, and Enter plays whichever track is highlighted. Falls back to plain
ENTER's behaviour for a category tile or a podcast episode, neither of
which has a track list to show.

Mute (Shift+M) is new - nothing was bound to it before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 11:40:40 +02:00
parent ba7f082f48
commit a3b2c0ce2f
5 changed files with 262 additions and 39 deletions

View File

@@ -239,6 +239,9 @@ export function App() {
case "pop":
playPop(action.freq);
break;
case "mute":
setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
break;
}
}
},
@@ -246,8 +249,8 @@ export function App() {
);
// Held in a ref so the listener is installed once rather than on every state change.
const latest = useRef({ ui, results, run });
latest.current = { ui, results, run };
const latest = useRef({ ui, results, run, openAlbumTrackCount: 0 });
latest.current = { ui, results, run, openAlbumTrackCount: openAlbum?.tracks.length ?? 0 };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -256,7 +259,7 @@ export function App() {
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
const current = latest.current;
const actions = handleKey(event, current.ui, current.results);
const actions = handleKey(event, current.ui, current.results, current.openAlbumTrackCount);
if (!actions.length) return;
event.preventDefault();
current.run(actions);
@@ -300,7 +303,7 @@ export function App() {
return;
}
playPop(420);
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 }));
};
/** Clicking the title text (as opposed to the cover) starts the album right away -
@@ -313,7 +316,13 @@ export function App() {
const onOpenCurrentAlbum = () => {
if (!currentAlbum) return;
playPop(420);
setUi((previous) => ({ ...previous, openAlbumId: currentAlbum.id }));
// currentAlbum is by definition whatever's playing, so its own track_index is the
// track already highlighted - the modal opens showing where you actually are.
setUi((previous) => ({
...previous,
openAlbumId: currentAlbum.id,
modalTrackIndex: state?.track_index ?? 0,
}));
};
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
@@ -435,6 +444,7 @@ export function App() {
album={openAlbum}
currentAlbumId={state.album_id}
currentTrackIndex={state.track_index}
selectedIndex={ui.modalTrackIndex}
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
/>