// 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); } // Pointer/wheel/touch interactions (plan §9) are not key events — the // dispatcher never sees them — but they belong in the same dialog so // every scrub gesture is discoverable in one place. TWO sections, // because the bar really does have two scrub systems (plan §12): the // desktop-pointer family and the touch family. templ x-fors these // through the component's `mouseGuide`/`touchGuide` properties (same // binding pattern as speeds/skipChoices). export interface PointerGuideRow { // Badge label, e.g. "Shift + drag". label: string; description: string; } export const MOUSE_GUIDE: readonly PointerGuideRow[] = [ { label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" }, { label: "Drag", description: "Free scrub across the whole bar" }, { label: "Shift + click", description: "Jump to the nearest chapter line" }, { label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" }, { label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" }, { label: "End time", description: "Click to cycle the −label: total → book left → chapter left", }, ]; export const TOUCH_GUIDE: readonly PointerGuideRow[] = [ { label: "Drag", description: "Free scrub across the whole bar" }, { label: "Pull away", description: "Drag off the bar while holding — the further you pull, the finer it gets", }, ]; // 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; }