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:
@@ -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] : [];
|
||||
|
||||
Reference in New Issue
Block a user