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

+53 -1
View File
@@ -75,6 +75,19 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
<button
type="button"
@click="helpOpen = !helpOpen"
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8"></path>
</svg>
</button>
</span>
</div>
@@ -219,7 +232,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
<template x-for="secs in skipChoices" :key="'skip' + secs">
<button
type="button"
@click="setSkip(secs); skipOpen = false"
@@ -453,10 +466,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
>
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
<textarea
id="bookmark-note"
x-model="bookmarkNote"
rows="3"
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
class="input mb-2 resize-y"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
></textarea>
<div class="flex justify-end gap-2">
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
@@ -464,6 +479,43 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</div>
</div>
<!-- Volume/mute OSD: brief confirmation for keyboard-driven changes -->
<div
x-show="osdVisible"
x-cloak
x-transition.opacity.duration.200ms
class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 text-xs px-3 py-1.5 rounded-full shadow-lg tabular-nums"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);"
x-text="osdText"
></div>
<!-- Shortcut help (?): rows come from the shared KEYMAP
(audio-keys.ts) — the same table drives the keydown handler. -->
<div x-show="helpOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="helpOpen = false"></div>
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Keyboard shortcuts</span>
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<template x-for="spec in visibleKeymap()" :key="spec.action">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="spec.description"></span>
<span class="flex items-center gap-1 shrink-0">
<template x-for="k in spec.keys" :key="k.label">
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="k.label"></kbd>
</template>
</span>
</div>
</template>
</div>
</div>
<audio id="audio-element" preload="auto" class="hidden"></audio>
</div>
</body>