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

@@ -239,6 +239,9 @@ export function App() {
case "pop": case "pop":
playPop(action.freq); playPop(action.freq);
break; 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. // Held in a ref so the listener is installed once rather than on every state change.
const latest = useRef({ ui, results, run }); const latest = useRef({ ui, results, run, openAlbumTrackCount: 0 });
latest.current = { ui, results, run }; latest.current = { ui, results, run, openAlbumTrackCount: openAlbum?.tracks.length ?? 0 };
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -256,7 +259,7 @@ export function App() {
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
const current = latest.current; 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; if (!actions.length) return;
event.preventDefault(); event.preventDefault();
current.run(actions); current.run(actions);
@@ -300,7 +303,7 @@ export function App() {
return; return;
} }
playPop(420); 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 - /** Clicking the title text (as opposed to the cover) starts the album right away -
@@ -313,7 +316,13 @@ export function App() {
const onOpenCurrentAlbum = () => { const onOpenCurrentAlbum = () => {
if (!currentAlbum) return; if (!currentAlbum) return;
playPop(420); 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); const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
@@ -435,6 +444,7 @@ export function App() {
album={openAlbum} album={openAlbum}
currentAlbumId={state.album_id} currentAlbumId={state.album_id}
currentTrackIndex={state.track_index} currentTrackIndex={state.track_index}
selectedIndex={ui.modalTrackIndex}
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))} onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)} onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
/> />

View File

@@ -1,5 +1,7 @@
/** The album detail sheet: cover, metadata, and a numbered track list. */ /** The album detail sheet: cover, metadata, and a numbered track list. */
import { useEffect, useRef } from "react";
import type { Album } from "../api/types"; import type { Album } from "../api/types";
import { isBook, unitLabel } from "../lib/covers"; import { isBook, unitLabel } from "../lib/covers";
import { clock } from "../lib/format"; import { clock } from "../lib/format";
@@ -9,6 +11,8 @@ interface Props {
album: Album; album: Album;
currentAlbumId: string | null; currentAlbumId: string | null;
currentTrackIndex: number; currentTrackIndex: number;
/** The track Ctrl+j/k has highlighted, so the keyboard has something visible to move. */
selectedIndex: number;
onClose: () => void; onClose: () => void;
onPlay: (trackIndex: number) => void; onPlay: (trackIndex: number) => void;
} }
@@ -17,13 +21,32 @@ export function AlbumModal({
album, album,
currentAlbumId, currentAlbumId,
currentTrackIndex, currentTrackIndex,
selectedIndex,
onClose, onClose,
onPlay, onPlay,
}: Props) { }: Props) {
const book = isBook(album); 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 ( return (
<div className="overlay" style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }} onClick={onClose}> <div className="overlay" style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }} onClick={onClose}>
<div <div
ref={sheet}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
style={{ style={{
background: "oklch(96% 0.012 210)", background: "oklch(96% 0.012 210)",
@@ -128,6 +151,7 @@ export function AlbumModal({
return ( return (
<button <button
key={index} key={index}
data-nav-index={index}
onClick={() => onPlay(index)} onClick={() => onPlay(index)}
style={{ style={{
display: "flex", display: "flex",
@@ -142,6 +166,8 @@ export function AlbumModal({
background: current background: current
? "oklch(70% 0.16 340 / .16)" ? "oklch(70% 0.16 340 / .16)"
: "oklch(30% 0.03 210 / .05)", : "oklch(30% 0.03 210 / .05)",
outline: index === selectedIndex ? "3px solid var(--accent)" : "none",
outlineOffset: 2,
}} }}
> >
<div <div

View File

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

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types"; 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"; import { normalize, results as computeResults, type Results } from "../search";
function album(id: string, over: Partial<Album> = {}): Album { function album(id: string, over: Partial<Album> = {}): Album {
@@ -48,8 +48,8 @@ const resultsFor = (ui: UiState) =>
category: ui.category, category: ui.category,
}); });
const press = (k: string, ui: UiState = initialUiState, over = {}) => const press = (k: string, ui: UiState = initialUiState, over = {}, openAlbumTrackCount = 0) =>
handleKey(key(k, over), ui, resultsFor(ui)); handleKey(key(k, over), ui, resultsFor(ui), openAlbumTrackCount);
describe("normalize", () => { describe("normalize", () => {
it("strips case, punctuation and diacritics so a child's letters match", () => { 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([]); expect(press("r", { ...initialUiState }, { ctrlKey: true })).toEqual([]);
}); });
it("maps the ctrl chords to transport and volume", () => { it("moves the grid with Ctrl+hjkl, the same as the arrow keys", () => {
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([{ type: "next" }]); const inGroup = { ...initialUiState, group: "music" as const };
expect(press("h", initialUiState, { ctrlKey: true })).toEqual([{ type: "previous" }]); expect(press("l", inGroup, { ctrlKey: true })).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([ 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 }, { type: "volume", delta: 10 },
]); ]);
expect(press("j", initialUiState, { ctrlKey: true })).toEqual([ expect(press("J", initialUiState, { shiftKey: true })).toEqual([
{ type: "volume", delta: -10 }, { 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", () => { it("cycles the group with TAB", () => {
@@ -239,6 +276,59 @@ describe("keyboard", () => {
{ type: "play", albumId: "b", trackIndex: 0 }, { 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", () => { 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 type { Album } from "../api/types";
import { groupOf } from "./search";
import type { Group, Mode, Results } from "./search"; import type { Group, Mode, Results } from "./search";
export interface UiState { export interface UiState {
@@ -24,6 +25,9 @@ export interface UiState {
page: "music" | "room"; page: "music" | "room";
view: "browse" | "play"; view: "browse" | "play";
openAlbumId: string | null; 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; showHelp: boolean;
cols: number; cols: number;
/** Armed by `A` on the play screen: the next digit assigns what's playing to that /** 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", page: "music",
view: "browse", view: "browse",
openAlbumId: null, openAlbumId: null,
modalTrackIndex: 0,
showHelp: false, showHelp: false,
cols: 4, cols: 4,
assignPending: false, assignPending: false,
@@ -54,7 +59,8 @@ export type Action =
| { type: "volume"; delta: number } | { type: "volume"; delta: number }
| { type: "seek"; delta: number } | { type: "seek"; delta: number }
| { type: "pop"; freq: 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. */ /** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
const SEARCHABLE = /^[\p{L}\p{N}]$/u; 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 VOLUME_STEP = 10;
export const SEEK_STEP = 15; 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. */ /** What the flat selection index currently points at. */
export function selectionAt(results: Results, selIndex: number): Action | null { 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; 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[] { function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
if (!results.total) return []; if (!results.total) return [];
const cols = Math.max(1, state.cols); 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 * 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. * 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; const { key } = event;
if (event.ctrlKey || event.metaKey) { if (event.shiftKey && !event.ctrlKey && !event.metaKey) {
switch (key.toLowerCase()) { const media = SHIFT_MEDIA[key.toUpperCase()];
case "l": if (media) return media;
return [{ type: "next" }]; }
case "h":
return [{ type: "previous" }]; // Space is the one other transport key reachable from anywhere, same as Shift+media.
case "k": if (key === " ") {
return [{ type: "volume", delta: VOLUME_STEP }]; return [{ type: "pop", freq: 340 }, { type: "toggle" }];
case "j":
return [{ type: "volume", delta: -VOLUME_STEP }];
default:
return [];
}
} }
// Armed by "A" below; the next digit assigns what's playing to that remote key // 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 // 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 // the global transport above (Shift+media, Space) make sense there. Everything else
// hidden music state without anything on screen to show for it. // would otherwise mutate the hidden music state without anything on screen to show
// for it.
if (state.page === "room" && key !== "Escape") { 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; 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 "/": case "/":
return [{ type: "ui", patch: { view: "browse" } }]; return [{ type: "ui", patch: { view: "browse" } }];
case " ":
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
case "ArrowRight": case "ArrowRight":
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }]; 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": { case "Enter": {
if (state.openAlbumId !== null) { 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); const chosen = selectionAt(results, state.selIndex);
return chosen ? [chosen] : []; return chosen ? [chosen] : [];