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,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types";
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
import { normalize, results as computeResults, type Results } from "../search";
function album(id: string, over: Partial<Album> = {}): Album {
@@ -48,8 +48,8 @@ const resultsFor = (ui: UiState) =>
category: ui.category,
});
const press = (k: string, ui: UiState = initialUiState, over = {}) =>
handleKey(key(k, over), ui, resultsFor(ui));
const press = (k: string, ui: UiState = initialUiState, over = {}, openAlbumTrackCount = 0) =>
handleKey(key(k, over), ui, resultsFor(ui), openAlbumTrackCount);
describe("normalize", () => {
it("strips case, punctuation and diacritics so a child's letters match", () => {
@@ -94,15 +94,52 @@ describe("keyboard", () => {
expect(press("r", { ...initialUiState }, { ctrlKey: true })).toEqual([]);
});
it("maps the ctrl chords to transport and volume", () => {
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([{ type: "next" }]);
expect(press("h", initialUiState, { ctrlKey: true })).toEqual([{ type: "previous" }]);
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([
it("moves the grid with Ctrl+hjkl, the same as the arrow keys", () => {
const inGroup = { ...initialUiState, group: "music" as const };
expect(press("l", inGroup, { ctrlKey: true })).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
expect(press("j", inGroup, { ctrlKey: true })).toEqual(press("ArrowDown", inGroup));
expect(press("k", inGroup, { ctrlKey: true, metaKey: false })).toEqual(
press("ArrowUp", inGroup),
);
});
it("jumps a half-page with Ctrl+d/Ctrl+u", () => {
const ui = { ...initialUiState, group: "music" as const, cols: 2 };
const grid: Results = {
songs: [],
categories: Array.from({ length: 10 }, (_, i) => ({ key: String(i), albums: [] })),
albums: [],
total: 10,
};
// 3 rows of 2 columns each = 6.
expect(handleKey(key("d", { ctrlKey: true }), { ...ui, selIndex: 0 }, grid)).toEqual([
{ type: "ui", patch: { selIndex: 6 } },
]);
expect(handleKey(key("u", { ctrlKey: true }), { ...ui, selIndex: 8 }, grid)).toEqual([
{ type: "ui", patch: { selIndex: 2 } },
]);
});
it("does nothing with Ctrl+hjkl outside the browse grid, so it never fights the play view's own bindings", () => {
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
});
it("controls transport and volume from anywhere with Shift+h/j/k/l/m", () => {
expect(press("H", initialUiState, { shiftKey: true })).toEqual([{ type: "previous" }]);
expect(press("L", initialUiState, { shiftKey: true })).toEqual([{ type: "next" }]);
expect(press("K", initialUiState, { shiftKey: true })).toEqual([
{ type: "volume", delta: 10 },
]);
expect(press("j", initialUiState, { ctrlKey: true })).toEqual([
expect(press("J", initialUiState, { shiftKey: true })).toEqual([
{ type: "volume", delta: -10 },
]);
expect(press("M", initialUiState, { shiftKey: true })).toEqual([{ type: "mute" }]);
});
it("leaves every other Shift+letter alone, so capitalizing a search still works", () => {
expect(press("B", initialUiState, { shiftKey: true })).toEqual([
{ type: "ui", patch: { search: "B", view: "browse", selIndex: 0 } },
]);
});
it("cycles the group with TAB", () => {
@@ -239,6 +276,59 @@ describe("keyboard", () => {
{ type: "play", albumId: "b", trackIndex: 0 },
]);
});
it("plays the highlighted track on ENTER once one's been picked with Ctrl+j/k", () => {
expect(
press("Enter", { ...initialUiState, openAlbumId: "b", modalTrackIndex: 1 }),
).toEqual([{ type: "play", albumId: "b", trackIndex: 1 }]);
});
it("opens the track list with Shift+ENTER instead of playing", () => {
// group + category together skip straight to the album grid (see browseBackActions).
const ui = { ...initialUiState, group: "music" as const, category: "Kinderparty", selIndex: 0 };
const found = resultsFor(ui);
expect(press("Enter", ui, { shiftKey: true })).toEqual([
{ type: "ui", patch: { openAlbumId: found.albums[0]!.id, modalTrackIndex: 0 } },
]);
});
it("falls back to plain ENTER's behaviour for a category, where there's nothing to show", () => {
// Without a category picked yet, index 0 here is a category tile, not an album.
const ui = { ...initialUiState, group: "music" as const, selIndex: 0 };
expect(press("Enter", ui, { shiftKey: true })).toEqual(press("Enter", ui));
});
it("moves the Ctrl+j/k track highlight, clamped to the album's own track count", () => {
const open = { ...initialUiState, openAlbumId: "b", modalTrackIndex: 0 };
expect(press("j", open, { ctrlKey: true }, 2)).toEqual([
{ type: "ui", patch: { modalTrackIndex: 1 } },
]);
// Album "b" only has 2 tracks - Ctrl+j from the last one stays put.
expect(press("j", { ...open, modalTrackIndex: 1 }, { ctrlKey: true }, 2)).toEqual([
{ type: "ui", patch: { modalTrackIndex: 1 } },
]);
expect(press("k", { ...open, modalTrackIndex: 1 }, { ctrlKey: true }, 2)).toEqual([
{ type: "ui", patch: { modalTrackIndex: 0 } },
]);
});
});
describe("selectionAlbumAt", () => {
it("resolves an album tile directly", () => {
const found = resultsFor({ ...initialUiState, group: "music", category: "Kinderparty" });
expect(selectionAlbumAt(found, 0)?.id).toBe(found.albums[0]!.id);
});
it("resolves a song hit to its parent album, so Shift+ENTER can jump to the rest of it", () => {
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };
const found = resultsFor(ui);
expect(selectionAlbumAt(found, 0)?.id).toBe(found.songs[0]!.album.id);
});
it("returns null for a category tile - there's no single album to show", () => {
const found = resultsFor({ ...initialUiState, group: "music" });
expect(selectionAlbumAt(found, 0)).toBeNull();
});
});
describe("remote key assignment", () => {

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] : [];