feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks

A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.

A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.

A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).

A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.

Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
This commit is contained in:
John O'Keefe committed 2026-10-15 18:03:35 -04:00
1 parent 902cbe5915
commit 86f0af632e
38 files changed
+3069 -1552

No files matched your search

+7 -7
View File
@@ -118,7 +118,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- 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, 7, "\"><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);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -130,7 +130,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 41, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 86, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -143,7 +143,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 42, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 87, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -154,25 +154,25 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"material-symbols-rounded text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">headphones</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div><h1 class=\"font-semibold text-lg leading-snug\" style=\"color: var(--text-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 51, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 96, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><p class=\"text-sm mt-0.5\" 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\" :value=\"chaptersDuration() || 1\" class=\"flex-1 accent-current\" @input.debounce.300ms=\"seekTo($event.target.value)\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-6\"><button type=\"button\" @click=\"skip(-1)\" class=\"rounded-full p-3 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-lg font-semibold\">-<span x-text=\"skipSeconds\">10</span>s</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=\"text-3xl leading-none\">▶</span> <span x-show=\"playing\" class=\"text-3xl leading-none\" x-cloak>❚❚</span></button> <button type=\"button\" @click=\"skip(1)\" class=\"rounded-full p-3 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-lg font-semibold\">+<span x-text=\"skipSeconds\">10</span>s</span></button></div></div><!-- Chapter list + speed --><div class=\"w-full max-w-xl\"><div class=\"flex items-center justify-between\"><button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors\" style=\"color: var(--text-secondary);\">Chapters <span x-text=\"'(' + chapters.length + ')'\"></span></button><div class=\"flex items-center gap-1.5\"><span class=\"text-xs\" style=\"color: var(--text-secondary);\">Skip</span><template x-for=\"secs in [1, 5, 10, 15, 30]\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"setSkip(secs)\" class=\"text-xs px-2 py-1 rounded-md transition-colors\" :class=\"Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"secs + 's'\"></button></template></div></div><div class=\"flex items-center justify-between mt-1\"><span class=\"text-xs\" style=\"color: var(--text-secondary);\">Speed</span><div class=\"flex items-center gap-1.5\"><template x-for=\"rate in [0.75, 1, 1.25, 1.5, 2]\" :key=\"rate\"><button type=\"button\" @click=\"setSpeed(rate)\" class=\"text-xs px-2 py-1 rounded-md transition-colors\" :class=\"Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"rate + '×'\"></button></template></div></div><div x-show=\"chaptersOpen\" x-cloak class=\"mt-2 max-h-64 overflow-y-auto rounded-xl border p-2\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><template x-for=\"ch in chapters\" :key=\"ch.index\"><button type=\"button\" @click=\"jumpToChapter(ch.index)\" class=\"w-full text-left px-3 py-2 rounded-lg text-sm flex justify-between gap-3 hover:bg-surface-hover transition-colors\" :class=\"chapters.length && currentChapter() === ch.index ? 'font-semibold' : ''\" style=\"color: var(--text-secondary);\"><span class=\"truncate\" x-text=\"ch.title || ('Chapter ' + (ch.index + 1))\"></span> <span class=\"shrink-0 text-xs tabular-nums\" x-text=\"fmtTime(ch.start)\"></span></button></template><p x-show=\"!chapters.length\" class=\"text-sm text-center py-4\" style=\"color: var(--text-secondary);\">No chapters detected for this audiobook.</p></div></div><audio id=\"audio-element\" preload=\"auto\" class=\"hidden\"></audio></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</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-6\"><button type=\"button\" @click=\"skip(-1)\" class=\"rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-base font-semibold\">–<span x-text=\"skipSeconds\">10</span>s</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=\"text-3xl leading-none\">▶</span> <span x-show=\"playing\" class=\"text-3xl leading-none\" x-cloak>❚❚</span></button> <button type=\"button\" @click=\"skip(1)\" class=\"rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-base font-semibold\">+<span x-text=\"skipSeconds\">10</span>s</span></button> <button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"rounded-full p-2.5 ml-2 hover:bg-surface-hover transition-colors\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-60'\" aria-label=\"Add bookmark at current position\" title=\"Add bookmark\" style=\"color: var(--text-primary);\"><svg width=\"20\" height=\"20\" 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> <path d=\"M12 7v6M9 10h6\"></path></svg></button></div></div><!-- Skip / Speed rows: one layout rule, labels left, chips right --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center justify-between\"><span class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Skip</span><div class=\"flex items-center gap-1.5\"><template x-for=\"secs in [1, 5, 10, 15, 30]\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"setSkip(secs)\" class=\"text-xs px-2.5 py-1.5 rounded-md transition-colors\" :class=\"Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"secs + 's'\"></button></template></div></div><div class=\"flex items-center justify-between\"><span class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Speed</span><div class=\"flex items-center gap-1.5\"><template x-for=\"rate in [0.75, 1, 1.25, 1.5, 2]\" :key=\"rate\"><button type=\"button\" @click=\"setSpeed(rate)\" class=\"text-xs px-2.5 py-1.5 rounded-md transition-colors\" :class=\"Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"rate + '×'\"></button></template></div></div></div><!-- Chapter drawer: slides top-to-bottom, floats above the page\n\t\t\t (backdrop overlay — the page never shifts) --><div x-show=\"chaptersOpen\" x-cloak class=\"fixed inset-0 z-40\" style=\"background-color: color-mix(in srgb, black 32%, transparent);\" @click=\"chaptersOpen = false\"></div><div x-show=\"chaptersOpen\" x-cloak x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"-translate-y-4 opacity-0\" x-transition:enter-end=\"translate-y-0 opacity-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"translate-y-0 opacity-100\" x-transition:leave-end=\"-translate-y-4 opacity-0\" class=\"fixed top-0 right-0 z-50 w-full sm:w-96 max-h-[70vh] overflow-y-auto rounded-b-2xl shadow-2xl p-3\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); border-top-width: 0;\" @click.outside=\"chaptersOpen = false\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Chapters</span> <button type=\"button\" @click=\"chaptersOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-labLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}