feat(shortcuts): tell the key story once, and give the arrows back
Build & publish Arch package / arch-package (push) Successful in 2m1s
CI / check (push) Successful in 2m24s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Canceled after 8s

Decision 1 keeps the unmodified single-key bindings, and Settings was
the only place they were written down — three of the four categories of
them, because config-page listed the categories by hand, so the autotag
keys were written down nowhere at all. `?` now opens an overlay from
anywhere the app owns the keyboard, and both surfaces read one table
(services/shortcut-meta.ts, moved out of config-page's private static).

The other half is the same explanation from the other side. Phase 1
gave the arrow keys to the grid, correctly — but all six of them, and
no list in this app moves horizontally: track-list's own handler and
utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So
seeking stopped working from a focused row and nothing gained the keys.
Reproduced in the running app: two ArrowRights on a focused track row,
zero Player.Seek calls, against one per press from the body.

A shifted character no longer reports Shift, so the binding is `?` and
not `Shift+?` — the character already carries the shift, and a layout
where it does not is a layout where "Shift+?" is wrong anyway.
This commit is contained in:
2026-08-12 12:33:50 -04:00
parent 4615afe7f7
commit 1aa1598ecb
11 changed files with 730 additions and 164 deletions
+166
View File
@@ -0,0 +1,166 @@
/**
* What each shortcut action is called, where it applies, and what it is
* bound to out of the box.
*
* This lived as a private static in `config-page`, which meant Settings
* was the only place in the app the key bindings were written down —
* and Settings renders three of the four categories, so the autotag
* keys were written down nowhere at all. The `?` overlay and the
* Settings editor now read one table.
*/
export interface ShortcutMeta {
label: string;
category: string;
scope: string;
defaultKey: string;
}
/** The categories, in the order both surfaces show them. */
export const SHORTCUT_CATEGORIES = [
'Player',
'Navigation',
'App',
'Autotag',
] as const;
export const SHORTCUT_META: Record<string, ShortcutMeta> = {
'player.playPause': {
label: 'Play / Pause',
category: 'Player',
scope: 'global',
defaultKey: 'Space',
},
'player.next': {
label: 'Next Track',
category: 'Player',
scope: 'global',
defaultKey: 'N',
},
'player.previous': {
label: 'Previous Track',
category: 'Player',
scope: 'global',
defaultKey: 'P',
},
'player.volumeUp': {
label: 'Volume Up',
category: 'Player',
scope: 'global',
defaultKey: 'Up',
},
'player.volumeDown': {
label: 'Volume Down',
category: 'Player',
scope: 'global',
defaultKey: 'Down',
},
'player.seekForward': {
label: 'Seek Forward',
category: 'Player',
scope: 'global',
defaultKey: 'Right',
},
'player.seekBack': {
label: 'Seek Back',
category: 'Player',
scope: 'global',
defaultKey: 'Left',
},
'player.shuffle': {
label: 'Toggle Shuffle',
category: 'Player',
scope: 'global',
defaultKey: 'S',
},
'player.repeat': {
label: 'Cycle Repeat',
category: 'Player',
scope: 'global',
defaultKey: 'R',
},
'player.mute': {
label: 'Toggle Mute',
category: 'Player',
scope: 'global',
defaultKey: 'M',
},
'nav.search': {
label: 'Focus Search',
category: 'Navigation',
scope: 'global',
defaultKey: '/',
},
'nav.searchAlt': {
label: 'Focus Search (Alt)',
category: 'Navigation',
scope: 'global',
defaultKey: 'Ctrl+F',
},
'nav.queue': {
label: 'Toggle Queue',
category: 'Navigation',
scope: 'global',
defaultKey: 'Q',
},
'app.shortcuts': {
label: 'Keyboard Shortcuts',
category: 'App',
scope: 'global',
defaultKey: '?',
},
'app.selectAll': {
label: 'Select All',
category: 'App',
scope: 'global',
defaultKey: 'Ctrl+A',
},
'tracklist.play': {
label: 'Play Selected',
category: 'Navigation',
scope: 'panel:track-list',
defaultKey: 'Enter',
},
'autotag.apply': {
label: 'Apply Match',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'A',
},
'autotag.skip': {
label: 'Skip Folder',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'S',
},
'autotag.leave': {
label: 'Leave As Is',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'L',
},
'autotag.paste': {
label: 'Paste Release URL',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'U',
},
'autotag.search': {
label: 'Search Candidates',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'F',
},
'autotag.next': {
label: 'Next Folder',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'Down',
},
'autotag.previous': {
label: 'Previous Folder',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'Up',
},
};