Phase 1 (foundation): 'audiobooks' library type seeded (schema + DB);
audio_metadata JSONB on media_items (the kobo_metadata precedent —
only audio paths read it); reading_progress.audio_offset_seconds for
the honest {chapter, offset} locator; extensions/mimes registered for
ALL breadth formats (m4b/m4a/mp3/flac — deliberate per the DOC/LIT
doctrine, m4b scans richly first); classifyFormatGroup 'audiobook'
case (gotcha 39 recomputes on rescan); Go mime table extended at boot
so media elements accept the streams; ffmpeg/ffprobe in the image
(8.1.2 — also the xHE-AAC identification tripwire).
Phase 2 (ingest): sidecar-first (Libation/ABS metadata.json —
narrators/ASIN/curated chapters), ffprobe fallback/filler (duration,
bitrate, codec/profile, embedded chapters). Fixed three real
parser brittlenesses the Libation sidecar exposed: series accepts
string OR array-of-objects, publishedYear accepts number OR string,
ffprobe chapter timings accept number OR string (flexFloat). Folder-
structure fallback now runs on the UPDATE path too (rescans were
clearing sidecar-absent series/author); audio facts re-derive on
every rescan (gotcha 39).
Phase 3 (playback + sync): GET /api/media-items/:id/chapters (the
format-abstraction list); streaming rides the existing cookie-authed
Range /uploads path; PUT progress carries audio_offset_seconds +
elapsed_seconds (honest wall-clock playing time) — the reading-
history capture prefers player-reported elapsed over the inter-push
delta, same 2h ceiling; reading_mode='audio' marks the row.
Phase 4 (web player): dedicated audio reader page (foliate not
involved) — cover, chapter title, whole-book scrubber, skip ± (chips
1/5/10/15/30s, default 10s), speed (0.75×–2×), chapter list with
jump-to, Media Session lockscreen/notification transport, periodic
playing-only progress pushes, position restore on open.
Verified live: ingest of the Libation sample (Gardens of the Moon —
title/author/series/cover/date from sidecar+folders, 34 chapters,
26h duration, aac/LC); chapters endpoint; the reader route serving
the player; playback with position restore (0:57 round-trip);
progress rows in audio mode; wall-clock session rows in
reading_history.
168 lines
6.6 KiB
Templ
168 lines
6.6 KiB
Templ
package templates
|
||
|
||
// AudioReader is the reader surface for audiobooks (media_items with
|
||
// format_group = "audiobook"): cover, chapter, scrubber, skip/speed
|
||
// controls and the chapter list. Standalone page — the foliate document
|
||
// engine is deliberately not involved (handoff item 3 Batch A); the
|
||
// <audio> element streams the m4b through the existing cookie-authed
|
||
// Range-serving /uploads path.
|
||
|
||
import "fmt"
|
||
|
||
templ AudioReader(user User, metadata ReaderMetadata) {
|
||
<!DOCTYPE html/>
|
||
<html lang="en">
|
||
<head>
|
||
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
<script type="module" src="/static/main.js"></script>
|
||
</head>
|
||
<body
|
||
x-data="audioReader"
|
||
x-init="init()"
|
||
class="theme-{ user.Theme } h-screen overflow-hidden"
|
||
style="background-color: var(--bg-primary);"
|
||
data-media-item-id={ metadata.MediaItemID }
|
||
data-title={ metadata.Title }
|
||
data-cover={ metadata.CoverImagePath }
|
||
data-file-url={ metadata.FileURL }
|
||
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||
>
|
||
<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);">
|
||
if metadata.CoverImagePath != "" {
|
||
<img
|
||
src={ metadata.CoverImagePath }
|
||
alt={ metadata.Title }
|
||
class="w-full h-full object-cover"
|
||
onerror="this.style.display='none'"
|
||
/>
|
||
} else {
|
||
<span class="material-symbols-rounded text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">headphones</span>
|
||
}
|
||
</div>
|
||
<div>
|
||
<h1 class="font-semibold text-lg leading-snug" style="color: var(--text-primary);">{ metadata.Title }</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>
|
||
}
|