Release / build-and-push (push) Successful in 3m9s
- 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).
263 lines
7.7 KiB
TypeScript
263 lines
7.7 KiB
TypeScript
// 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;
|
||
}
|