Files
bookhoard/templates/audio_reader.templ
T
John O'Keefe b89eac22e3
Release / build-and-push (push) Successful in 3m31s
fix(templ): stop emitting literal { expr } text into attributes and body classes
templ does not interpolate { expr } inside quoted attribute values — the
braces ship as literal text (AGENTS.md gotcha #1). Swept every templ
source for the pattern and fixed all of it:

Body theme classes: 22 page bodies rendered class="theme-{ user.Theme }"
literally, so every page first-painted the :root default (Tokyo Night)
and flashed when main.js applied the real theme. All now render
class={ "theme-" + user.Theme } server-side.

Attribute interpolations (26 across 8 files):
- api_explorer: initAPIExplorerDoc path/body/response and the Copy
  Request/Response buttons (invalid JS, silently dead)
- collections: book-selection :checked/@change/@click ids and four
  hx-get search URLs (collection_id={ collection.ID })
- conflicts: Resolve button id
- devices: Kobo sync-url/auth-token element ids (copy lookups only
  worked because the id and the lookup were the same literal)
- unlinked_books: search-matches/manual-link clicks and container ids
- queue: status/priority badge classes never matched their CSS
- dashboard: Restore-section tooltip text
- collection_rules/collections: collection icon color

Free text goes through the new templates.JSONArg (json.Marshal) —
templ.JSONString in this templ version returns (string, error) and
cannot compose in attribute expressions; uuids via device.ID.String().
Includes regen-only drift from running templ generate (error-path
normalization) on files with no source change.
2026-10-18 12:08:20 -04:00

472 lines
23 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
<!-- Header: back to the detail page (reader-style exit) and the
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 metadata.CoverImagePath != "" {
<img
src={ metadata.CoverImagePath }
alt={ metadata.Title }
class="w-full h-full object-cover"
onerror="this.style.display='none'"
/>
} else {
<span class="text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">🎧</span>
}
</div>
<div class="max-w-md px-4">
<h1 class="font-semibold text-lg leading-snug line-clamp-2" style="color: var(--text-primary);">{ metadata.Title }</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></svg>
</button>
<div
x-show="skipOpen"
x-cloak
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;"
>
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
<button
type="button"
@click="setSkip(secs); skipOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(skipSeconds) === secs ? 'font-semibold' : ''"
:style="Number(skipSeconds) === secs ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="secs + ' seconds'"
></button>
</template>
</div>
</div>
<div class="relative" @click.outside="speedOpen = false">
<button
type="button"
@click="speedOpen = !speedOpen"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:aria-label="'Playback speed, currently ' + speed + ' times'"
style="color: var(--text-secondary);"
>
<span>Speed</span>
<span class="font-semibold" style="color: var(--text-primary);" x-text="speed + '×'">1×</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></svg>
</button>
<div
x-show="speedOpen"
x-cloak
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-[7rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate">
<button
type="button"
@click="setSpeed(rate); speedOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(speed) === rate ? 'font-semibold' : ''"
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="rate + '×'"
></button>
</template>
</div>
</div>
<button
type="button"
@click="toggleBookmarkCapture()"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:class="bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'"
aria-label="Add bookmark at current position"
style="color: var(--text-secondary);"
>
<svg width="15" height="15" 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>
<span>Bookmark</span>
</button>
<div class="relative" @click.outside="volumeOpen = false">
<button
type="button"
@click="volumeOpen = !volumeOpen"
class="rounded-lg p-2 hover:bg-surface-hover transition-colors"
:aria-label="'Volume ' + Math.round(Number(volume) * 100) + '%'"
style="color: var(--text-secondary);"
>
<span x-show="volumeIcon() === 'muted'" x-cloak>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="m17 9 4 6M21 9l-4 6"></path>
</svg>
</span>
<span x-show="volumeIcon() === 'low'">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M15.5 9.5a3.5 3.5 0 0 1 0 5"></path>
</svg>
</span>
<span x-show="volumeIcon() === 'mid'">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M14.5 8a5 5 0 0 1 0 8"></path>
<path d="M17.5 5.5a9 9 0 0 1 0 13"></path>
</svg>
</span>
<span x-show="volumeIcon() === 'high'">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M14.5 8a5 5 0 0 1 0 8"></path>
<path d="M17.5 5.5a9 9 0 0 1 0 13"></path>
<path d="M20.5 3a13 13 0 0 1 0 18"></path>
</svg>
</span>
</button>
<div
x-show="volumeOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg px-3 py-2.5 flex items-center gap-3"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<button
type="button"
@click="toggleMute()"
class="rounded-full p-1.5 hover:bg-surface-hover transition-colors"
:aria-label="muted ? 'Unmute' : 'Mute'"
style="color: var(--text-secondary);"
>
<span x-show="muted" x-cloak class="block" style="color: var(--text-primary);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="m17 9 4 6M21 9l-4 6"></path>
</svg>
</span>
<span x-show="!muted" class="block">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M14.5 8a5 5 0 0 1 0 8"></path>
<path d="M17.5 5.5a9 9 0 0 1 0 13"></path>
</svg>
</span>
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
:value="muted ? 0 : volume"
@input="setVolume(Number($event.target.value))"
class="accent-current"
style="width: 7.5rem;"
aria-label="Volume"
/>
</div>
</div>
</div>
<!-- Chapter drawer: slides top-to-bottom, floats above the page
(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-label="Close chapters">
<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="ch in chapters" :key="ch.index">
<button
type="button"
@click="jumpToChapter(ch.index)"
class="w-full text-left px-3 py-2.5 rounded-lg text-sm flex justify-between gap-3 hover:bg-surface-hover transition-colors"
:class="currentChapter() === ch.index ? 'font-semibold' : ''"
:style="currentChapter() === ch.index ? 'background-color: var(--accent-muted); color: var(--text-primary);' : '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>
<!-- Bookmarks drawer: same slide-down panel shape as chapters;
note is the point (A2d) -->
<div
x-show="bookmarksOpen"
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;"
>
<div class="flex items-center justify-between mb-2 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Bookmarks</span>
<button type="button" @click="bookmarksOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close bookmarks">
<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="b in bookmarks" :key="String(b.id)">
<div class="px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors">
<div class="flex justify-between items-center gap-3">
<button type="button" class="flex-1 text-left min-w-0" @click="jumpToBookmark(b)">
<span class="text-sm font-medium truncate block" style="color: var(--text-primary);" x-text="b.title"></span>
</button>
<div class="shrink-0 flex items-center gap-1">
<button type="button" @click="startEditNote(b)" class="rounded p-1 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Edit note">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path></svg>
</button>
<button type="button" @click="deleteBookmark(b)" class="rounded p-1 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Delete bookmark">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</div>
<div class="note-rich text-xs mt-1" x-show="b.notes && editingBookmarkId !== String(b.id)" x-html="bookmarkNoteHtml(b)"></div>
<div x-show="editingBookmarkId === String(b.id)" x-cloak class="mt-1">
<textarea x-model="editingNote" rows="3" class="input text-sm resize-y" placeholder="Note (markdown)"></textarea>
<div class="flex justify-end gap-2 mt-1">
<button type="button" @click="editingBookmarkId = ''" class="btn btn-ghost text-xs px-2 py-1">Cancel</button>
<button type="button" @click="saveNote()" class="btn btn-primary text-xs px-2 py-1">Save note</button>
</div>
</div>
</div>
</template>
<p x-show="!bookmarks.length" class="text-sm text-center py-4" style="color: var(--text-secondary);">
No bookmarks yet — tap the <span class="inline-flex align-middle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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></span> button to save your position.
</p>
</div>
<!-- Bookmark capture pill: floats under the header while playing
position is live; optional note per the A2d spec -->
<div
x-show="bookmarkCaptureOpen"
x-cloak
x-transition
class="fixed top-16 right-4 z-50 w-80 rounded-xl shadow-2xl p-3"
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
>
<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
x-model="bookmarkNote"
rows="3"
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
class="input mb-2 resize-y"
></textarea>
<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="saveBookmark()" class="btn btn-primary text-sm px-3 py-1.5">Save</button>
</div>
</div>
<audio id="audio-element" preload="auto" class="hidden"></audio>
</div>
</body>
</html>
}