Files
bookhoard/web/src/audio-keys.ts
T
John O'Keefe 9d0ae2bcc4
Release / build-and-push (push) Successful in 3m9s
feat(web): sync-conflict banner at top of media item page + end-time help row
- A progress sync conflict used to be flagged only inside the progress
  card, far down a long page. An unresolved conflict now also renders
  a full-width warning strip at the very top (under the app bar) that
  opens the same resolution modal on click. Rendered from the same
  ActiveConflict the progress section uses — absent when clean.
- Shortcuts dialog Mouse section gains an 'End time' row: click to
  cycle total → book left → chapter left (§10's toggle was
  undiscoverable).
2026-10-23 12:30:12 -04:00

263 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}