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> <path d="m6 9 6 6 6-6"></path>
</svg> </svg>
</button> </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> </span>
</div> </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]" 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;" 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 <button
type="button" type="button"
@click="setSkip(secs); skipOpen = false" @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> <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 <textarea
id="bookmark-note"
x-model="bookmarkNote" x-model="bookmarkNote"
rows="3" rows="3"
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))" placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
class="input mb-2 resize-y" class="input mb-2 resize-y"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
></textarea> ></textarea>
<div class="flex justify-end gap-2"> <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> <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>
</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> <audio id="audio-element" preload="auto" class="hidden"></audio>
</div> </div>
</body> </body>
+5 -5
View File
@@ -140,7 +140,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" 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=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><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\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <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\"><path d=\"m6 9 6 6 6-6\"></path></svg></button></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" 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=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><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\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <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\"><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><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -152,7 +152,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 86, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 99, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -165,7 +165,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 87, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 100, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -188,13 +188,13 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 96, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 109, Col: 117}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { KEYMAP, resolveShortcut, visibleKeymap } from "./audio-keys";
describe("resolveShortcut", () => {
it("resolves every in-page binding", () => {
expect(resolveShortcut(" ", false)).toBe("toggle-play");
expect(resolveShortcut("Enter", false)).toBe("toggle-play");
expect(resolveShortcut("h", false)).toBe("skip-back");
expect(resolveShortcut("H", false)).toBe("skip-back");
expect(resolveShortcut("ArrowLeft", false)).toBe("skip-back");
expect(resolveShortcut("l", false)).toBe("skip-forward");
expect(resolveShortcut("ArrowRight", false)).toBe("skip-forward");
expect(resolveShortcut("k", false)).toBe("prev-chapter");
expect(resolveShortcut("PageUp", false)).toBe("prev-chapter");
expect(resolveShortcut("j", false)).toBe("next-chapter");
expect(resolveShortcut("PageDown", false)).toBe("next-chapter");
expect(resolveShortcut("b", false)).toBe("bookmark");
expect(resolveShortcut("ArrowUp", false)).toBe("volume-up");
expect(resolveShortcut("ArrowDown", false)).toBe("volume-down");
expect(resolveShortcut("m", false)).toBe("toggle-mute");
expect(resolveShortcut("]", false)).toBe("cycle-skip-up");
expect(resolveShortcut("[", false)).toBe("cycle-skip-down");
expect(resolveShortcut("?", false)).toBe("toggle-help");
expect(resolveShortcut("Escape", false)).toBe("escape");
});
it("keeps Alt bindings distinct from their plain counterparts", () => {
expect(resolveShortcut("l", true)).toBe("speed-up");
expect(resolveShortcut("l", false)).toBe("skip-forward");
expect(resolveShortcut("h", true)).toBe("speed-down");
expect(resolveShortcut("ArrowRight", true)).toBe("speed-up");
expect(resolveShortcut("ArrowLeft", true)).toBe("speed-down");
// Alt on an unbound key claims nothing.
expect(resolveShortcut("ArrowUp", true)).toBeNull();
expect(resolveShortcut("m", true)).toBeNull();
});
it("resolves in-page media key events", () => {
expect(resolveShortcut("MediaPlayPause", false)).toBe("media-toggle-play");
expect(resolveShortcut("MediaPlay", false)).toBe("media-play");
expect(resolveShortcut("MediaPause", false)).toBe("media-pause");
expect(resolveShortcut("MediaStop", false)).toBe("media-pause");
expect(resolveShortcut("MediaTrackNext", false)).toBe("media-next");
expect(resolveShortcut("MediaTrackPrevious", false)).toBe("media-prev");
});
it("returns null for unknown keys", () => {
expect(resolveShortcut("x", false)).toBeNull();
expect(resolveShortcut("F5", false)).toBeNull();
expect(resolveShortcut("", false)).toBeNull();
});
});
describe("KEYMAP integrity", () => {
it("has exactly one spec per action", () => {
const actions = KEYMAP.map((spec) => spec.action);
expect(new Set(actions).size).toBe(actions.length);
});
it("has no conflicting (key, alt) combos across actions", () => {
const seen = new Map<string, string>();
for (const spec of KEYMAP) {
for (const k of spec.keys) {
const combo = `${k.alt ? "Alt+" : ""}${k.key}`;
const owner = seen.get(combo);
expect(owner, `${combo} bound by both ${owner} and ${spec.action}`).toBeUndefined();
seen.set(combo, spec.action);
}
}
});
it("gives every spec at least one key, a label and a description", () => {
for (const spec of KEYMAP) {
expect(spec.keys.length, spec.action).toBeGreaterThan(0);
expect(spec.label.length, spec.action).toBeGreaterThan(0);
expect(spec.description.length, spec.action).toBeGreaterThan(0);
}
});
it("hides only media-key rows from the dialog", () => {
const visible = new Set(visibleKeymap().map((spec) => spec.action));
for (const spec of KEYMAP) {
if (spec.action.startsWith("media-")) {
expect(visible.has(spec.action), spec.action).toBe(false);
} else {
expect(visible.has(spec.action), spec.action).toBe(true);
}
}
});
});
+229
View File
@@ -0,0 +1,229 @@
// 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);
}
// 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;
}
+19 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SPEEDS, normalizeSpeed, stepSpeed } from "./audio"; import { SPEEDS, normalizeSpeed, stepSpeed, SKIP_SECONDS, cycleSkip } from "./audio";
describe("SPEEDS", () => { describe("SPEEDS", () => {
it("is the agreed list from the player plan (0.5 through 3)", () => { it("is the agreed list from the player plan (0.5 through 3)", () => {
@@ -53,3 +53,21 @@ describe("stepSpeed", () => {
expect(stepSpeed(2.2, -1)).toBe(0.75); expect(stepSpeed(2.2, -1)).toBe(0.75);
}); });
}); });
describe("SKIP_SECONDS + cycleSkip", () => {
it("is the agreed skip list", () => {
expect(SKIP_SECONDS).toEqual([1, 5, 10, 15, 30]);
});
it("cycles forward and backward, clamping at the ends", () => {
expect(cycleSkip(10, 1)).toBe(15);
expect(cycleSkip(10, -1)).toBe(5);
expect(cycleSkip(1, -1)).toBe(1);
expect(cycleSkip(30, 1)).toBe(30);
});
it("anchors unknown currents at 10s before stepping", () => {
expect(cycleSkip(7, 1)).toBe(15);
expect(cycleSkip(7, -1)).toBe(5);
});
});
+172 -15
View File
@@ -15,6 +15,7 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown"; import { renderNoteHtml } from "./markdown";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
type AudioChapter = { index: number; title: string; start: number; end: number }; 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))]; 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; const PROGRESS_INTERVAL_MS = 30_000;
interface AudioConfig { interface AudioConfig {
@@ -141,6 +155,14 @@ Alpine.data("audioReader", () => ({
skipOpen: false, skipOpen: false,
speedOpen: false, speedOpen: false,
volumeOpen: 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 // Volume: level 0..1 + mute, persisted separately. prevVolume restores
// on unmute (spec: icon shows level arcs, crossed when muted). // on unmute (spec: icon shows level arcs, crossed when muted).
@@ -272,21 +294,11 @@ Alpine.data("audioReader", () => ({
if (document.visibilityState === "hidden") this.pushProgress(true); if (document.visibilityState === "hidden") this.pushProgress(true);
}); });
// Spacebar = play/pause (the reader's keyboard contract, audio flavor). // Keyboard shortcuts (plan §2): one dispatcher resolving through
document.addEventListener("keydown", (e) => { // the declarative audio-keys.ts keymap. Hardware media keys ride
const target = e.target as HTMLElement | null; // the MediaSession handlers above; the Media* keydown codes land
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return; // here and call the SAME methods.
if (e.code === "Space") { document.addEventListener("keydown", (e) => this.handleKeydown(e));
e.preventDefault();
this.togglePlay();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
this.skip(-1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
this.skip(1);
}
});
}, },
audio(): HTMLAudioElement | null { audio(): HTMLAudioElement | null {
@@ -560,6 +572,151 @@ Alpine.data("audioReader", () => ({
this.bookmarkCaptureOpen = true; 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() { async saveBookmark() {
const audio = this.audio(); const audio = this.audio();
if (!audio) return; if (!audio) return;