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

@@ -10,6 +10,7 @@ playing), and seeking is the one thing a real player can do that the mockup coul
*/
import type { Album } from "../api/types";
import { groupOf } from "./search";
import type { Group, Mode, Results } from "./search";
export interface UiState {
@@ -24,6 +25,9 @@ export interface UiState {
page: "music" | "room";
view: "browse" | "play";
openAlbumId: string | null;
/** Which track is highlighted in the open album's track list. Only meaningful while
* `openAlbumId` is set; reset to a sensible value each time a modal opens. */
modalTrackIndex: number;
showHelp: boolean;
cols: number;
/** Armed by `A` on the play screen: the next digit assigns what's playing to that
@@ -40,6 +44,7 @@ export const initialUiState: UiState = {
page: "music",
view: "browse",
openAlbumId: null,
modalTrackIndex: 0,
showHelp: false,
cols: 4,
assignPending: false,
@@ -54,7 +59,8 @@ export type Action =
| { type: "volume"; delta: number }
| { type: "seek"; delta: number }
| { type: "pop"; freq: number }
| { type: "assign"; digit: string };
| { type: "assign"; digit: string }
| { type: "mute" };
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
@@ -71,6 +77,22 @@ const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
export const VOLUME_STEP = 10;
export const SEEK_STEP = 15;
/** How far Ctrl+d/Ctrl+u jump through the grid - vim's own half-page scroll, applied to
* rows of cards instead of lines of text. */
export const PAGE_ROWS = 3;
/** Shift+h/j/k/l/m: transport, reachable from anywhere, matching the muscle memory of
* every other vim-ish media app. Keyed on the exact shifted letter, so this only ever
* intercepts those five keys - every other Shift+letter (capitalizing a search) still
* reaches `typeIntoSearch` untouched. Search is case-insensitive, so nothing searchable
* is lost: there was never a reason to hold Shift while typing a query here. */
const SHIFT_MEDIA: Record<string, Action[]> = {
H: [{ type: "previous" }],
L: [{ type: "next" }],
K: [{ type: "volume", delta: VOLUME_STEP }],
J: [{ type: "volume", delta: -VOLUME_STEP }],
M: [{ type: "mute" }],
};
/** What the flat selection index currently points at. */
export function selectionAt(results: Results, selIndex: number): Action | null {
@@ -91,6 +113,26 @@ export function selectionAt(results: Results, selIndex: number): Action | null {
return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null;
}
/** The album behind the current selection, if Shift+Enter's "show me the tracks" makes
* sense for it: an album tile directly, or - so you can jump to the rest of it - the
* album behind a song hit. `null` for a category tile, or a podcast episode, which is a
* single track with nothing else to show. */
export function selectionAlbumAt(results: Results, selIndex: number): Album | null {
const { songs, categories, albums } = results;
const index = Math.min(selIndex, results.total - 1);
if (index < 0) return null;
let album: Album | undefined;
if (index < songs.length) {
album = songs[index]?.album;
} else {
const afterSongs = index - songs.length;
if (afterSongs < categories.length) return null;
album = albums[afterSongs - categories.length];
}
return album && groupOf(album) !== "podcasts" ? album : null;
}
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
if (!results.total) return [];
const cols = Math.max(1, state.cols);
@@ -148,23 +190,27 @@ export interface KeyEvent {
/**
* Translate one keypress. Returns the actions to run, or nothing when the key means
* nothing here - the caller only calls `preventDefault()` when something came back.
*
* `openAlbumTrackCount` is the track count of the open album (when `openAlbumId` is
* set) - the one piece of data Ctrl+j/k needs to clamp the track-list highlight that
* `results` doesn't otherwise carry.
*/
export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] {
export function handleKey(
event: KeyEvent,
state: UiState,
results: Results,
openAlbumTrackCount = 0,
): Action[] {
const { key } = event;
if (event.ctrlKey || event.metaKey) {
switch (key.toLowerCase()) {
case "l":
return [{ type: "next" }];
case "h":
return [{ type: "previous" }];
case "k":
return [{ type: "volume", delta: VOLUME_STEP }];
case "j":
return [{ type: "volume", delta: -VOLUME_STEP }];
default:
return [];
}
if (event.shiftKey && !event.ctrlKey && !event.metaKey) {
const media = SHIFT_MEDIA[key.toUpperCase()];
if (media) return media;
}
// Space is the one other transport key reachable from anywhere, same as Shift+media.
if (key === " ") {
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
}
// Armed by "A" below; the next digit assigns what's playing to that remote key
@@ -174,10 +220,55 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
}
// The room page has no browse hierarchy of its own - only ESC (handled below) and
// transport controls make sense there. Everything else would otherwise mutate the
// hidden music state without anything on screen to show for it.
// the global transport above (Shift+media, Space) make sense there. Everything else
// would otherwise mutate the hidden music state without anything on screen to show
// for it.
if (state.page === "room" && key !== "Escape") {
return key === " " ? [{ type: "pop", freq: 340 }, { type: "toggle" }] : [];
return [];
}
// Ctrl+hjkl moves the highlight in whichever list is on screen - the open album's
// track list if one is open, otherwise the browse grid - the same job the arrow keys
// already do, just reachable without leaving the home row. Ctrl+d/u are vim's own
// half-page jump, applied to rows of cards.
if (event.ctrlKey || event.metaKey) {
if (state.openAlbumId !== null) {
switch (key.toLowerCase()) {
case "j":
return [
{
type: "ui",
patch: {
modalTrackIndex: Math.max(
0,
Math.min(openAlbumTrackCount - 1, state.modalTrackIndex + 1),
),
},
},
];
case "k":
return [{ type: "ui", patch: { modalTrackIndex: Math.max(0, state.modalTrackIndex - 1) } }];
default:
return [];
}
}
if (state.view !== "browse" || state.showHelp) return [];
switch (key.toLowerCase()) {
case "h":
return moveSelection(state, results, -1, 0);
case "l":
return moveSelection(state, results, 1, 0);
case "j":
return moveSelection(state, results, 0, 1);
case "k":
return moveSelection(state, results, 0, -1);
case "d":
return moveSelection(state, results, 0, PAGE_ROWS);
case "u":
return moveSelection(state, results, 0, -PAGE_ROWS);
default:
return [];
}
}
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
@@ -193,8 +284,6 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
}
case "/":
return [{ type: "ui", patch: { view: "browse" } }];
case " ":
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
case "ArrowRight":
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
@@ -241,7 +330,11 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
: [];
case "Enter": {
if (state.openAlbumId !== null) {
return [{ type: "play", albumId: state.openAlbumId, trackIndex: 0 }];
return [{ type: "play", albumId: state.openAlbumId, trackIndex: state.modalTrackIndex }];
}
if (event.shiftKey) {
const album = selectionAlbumAt(results, state.selIndex);
if (album) return [{ type: "ui", patch: { openAlbumId: album.id, modalTrackIndex: 0 } }];
}
const chosen = selectionAt(results, state.selIndex);
return chosen ? [chosen] : [];