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

@@ -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<HTMLDivElement | null>(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<HTMLElement>(`[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 (
<div className="overlay" style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }} onClick={onClose}>
<div
ref={sheet}
onClick={(event) => event.stopPropagation()}
style={{
background: "oklch(96% 0.012 210)",
@@ -128,6 +151,7 @@ export function AlbumModal({
return (
<button
key={index}
data-nav-index={index}
onClick={() => onPlay(index)}
style={{
display: "flex",
@@ -142,6 +166,8 @@ export function AlbumModal({
background: current
? "oklch(70% 0.16 340 / .16)"
: "oklch(30% 0.03 210 / .05)",
outline: index === selectedIndex ? "3px solid var(--accent)" : "none",
outlineOffset: 2,
}}
>
<div