From a3b2c0ce2f02a7fa098fd2a6a030c9cc112b5f31 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Fri, 11 Sep 2026 11:40:40 +0200 Subject: [PATCH] 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 --- web/src/App.tsx | 20 +++- web/src/components/AlbumModal.tsx | 26 +++++ web/src/components/HelpOverlay.tsx | 14 ++- web/src/lib/__tests__/keyboard.test.ts | 106 +++++++++++++++++-- web/src/lib/keyboard.ts | 135 +++++++++++++++++++++---- 5 files changed, 262 insertions(+), 39 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2390a0b..cef941e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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)} /> diff --git a/web/src/components/AlbumModal.tsx b/web/src/components/AlbumModal.tsx index 37910d0..9fe89a4 100644 --- a/web/src/components/AlbumModal.tsx +++ b/web/src/components/AlbumModal.tsx @@ -1,5 +1,7 @@ /** The album detail sheet: cover, metadata, and a numbered track list. */ +import { useEffect, useRef } from "react"; + import type { Album } from "../api/types"; import { isBook, unitLabel } from "../lib/covers"; import { clock } from "../lib/format"; @@ -9,6 +11,8 @@ interface Props { album: Album; currentAlbumId: string | null; currentTrackIndex: number; + /** The track Ctrl+j/k has highlighted, so the keyboard has something visible to move. */ + selectedIndex: number; onClose: () => void; onPlay: (trackIndex: number) => void; } @@ -17,13 +21,32 @@ export function AlbumModal({ album, currentAlbumId, currentTrackIndex, + selectedIndex, onClose, onPlay, }: Props) { const book = isBook(album); + const sheet = useRef(null); + + // Keep the Ctrl+j/k highlight on screen as it moves past the fold, the same way the + // browse grid keeps its own keyboard selection visible. + useEffect(() => { + const box = sheet.current; + const element = box?.querySelector(`[data-nav-index="${selectedIndex}"]`); + if (!box || !element) return; + const top = element.offsetTop - box.offsetTop; + const bottom = top + element.offsetHeight; + const pad = 12; + if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad); + else if (bottom + pad > box.scrollTop + box.clientHeight) { + box.scrollTop = bottom + pad - box.clientHeight; + } + }, [selectedIndex]); + return (
event.stopPropagation()} style={{ background: "oklch(96% 0.012 210)", @@ -128,6 +151,7 @@ export function AlbumModal({ return (