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

+172 -15
View File
@@ -15,6 +15,7 @@
import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown";
import { showToast } from "./toast";
import { resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
type AudioChapter = { index: number; title: string; start: number; end: number };
@@ -74,6 +75,19 @@ export function stepSpeed(current: number, delta: 1 | -1): number {
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, base + delta))];
}
// Skip durations — ONE source (same pattern as SPEEDS). The templ
// dropdown iterates it through the component's `skipChoices` property,
// and [ / ] keyboard cycling walks it through cycleSkip.
export const SKIP_SECONDS: readonly number[] = [1, 5, 10, 15, 30];
// cycleSkip moves through SKIP_SECONDS by delta, clamping at both
// ends; an unknown current anchors at 10s (the historical default).
export function cycleSkip(current: number, delta: 1 | -1): number {
const idx = SKIP_SECONDS.indexOf(current);
const base = idx === -1 ? SKIP_SECONDS.indexOf(10) : idx;
return SKIP_SECONDS[Math.min(SKIP_SECONDS.length - 1, Math.max(0, base + delta))];
}
const PROGRESS_INTERVAL_MS = 30_000;
interface AudioConfig {
@@ -141,6 +155,14 @@ Alpine.data("audioReader", () => ({
skipOpen: false,
speedOpen: false,
volumeOpen: false,
skipChoices: SKIP_SECONDS,
// Keyboard shortcuts (audio-keys.ts keymap): the ? dialog and the
// brief OSD confirming keyboard-driven volume/mute changes.
helpOpen: false,
osdVisible: false,
osdText: "",
osdTimer: null as ReturnType<typeof setTimeout> | null,
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
// on unmute (spec: icon shows level arcs, crossed when muted).
@@ -272,21 +294,11 @@ Alpine.data("audioReader", () => ({
if (document.visibilityState === "hidden") this.pushProgress(true);
});
// Spacebar = play/pause (the reader's keyboard contract, audio flavor).
document.addEventListener("keydown", (e) => {
const target = e.target as HTMLElement | null;
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
if (e.code === "Space") {
e.preventDefault();
this.togglePlay();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
this.skip(-1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
this.skip(1);
}
});
// Keyboard shortcuts (plan §2): one dispatcher resolving through
// the declarative audio-keys.ts keymap. Hardware media keys ride
// the MediaSession handlers above; the Media* keydown codes land
// here and call the SAME methods.
document.addEventListener("keydown", (e) => this.handleKeydown(e));
},
audio(): HTMLAudioElement | null {
@@ -560,6 +572,151 @@ Alpine.data("audioReader", () => ({
this.bookmarkCaptureOpen = true;
},
// Keyboard B: open the capture pill and put the cursor in the note
// box (user's explicit flow — Enter then saves, even when blank).
openBookmarkCapture() {
if (!this.bookmarkCaptureOpen) this.toggleBookmarkCapture();
window.setTimeout(() => {
(document.getElementById("bookmark-note") as HTMLTextAreaElement | null)?.focus();
}, 0);
},
// ---- keyboard dispatcher (audio-keys.ts) ----
visibleKeymap() {
return visibleKeymap();
},
// Escape closes the top-most open surface — even while typing, since
// canceling the bookmark note mid-sentence is the expected Esc.
// Returns true when something actually closed.
closeTopSurface(): boolean {
if (this.helpOpen) {
this.helpOpen = false;
return true;
}
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;
return true;
}
if (this.editingBookmarkId !== "") {
this.editingBookmarkId = "";
return true;
}
if (this.chaptersOpen) {
this.chaptersOpen = false;
return true;
}
if (this.bookmarksOpen) {
this.bookmarksOpen = false;
return true;
}
if (this.skipOpen || this.speedOpen || this.volumeOpen) {
this.skipOpen = false;
this.speedOpen = false;
this.volumeOpen = false;
return true;
}
return false;
},
showOsd(text: string) {
this.osdText = text;
this.osdVisible = true;
if (this.osdTimer) clearTimeout(this.osdTimer);
this.osdTimer = setTimeout(() => {
this.osdVisible = false;
}, 1200);
},
handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const typing = tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable === true;
// Escape first: cancel the top-most surface even while typing.
if (e.key === "Escape") {
if (this.closeTopSurface()) {
e.preventDefault();
e.stopPropagation();
}
return;
}
// Typing in any input (bookmark notes!) never triggers shortcuts.
if (typing) return;
const action: KeyAction | null = resolveShortcut(e.key, e.altKey);
if (!action) return;
// Space/Enter activate a focused control — never hijack them for
// play/pause when the user is keyboard-driving a button.
if (action === "toggle-play" && (tag === "BUTTON" || tag === "SELECT" || tag === "A")) {
return;
}
e.preventDefault();
switch (action) {
case "toggle-play":
case "media-toggle-play":
this.togglePlay();
break;
case "media-play":
if (!this.playing) this.togglePlay();
break;
case "media-pause":
if (this.playing) this.togglePlay();
break;
case "skip-back":
this.skip(-1);
break;
case "skip-forward":
this.skip(1);
break;
case "prev-chapter":
case "media-prev":
this.stepChapter(-1);
break;
case "next-chapter":
case "media-next":
this.stepChapter(1);
break;
case "bookmark":
this.openBookmarkCapture();
break;
case "speed-up":
this.setSpeed(stepSpeed(this.speed, 1));
break;
case "speed-down":
this.setSpeed(stepSpeed(this.speed, -1));
break;
case "volume-up":
case "volume-down": {
const step = action === "volume-up" ? 0.05 : -0.05;
const next = Math.round((Number(this.volume) + step) * 100) / 100;
this.setVolume(next);
this.showOsd(`Volume ${Math.round(Number(this.volume) * 100)}%`);
break;
}
case "toggle-mute":
this.toggleMute();
this.showOsd(this.muted ? "Muted" : "Unmuted");
break;
case "cycle-skip-up":
this.setSkip(cycleSkip(this.skipSeconds, 1));
break;
case "cycle-skip-down":
this.setSkip(cycleSkip(this.skipSeconds, -1));
break;
case "toggle-help":
this.helpOpen = !this.helpOpen;
break;
case "escape":
this.closeTopSurface();
break;
}
},
async saveBookmark() {
const audio = this.audio();
if (!audio) return;