feat(audio-player): keyboard shortcuts — declarative keymap, dialog, media keys (plan §2)

Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp
chapter steps, B bookmark capture (note box auto-focuses, Enter saves
even when blank, Shift+Enter inserts a newline, Esc cancels),
Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD,
M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the
top-most surface (help → capture → note edit → drawers → dropdowns).

audio-keys.ts is the ONE declarative table: resolveShortcut() drives
the document keydown dispatcher and visibleKeymap() renders the
templ dialog (x-for over the exposed keymap — templ cannot import
TS). Hardware media keys ride the existing MediaSession handlers;
the Media* keydown codes resolve through the same table to the SAME
methods, so both layers stay one behavior.

Guards mirror the ebook reader: typing in any
INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark
notes included), a focused button keeps Space/Enter for itself, and
the focused scrubber keeps its arrows (its own @change seeks — a
native arrow nudge moves 0.08s, not a 10s skip).

SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip
dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the
same one-source pattern as the §5 speed-list consolidation.

Unit: audio-keys.test.ts (resolution, Alt disambiguation, media
keys, keymap integrity and conflict-freedom, dialog visibility) and
cycleSkip tests. Live: 39 Playwright checks on the KJV folder book
covering every binding, the guards, dialog and OSD; probe user
created and deleted.
This commit is contained in:
John O'Keefe committed 2026-10-22 08:15:06 -04:00
1 parent db96ea91cd
commit 295c4b0664
6 files changed
+569 -22

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
// Declarative keyboard map for the audio player (player plan §2).
// ONE table drives BOTH the document keydown dispatcher (audio.ts
// resolves events through resolveShortcut) and the templ-rendered
// shortcut dialog (x-for over visibleKeymap). Never hardcode a key
// binding in the handler or a row in the dialog — add it here.
//
// Hardware media keys are a separate layer: they ride the existing
// MediaSession action handlers even when the tab is unfocused. The
// Media* entries below cover browsers that ALSO deliver them as
// in-page keydown events; they resolve to the same methods the
// MediaSession handlers call (togglePlay / stepChapter).
export type KeyAction =
| "toggle-play"
| "skip-back"
| "skip-forward"
| "prev-chapter"
| "next-chapter"
| "bookmark"
| "speed-up"
| "speed-down"
| "volume-up"
| "volume-down"
| "toggle-mute"
| "cycle-skip-up"
| "cycle-skip-down"
| "toggle-help"
| "escape"
| "media-toggle-play"
| "media-play"
| "media-pause"
| "media-next"
| "media-prev";
export interface KeySpec {
// e.key value to match. Single characters are matched
// case-insensitively by resolveShortcut; multi-character key names
// ("Enter", "ArrowLeft", "MediaPlayPause") must match verbatim.
key: string;
// Requires the Alt modifier. Alt-qualified entries disambiguate the
// plain binding (Alt+L = speed up while L = skip forward).
alt?: boolean;
// Dialog badge label, e.g. "Space", "←", "Alt + →".
label: string;
}
export interface KeyActionSpec {
action: KeyAction;
// Dialog row label.
label: string;
description: string;
keys: readonly KeySpec[];
// Media-key entries are resolved but not shown in the dialog —
// hardware keys, not something a keyboard user types.
hidden?: boolean;
}
export const KEYMAP: readonly KeyActionSpec[] = [
{
action: "toggle-play",
label: "Play / pause",
description: "Play or pause (only when no control is focused)",
keys: [
{ key: " ", label: "Space" },
{ key: "Enter", label: "Enter" },
],
},
{
action: "skip-back",
label: "Skip back",
description: "Skip back by the skip duration",
keys: [
{ key: "h", label: "H" },
{ key: "ArrowLeft", label: "←" },
],
},
{
action: "skip-forward",
label: "Skip forward",
description: "Skip forward by the skip duration",
keys: [
{ key: "l", label: "L" },
{ key: "ArrowRight", label: "→" },
],
},
{
action: "prev-chapter",
label: "Previous chapter",
description: "Previous chapter",
keys: [
{ key: "k", label: "K" },
{ key: "PageUp", label: "PgUp" },
],
},
{
action: "next-chapter",
label: "Next chapter",
description: "Next chapter",
keys: [
{ key: "j", label: "J" },
{ key: "PageDown", label: "PgDn" },
],
},
{
action: "bookmark",
label: "Bookmark",
description: "Bookmark this position — Enter saves, Shift+Enter newline",
keys: [{ key: "b", label: "B" }],
},
{
action: "speed-up",
label: "Speed up",
description: "Step playback speed up",
keys: [
{ key: "ArrowRight", alt: true, label: "Alt + →" },
{ key: "l", alt: true, label: "Alt + L" },
],
},
{
action: "speed-down",
label: "Speed down",
description: "Step playback speed down",
keys: [
{ key: "ArrowLeft", alt: true, label: "Alt + ←" },
{ key: "h", alt: true, label: "Alt + H" },
],
},
{
action: "volume-up",
label: "Volume up",
description: "Volume up",
keys: [{ key: "ArrowUp", label: "↑" }],
},
{
action: "volume-down",
label: "Volume down",
description: "Volume down",
keys: [{ key: "ArrowDown", label: "↓" }],
},
{
action: "toggle-mute",
label: "Mute",
description: "Mute or unmute",
keys: [{ key: "m", label: "M" }],
},
{
action: "cycle-skip-up",
label: "Longer skip",
description: "Cycle the skip duration up",
keys: [{ key: "]", label: "]" }],
},
{
action: "cycle-skip-down",
label: "Shorter skip",
description: "Cycle the skip duration down",
keys: [{ key: "[", label: "[" }],
},
{
action: "toggle-help",
label: "Shortcuts",
description: "Show or hide this dialog",
keys: [{ key: "?", label: "?" }],
},
{
action: "escape",
label: "Close",
description: "Close dialog, cancel bookmark, close panels",
keys: [{ key: "Escape", label: "Esc" }],
},
// Media keys: same actions the MediaSession handlers expose.
{
action: "media-toggle-play",
label: "Media play/pause key",
description: "Hardware media play/pause",
keys: [{ key: "MediaPlayPause", label: "Media ⏯" }],
hidden: true,
},
{
action: "media-play",
label: "Media play key",
description: "Hardware media play",
keys: [{ key: "MediaPlay", label: "Media ▶" }],
hidden: true,
},
{
action: "media-pause",
label: "Media pause/stop key",
description: "Hardware media pause or stop",
keys: [
{ key: "MediaPause", label: "Media ⏸" },
{ key: "MediaStop", label: "Media ⏹" },
],
hidden: true,
},
{
action: "media-next",
label: "Media next key",
description: "Hardware media next (chapter step, like the notification)",
keys: [{ key: "MediaTrackNext", label: "Media ⏭" }],
hidden: true,
},
{
action: "media-prev",
label: "Media previous key",
description: "Hardware media previous (chapter step, like the notification)",
keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }],
hidden: true,
},
];
// visibleKeymap filters out the hardware-media rows for the dialog.
export function visibleKeymap(): KeyActionSpec[] {
return KEYMAP.filter((spec) => !spec.hidden);
}
// resolveShortcut maps a keydown's (key, altKey) to an action, or null
// when nothing claims it. Single characters match case-insensitively;
// Alt-qualified bindings take precedence order for free: a plain key
// never matches an alt binding and vice versa, so L and Alt+L are
// distinct by construction.
export function resolveShortcut(key: string, altKey: boolean): KeyAction | null {
const normalized = key.length === 1 ? key.toLowerCase() : key;
for (const spec of KEYMAP) {
for (const k of spec.keys) {
if (k.key === normalized && (k.alt ?? false) === altKey) return spec.action;
}
}
return null;
}