Files
bookhoard/templates/audio_reader.templ
T
John O'Keefe fe7eab91e8 feat(audio-player): sleep timer — moon pill, countdown, 5s fade then pause (plan §3)
Off / 5 / 10 / 15 / 30 / 45 / 60 minutes / end-of-chapter. The moon
pill sits in the settings strip between Bookmark and Volume and shows
the live mm:ss countdown while armed (or 'End of chapter'); the
dropdown's armed row highlights like Skip/Speed.

audio-sleep.ts is the pure, DOM-free state machine (arm/due/fired/
fade-ramp/label + the SLEEP_CHOICES vocabulary the templ dropdown
iterates). The component owns the timers: a 1s countdown ticker in
minutes mode, and a ~5s element-volume fade on expiry that ends in
audio.pause() — the SAME pause listener a manual pause fires, so the
progress push and media-session state land. The user's configured
volume is restored right after the pause, and the fade operates on
the element's volume the user controls, not a parallel path.

End-of-chapter fires when the armed chapter is left — natural
crossing (tickChapter) or a manual jump (jumpToChapter) alike. Manual
play during a fade aborts the expiry (you're awake, you asked);
stopSleep restores any partial fade. Per-session ONLY: nothing
touches localStorage — the reload check proves it starts Off.

sleepOpen joins closeTopSurface's dropdown branch, so Esc closes it
with the other settings-strip surfaces. Muted (or zero-volume) expiry
skips the pantomime and pauses immediately.

Unit: audio-sleep.test.ts (deadline mechanics, chapter-fired
semantics both directions, fade ramp clamps, label formatting,
SLEEP_CHOICES integrity). Live: 24 Playwright checks on the KJV book
— dropdown, countdown ticking, armed-row highlight, end-of-chapter
fire + reset, internal 3s deadline fade→pause→restore, play-abort,
reload-starts-Off; probe user created and deleted.
2026-10-22 08:40:54 -04:00

556 lines
28 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>
<button
type="button"
@click="helpOpen = !helpOpen"
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="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
>
<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">
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8"></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 skipChoices" :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 speeds" :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="sleepOpen = false">
<button
type="button"
@click="sleepOpen = !sleepOpen"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:aria-label="'Sleep timer, currently ' + sleepLabelStr"
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="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
<span class="font-semibold tabular-nums" style="color: var(--text-primary);" x-text="sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr">Sleep</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="sleepOpen"
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-[8.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="opt in sleepChoices" :key="opt.key">
<button
type="button"
@click="chooseSleep(opt); sleepOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="opt.label"
></button>
</template>
</div>
</div>
<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
id="bookmark-note"
x-model="bookmarkNote"
rows="3"
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
class="input mb-2 resize-y"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
></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>
<!-- Volume/mute OSD: brief confirmation for keyboard-driven changes -->
<div
x-show="osdVisible"
x-cloak
x-transition.opacity.duration.200ms
class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 text-xs px-3 py-1.5 rounded-full shadow-lg tabular-nums"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);"
x-text="osdText"
></div>
<!-- Shortcut help (?): rows come from the shared KEYMAP
(audio-keys.ts) — the same table drives the keydown handler. -->
<div x-show="helpOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="helpOpen = false"></div>
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Keyboard shortcuts</span>
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
<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="spec in visibleKeymap()" :key="spec.action">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="spec.description"></span>
<span class="flex items-center gap-1 shrink-0">
<template x-for="k in spec.keys" :key="k.label">
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="k.label"></kbd>
</template>
</span>
</div>
</template>
</div>
</div>
<audio id="audio-element" preload="auto" class="hidden"></audio>
</div>
</body>
</html>
}