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

+57 -20
View File
@@ -3,9 +3,10 @@ package templates
import (
"bookhoard/internal/handlers"
"fmt"
"strings"
)
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, errorMessage string) {
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -25,7 +26,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
switch. The widget re-fetches itself via htmx instead. -->
if week != nil {
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
@DashboardWeekWidget(*week)
@DashboardWeekWidget(*week, activeLibType)
</div>
}
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
@@ -130,6 +131,17 @@ templ BookCard(item handlers.BookInfo) {
class="w-full h-full object-cover"
/>
}
<!-- Media-type identifier (A2c): books stay unbadged — the
shelf default already reads as a book -->
if MediaBadgeIcon(item.FormatGroup) != "" {
<span
class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--text-primary); backdrop-filter: blur(3px);"
title={ strings.Title(item.FormatGroup) + " read" }
>
@Icon(MediaBadgeIcon(item.FormatGroup), "h-4 w-4")
</span>
}
</div>
<div class="book-card-meta">
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
@@ -152,6 +164,15 @@ templ BookCard(item handlers.BookInfo) {
>
@Icon("book-open", "h-5 w-5")
</a>
} else if item.FormatGroup == "audiobook" {
<a
href={ "/readers/" + item.MediaItemID }
class="book-card-action-btn"
aria-label={ "Listen to " + item.Title }
title="Listen"
>
@Icon("headphones", "h-5 w-5")
</a>
} else {
<a
href={ "/readers/" + item.MediaItemID }
@@ -285,7 +306,7 @@ type BookStatsData struct {
// itself through htmx when the body sees a `week-refresh` event
// (dispatched by the library switcher), carrying the CURRENT
// #library-select value, so the widget tracks the switched library.
templ DashboardWeekWidget(week WeekStatsData) {
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
<details
id="week-widget"
class="card group"
@@ -315,23 +336,39 @@ templ DashboardWeekWidget(week WeekStatsData) {
</summary>
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Reading Time</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
</div>
if activeLibType == "audiobooks" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Listening Time</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
</div>
} else {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Reading Time</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
</div>
}
</div>
<p class="text-xs mt-3" style="color: var(--text-secondary)">
Synced from your reading devices