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:
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
|
||||
const KEYS: Array<[caps: string[], label: string]> = [
|
||||
[["LEER"], "Play / Pause"],
|
||||
[["CTRL+H", "CTRL+L"], "Song zurück / vor"],
|
||||
[["CTRL+J", "CTRL+K"], "Leiser / lauter"],
|
||||
[["SHIFT+H", "SHIFT+L"], "Song zurück / vor"],
|
||||
[["SHIFT+J", "SHIFT+K"], "Leiser / lauter"],
|
||||
[["SHIFT+M"], "Stumm"],
|
||||
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
||||
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||
[["CTRL+H/J/K/L"], "Auswahl bewegen (Raster oder Titelliste)"],
|
||||
[["CTRL+D", "CTRL+U"], "Eine halbe Seite weiter / zurück"],
|
||||
[["A-Z"], "Album oder Hörbuch suchen"],
|
||||
[["?"], "Einzelne Titel suchen"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["TAB"], "Musik / Hörbücher / Podcasts"],
|
||||
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"],
|
||||
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||
[["ENTER"], "Auswahl abspielen"],
|
||||
[["⇧ENTER"], "Titelliste öffnen"],
|
||||
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["ESC"], "Schließen / Suche löschen"],
|
||||
];
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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