The Player settings popover gains a 'Default playback speed' row beside autoplay — a compact rate select, always visible (the Speed ▾ footer hides when current == default, which made the setting undiscoverable at 1x/1×); same setDefaultSpeed PUT. The shortcuts dialog splits its pointer section into MOUSE (click/drag/shift/wheel) and TOUCH (drag/pull-away) — the bar has two scrub systems and the sections now say so.
735 lines
39 KiB
Templ
735 lines
39 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>
|
||
<style>
|
||
/* Layered scrubber (plan §1): the native range stays for
|
||
a11y (focus, arrow keys, aria) but its track is
|
||
transparent so the tick/buffer/fill layers beneath show
|
||
through. Static page CSS — templ owns the look. */
|
||
#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }
|
||
#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }
|
||
#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
|
||
#audio-scrub::-moz-range-track { background: transparent; border: none; }
|
||
#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
|
||
</style>
|
||
</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="Shortcuts & interactions"
|
||
title="Shortcuts & interactions (?)"
|
||
>
|
||
<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>
|
||
<div class="relative" @click.outside="settingsOpen = false">
|
||
<button
|
||
type="button"
|
||
@click="settingsOpen = !settingsOpen"
|
||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||
:class="settingsOpen ? 'opacity-100' : 'opacity-90'"
|
||
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
|
||
aria-label="Player settings"
|
||
title="Settings"
|
||
>
|
||
<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="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||
<circle cx="12" cy="12" r="3"></circle>
|
||
</svg>
|
||
</button>
|
||
<div
|
||
x-show="settingsOpen"
|
||
x-cloak
|
||
x-transition
|
||
class="absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
|
||
>
|
||
<div class="flex items-center justify-between mb-2 px-1">
|
||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Player settings</span>
|
||
<button type="button" @click="settingsOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close settings">
|
||
<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="M18 6 6 18"></path>
|
||
<path d="m6 6 12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<!-- Option rows: autoplay first, panel structured for more -->
|
||
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
|
||
<span class="text-sm" style="color: var(--text-secondary);">Autoplay on open</span>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
:aria-checked="autoplayOn ? 'true' : 'false'"
|
||
@click="toggleAutoplay()"
|
||
class="relative h-5 w-9 rounded-full transition-colors shrink-0"
|
||
:style="autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'"
|
||
aria-label="Autoplay on open"
|
||
>
|
||
<span class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-transform" :class="autoplayOn ? 'translate-x-4' : ''" style="background-color: var(--text-primary);"></span>
|
||
</button>
|
||
</div>
|
||
<!-- §12: default speed — always visible (unlike the
|
||
Speed ▾ footer, which hides when the current rate
|
||
IS the default), same setDefaultSpeed PUT -->
|
||
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
|
||
<span class="text-sm" style="color: var(--text-secondary);">Default playback speed</span>
|
||
<select
|
||
class="text-xs rounded-md px-1.5 py-1"
|
||
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
|
||
aria-label="Default playback speed"
|
||
@change="setDefaultSpeed(Number($event.target.value))"
|
||
>
|
||
for _, rate := range SpeedOptions {
|
||
<option
|
||
value={ fmt.Sprintf("%g", rate) }
|
||
:selected={ fmt.Sprintf("Number(defaultSpeed) === %g", rate) }
|
||
>{ fmt.Sprintf("%g×", rate) }</option>
|
||
}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 class="inline-block min-w-[4.75rem]" x-text="fmtTime(scrubbing ? scrubValue : currentTime)">0:00</span>
|
||
<div
|
||
id="scrub-area"
|
||
class="relative flex-1 h-6 flex items-center cursor-pointer"
|
||
style="touch-action: none;"
|
||
@pointerdown="scrubPointerDown($event)"
|
||
@pointermove="scrubPointerMove($event)"
|
||
@pointerup="scrubPointerUp($event)"
|
||
@pointercancel="scrubPointerUp($event)"
|
||
@pointerleave="scrubPointerLeave()"
|
||
@wheel.prevent="onScrubWheel($event)"
|
||
>
|
||
<!-- Base track -->
|
||
<div class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" style="background-color: var(--surface-hover);"></div>
|
||
<!-- Elapsed fill -->
|
||
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'"></div>
|
||
<!-- Buffered span: the ACTIVE track's element buffer only -->
|
||
<template x-if="bufferSpan()">
|
||
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'"></div>
|
||
</template>
|
||
<!-- Chapter ticks: density-decimated, current chapter tinted -->
|
||
<div id="scrub-ticks" class="absolute inset-0 pointer-events-none">
|
||
<template x-for="idx in tickIndices" :key="'tick' + idx">
|
||
<div
|
||
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full"
|
||
:class="currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'"
|
||
:style="'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'"
|
||
></div>
|
||
</template>
|
||
</div>
|
||
<!-- §9 magnet: the line a click would land on (hover
|
||
inside the pixel radius locks the tooltip here) -->
|
||
<div
|
||
x-show="hoverSnapped && !scrubbing"
|
||
x-cloak
|
||
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none"
|
||
:style="'left:' + hoverPct + '%;background-color:var(--accent);'"
|
||
></div>
|
||
<input
|
||
id="audio-scrub"
|
||
type="range"
|
||
min="0"
|
||
step="0.1"
|
||
:max="chaptersDuration() || 1"
|
||
:value="scrubbing ? scrubValue : currentTime"
|
||
class="relative w-full"
|
||
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
||
@change="seekTo(scrubValue); scrubbing = false"
|
||
aria-label="Seek position"
|
||
/>
|
||
<!-- Hover tooltip: time + chapter under the pointer -->
|
||
<div
|
||
x-show="hoverSeconds !== null && !scrubbing"
|
||
x-cloak
|
||
class="absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none"
|
||
:style="'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'"
|
||
>
|
||
<span x-text="hoverSeconds !== null ? fmtTime(hoverSeconds) : ''"></span>
|
||
<span style="color: var(--text-secondary);" x-text="hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''"></span>
|
||
</div>
|
||
<!-- Precision rail (§9): Shift+drag on mouse, or a touch
|
||
drag pulled off the bar — ±5 min or tighter, free
|
||
mapping inside. Edge labels self-document the window
|
||
(floats over the transports, page never shifts) -->
|
||
<div
|
||
x-show="railVisible"
|
||
x-cloak
|
||
class="absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none"
|
||
:style="'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')"
|
||
>
|
||
<div class="relative h-full">
|
||
<div class="absolute inset-x-0 top-1/2 h-px" style="background-color: var(--border);"></div>
|
||
<template x-for="tick in railTickList" :key="'rtick' + tick.start">
|
||
<div class="absolute top-1 bottom-1 w-px" :style="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></div>
|
||
</template>
|
||
<div class="absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full" :style="'left:' + railMarkerPct() + '%;background-color:var(--accent);'"></div>
|
||
<div class="absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-primary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
|
||
<div class="absolute left-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railFrom)"></div>
|
||
<div class="absolute right-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railTo)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="inline-block min-w-[4.75rem] text-right cursor-pointer"
|
||
x-text="endTimeText()"
|
||
:aria-label="endTimeAria()"
|
||
:title="endTimeAria()"
|
||
@click="cycleEndTime()"
|
||
>0:00</button>
|
||
</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 + '×' + (isDefaultSpeed(rate) ? ' · default' : '')"
|
||
></button>
|
||
</template>
|
||
<!-- §8: pin the current rate as the user default —
|
||
books without their own speed pick it up -->
|
||
<button
|
||
type="button"
|
||
x-show="!isDefaultSpeed(speed)"
|
||
@click="setDefaultSpeed(speed); speedOpen = false"
|
||
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
|
||
style="border-color: var(--border); color: var(--text-secondary);"
|
||
x-text="'Make ' + speed + '× my default'"
|
||
></button>
|
||
</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);">Shortcuts & interactions</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>
|
||
<!-- Pointer gestures (audio-keys.ts MOUSE_GUIDE/TOUCH_GUIDE):
|
||
two sections — the bar has two scrub systems -->
|
||
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Mouse</div>
|
||
<template x-for="row in mouseGuide" :key="'m' + row.label">
|
||
<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="row.description"></span>
|
||
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
|
||
</div>
|
||
</template>
|
||
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Touch</div>
|
||
<template x-for="row in touchGuide" :key="'t' + row.label">
|
||
<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="row.description"></span>
|
||
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<audio id="audio-element" preload="auto" class="hidden"></audio>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
}
|