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

+32
View File
@@ -3,6 +3,7 @@ package templates
import (
"bookhoard/internal/handlers"
"fmt"
"strings"
)
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
@@ -501,6 +502,37 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div>
</div>
</div>
if book.IsAudio() {
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"
@click="toggleSection('audio')">
<span class="font-semibold">Audio</span>
<span class="inline-flex">
<span x-show="openSections.audio">@Icon("chevron-down", "h-4 w-4")</span>
<span x-show="!openSections.audio" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
</span>
</button>
<div x-show="openSections.audio" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Narrator(s)</label>
<input type="text" name="narrators" value={ strings.Join(book.Narrators, ", ") } class="input" placeholder="Comma-separated names"/>
</div>
<div>
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="checkbox" name="abridged" checked={ book.Abridged } class="h-4 w-4 accent-current"/>
<span>Abridged edition</span>
</label>
</div>
if book.AudioDuration() > 0 {
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Duration (scanned)</label>
<p class="input opacity-60 cursor-default tabular-nums" style="color: var(--text-primary);">{ book.AudioDurationLabel() }</p>
</div>
}
</div>
</div>
}
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"