@@ -26,6 +26,65 @@
let samplePlayingVoice = $state < string | null >( null );
let sampleAudio : HTMLAudioElement | null = null ;
// ── Pull-down-to-dismiss gesture ─────────────────────────────────────────
let dragY = $state ( 0 );
let isDragging = $state ( false );
let dragStartY = 0 ;
let dragStartTime = 0 ;
let overlayEl = $state < HTMLDivElement | null >( null );
// Register ontouchmove with passive:false so e.preventDefault() works.
// Svelte 5 does not support the |nonpassive modifier, so we use $effect.
$effect (() => {
if ( ! overlayEl ) return ;
overlayEl . addEventListener ( 'touchmove' , onTouchMove , { passive : false });
return () => overlayEl ! . removeEventListener ( 'touchmove' , onTouchMove );
});
function onTouchStart ( e : TouchEvent ) {
// Don't hijack touches that start inside a scrollable element
const target = e . target as Element ;
if ( target . closest ( '.overflow-y-auto' )) return ;
// Don't activate if a modal is open (they handle their own scroll)
if ( showVoiceModal || showChapterModal ) return ;
isDragging = true ;
dragStartY = e . touches [ 0 ]. clientY ;
dragStartTime = Date . now ();
dragY = 0 ;
}
function onTouchMove ( e : TouchEvent ) {
if ( ! isDragging ) return ;
const delta = e . touches [ 0 ]. clientY - dragStartY ;
// Only track downward movement
if ( delta > 0 ) {
dragY = delta ;
// Prevent page scroll while dragging the overlay down
e . preventDefault ();
} else {
dragY = 0 ;
}
}
function onTouchEnd() {
if ( ! isDragging ) return ;
isDragging = false ;
const elapsed = Date . now () - dragStartTime ;
const velocity = dragY / Math . max ( elapsed , 1 ); // px/ms
// Dismiss if dragged far enough (>130px) or flicked fast enough (>0.4px/ms)
if ( dragY > 130 || velocity > 0.4 ) {
// Animate out: snap to bottom then close
dragY = window . innerHeight ;
setTimeout ( onclose , 220 );
} else {
// Spring back to 0
dragY = 0 ;
}
}
// ── Voice search filtering ────────────────────────────────────────────────
const voiceSearchLower = $derived ( voiceSearch . toLowerCase ());
const filteredKokoro = $derived ( kokoroVoices . filter (( v ) => voiceLabel ( v ). toLowerCase (). includes ( voiceSearchLower )));
@@ -188,67 +247,113 @@
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
< div
bind:this = { overlayEl }
class="fixed inset-0 z-60 flex flex-col overflow-hidden "
style = "background: var(--color-surface);"
style = "
background: var(--color-surface);
transform: translateY( { dragY } px);
opacity: { Math . max ( 0 , 1 - dragY / 500 )} ;
transition: { isDragging ? 'none' : 'transform 0.32s cubic-bezier(0.32,0.72,0,1), opacity 0.32s ease' } ;
will-change: transform;
touch-action: pan-x;
"
ontouchstart = { onTouchStart }
ontouchend= { onTouchEnd }
>
<!-- Blurred cover background -->
{ #if audioStore . cover }
<!-- ── Full-bleed cover hero (top ~50% of screen) ────────────────────── -->
< div class = "relative w-full shrink-0" style = "height: 52svh; min-height: 220px; max-height: 380px;" >
{ #if audioStore . cover }
<!-- Full-bleed cover image -->
< img
src = { audioStore . cover }
alt=""
class = "absolute inset-0 w-full h-full object-cover"
/>
{ : else }
<!-- Fallback when no cover -->
< div class = "absolute inset-0 flex items-center justify-center bg-(--color-surface-2)" >
< svg class = "w-20 h-20 text-(--color-muted)/30" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z" />
</ svg >
</ div >
{ /if }
<!-- Top gradient: surface → transparent (for header legibility) -->
< div
class = "absolute inset-0 bg-cover bg-center opacity-10 blur-2xl scale-110 "
style = "background-image: url(' { audioStore . cover } ' );"
class = "absolute inset-x-0 top-0 h-28 pointer-events-none "
style = "background: linear-gradient(to bottom, var(--color-surface) 0%, transparent 100% );"
aria-hidden = "true"
></ div >
<!-- Bottom gradient: transparent → surface (seamless blend into controls) -->
< div
class = "absolute inset-x-0 bottom-0 h-40 pointer-events-none"
style = "background: linear-gradient(to top, var(--color-surface) 0%, transparent 100%);"
aria-hidden = "true"
></ div >
{ /if }
<!-- Header bar -->
< div class = "relative flex items-center justify-between px-4 py -3 shrink-0 " >
< button
type = "button"
onclick = { onclose }
class="p-2 rounded-full text- ( --color-muted ) hover:text- ( --color-text ) hover:bg- ( --color-surface-2 ) transition-colors "
aria-label = "Close listening mode"
>
< svg class = "w-5 h-5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M19 9l-7 7-7-7" />
</ svg >
</ button >
< span class = "text-xs font-semibold text-(--color-muted) uppercase tracking-wider" > Now Playing</ span >
< div class = "flex items-center gap-2" >
<!-- Chapters button -->
{ #if audioStore . chapters . length > 0 }
< button
type = "button"
onclick = {() => { showChapterModal = ! showChapterModal ; showVoiceModal = false ; voiceSearch = '' ; }}
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors' ,
showChapterModal
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-label = "Browse chapters"
>
< svg class = "w-3.5 h-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M4 6h16M4 10h16M4 14h10" />
</ svg >
Chapters
</ button >
{ /if }
<!-- Voice selector button -->
<!-- Header bar (sits over the top gradient) -->
< div class = "relative z-10 flex items-center justify-between px-4 pt -3 pb-2 " >
< button
type = "button"
onclick = {() => { showVoiceModal = ! showVoiceModal ; showChapterModal = false ; } }
class={ cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors' ,
showVoiceModal
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
onclick = {onclose }
class="p-2 rounded-full text- ( --color-text )/ 70 hover:text- ( --color-text ) hover:bg-black / 20 transition-colors "
aria-label = "Close listening mode"
>
< svg class = "w-3. 5 h-3. 5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z " />
< svg class = "w-5 h-5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M19 9l-7 7-7-7 " />
</ svg >
< span class = "max-w-[80px] truncate" > { voiceLabel ( audioStore . voice )} </ span >
</ button >
< span class = "text-xs font-semibold text-(--color-text)/60 uppercase tracking-wider" > Now Playing</ span >
< div class = "flex items-center gap-2" >
<!-- Chapters button -->
{ #if audioStore . chapters . length > 0 }
< button
type = "button"
onclick = {() => { showChapterModal = ! showChapterModal ; showVoiceModal = false ; voiceSearch = '' ; }}
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors' ,
showChapterModal
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-white/20 bg-black/25 text-(--color-text)/70 hover:text-(--color-text) backdrop-blur-sm'
)}
aria-label = "Browse chapters"
>
< svg class = "w-3.5 h-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M4 6h16M4 10h16M4 14h10" />
</ svg >
Chapters
</ button >
{ /if }
<!-- Voice selector button -->
< button
type = "button"
onclick = {() => { showVoiceModal = ! showVoiceModal ; showChapterModal = false ; }}
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors' ,
showVoiceModal
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-white/20 bg-black/25 text-(--color-text)/70 hover:text-(--color-text) backdrop-blur-sm'
)}
>
< svg class = "w-3.5 h-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</ svg >
< span class = "max-w-[80px] truncate" > { voiceLabel ( audioStore . voice )} </ span >
</ button >
</ div >
</ div >
<!-- Track info (sits over the bottom gradient) -->
< div class = "absolute inset-x-0 bottom-0 z-10 px-6 pb-3 text-center" >
{ #if audioStore . chapter > 0 }
< p class = "text-[10px] font-bold uppercase tracking-widest text-(--color-brand) mb-0.5" >
Chapter { audioStore . chapter }
</ p >
{ /if }
< p class = "text-lg font-bold text-(--color-text) leading-snug line-clamp-2" >
{ audioStore . chapterTitle || ( audioStore . chapter > 0 ? `Chapter $ { audioStore . chapter } ` : '')}
</p>
<p class="text-sm text-(--color-text)/50 mt-0.5 truncate">{audioStore.bookTitle}</p>
</div>
</div>
@@ -273,7 +378,6 @@
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Select Voice</span>
</div>
<!-- Search input -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
@@ -288,7 +392,6 @@
/>
</div>
</div>
<!-- Voice list -->
<div class="flex-1 overflow-y-auto">
{#each ([['Kokoro', filteredKokoro], ['Pocket TTS', filteredPocket], ['CF AI', filteredCfai]] as [string, Voice[]][]) as [label, group]}
@@ -298,45 +401,24 @@
<div
class={cn(
'flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors',
audioStore. voice === v . id
? 'bg-(--color-brand)/8'
: 'hover:bg-(--color-surface-2)'
audioStore.voice === v.id ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- S elect v oice -->
< button
type = "button"
onclick = {() => selectVoice ( v . id )}
class="flex-1 flex items-center gap-3 text-left "
>
<!-- Selected indicator -->
<button type="button" onclick={() => s electV oice(v.id)} class="flex-1 flex items-center gap-3 text-left">
<span class={cn(
'w-4 h-4 shrink-0 rounded-full border-2 flex items-center justify-center transition-colors',
audioStore. voice === v . id
? 'border-(--color-brand) bg-(--color-brand)'
: 'border-(--color-border)'
audioStore.voice === v.id ? 'border-(--color-brand) bg-(--color-brand)' : 'border-(--color-border)'
)}>
{#if audioStore.voice === v.id}
< svg class = "w-2 h-2 text-(--color-surface)" fill= "currentColor" viewBox= "0 0 24 24">
< path d = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</ svg >
<svg class= "w-2 h-2 text-(--color-surface)" fill= "currentColor" viewBox= "0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
{/if}
</span>
< span class= { cn (
'text-sm' ,
audioStore . voice === v . id ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)} > { voiceLabel ( v )} </span >
< span class={cn('text-sm', audioStore.voice === v.id ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{voiceLabel(v)}</span>
</button>
<!-- Sample play button -->
<button
type="button"
onclick={() => playSample(v.id)}
class={ cn (
'shrink-0 p-2 rounded-full transition-colors' ,
samplePlayingVoice === v . id
? 'text-(--color-brand) bg-(--color-brand)/10'
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
)}
class={cn('shrink-0 p-2 rounded-full transition-colors', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)')}
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
>
@@ -360,11 +442,7 @@
<!-- Chapter modal (full-screen overlay) -->
{#if showChapterModal && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
< div
class = "absolute inset-0 z-70 flex flex-col"
style = "background: var(--color-surface);"
>
<!-- Modal header -->
<div class="absolute inset-0 z-70 flex flex-col" style="background: var(--color-surface);">
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<button
type="button"
@@ -378,7 +456,6 @@
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search input -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -392,35 +469,24 @@
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{ #each filteredChapters as ch ( ch . number )}
< button
type= "button"
onclick= {() => playChapter ( ch . number )}
use:scrollIfActive={ ch . number === audioStore. chapter}
class= { cn (
{ #each filteredChapters as ch (ch.number)}
< button
type= "button"
onclick={() => playChapter(ch.number)}
use:scrollIfActive={ch.number === audioStore. chapter}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge (mirrors voice radio indicator) -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch. number === audioStore. chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
ch. number === audioStore. chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -- >
< span class = { cn (
'flex-1 text-sm truncate' ,
ch . number === audioStore . chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)} > { ch . title || `Chapter $ { ch . number } `}</span>
<!-- Now-playing indicator -->
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || ` Chapter $ { ch . number } ` } </ span >
{ #if ch . number === audioStore . chapter }
<svg class= "w-4 h-4 shrink-0 text-(--color-brand)" fill= "currentColor" viewBox= "0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
< svg class = "w-4 h-4 shrink-0 text-(--color-brand)" fill= "currentColor" viewBox= "0 0 24 24">< path d = "M8 5v14l11-7z" /></ svg >
{ /if }
</ button >
{ /each }
@@ -431,195 +497,173 @@
</ div >
{ /if }
<!-- Scrollable body — fil ls remaining height, content spread vertically -->
<div class="relative flex-1 overflow-y-auto flex flex-col justify-between py-4">
<!-- ── Contro ls a rea (bottom half) ───────────────────────────────────── -->
< div class = "flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" >
<!-- Cover art + track info -->
<div class="flex flex-col items-center px-8 shrink-0">
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-44 h-64 object-cover rounded-xl shadow-2xl mb-5"
/>
{:else}
<div class="w-44 h-64 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
</div>
{/if}
<p class="text-base font-bold text-(--color-text) text-center leading-snug">
{audioStore.chapterTitle || (audioStore.chapter > 0 ? ` Chapter $ { audioStore . chapter } ` : '') }
</ p >
< p class = "text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full" > { audioStore . bookTitle } </ p >
<!-- Seek bar -->
< div class = "shrink-0 mb-1" >
< input
type = "range"
aria-label = "Seek"
min = "0"
max = { audioStore . duration || 0 }
value= { audioStore . currentTime }
oninput = { seek }
class="w-full h-1 . 5 cursor-pointer block "
style = "accent-color: var(--color-brand);"
/>
< div class = "flex justify-between text-xs text-(--color-muted) tabular-nums mt-1" >
< span > { formatTime ( audioStore . currentTime )} </ span >
<!-- Remaining time in centre -->
{ #if audioStore . duration > 0 }
< span class = "text-(--color-muted)/60" > − { formatTime ( Math . max ( 0 , audioStore . duration - audioStore . currentTime ))} </ span >
{ /if }
< span > { formatTime ( audioStore . duration )} </ span >
</ div >
</ div >
<!-- Bottom controls cluster: seek + transport + secondary -->
< div class = "flex flex-col gap-0 px-6 shrink-0" >
<!-- Seek bar -->
< div class = "shrink-0" >
< input
type = "range "
aria-label = "Seek "
min = "0 "
max = { audioStore . duration || 0 }
value= { audioStore . currentTime }
oninput = { seek }
class="w-full h-1 . 5 accent- [ --color-brand ] cursor-pointer block "
style = "accent-color: var(--color-brand);"
/>
< div class = "flex justify-between text-xs text-(--color-muted) tabular-nums mt-1" >
< span > { formatTime ( audioStore . currentTime )} </ span >
< span > { formatTime ( audioStore . duration )} </ span >
</ div >
</ div >
<!-- Transport controls -->
< div class = "flex items-center justify-center gap-4 pt-5 pb-3 shrink-0" >
<!-- Prev chapter -->
{ #if audioStore . chapter > 1 && audioStore . slug }
< a
href = "/books/ { audioStore . slug } /chapters/ { audioStore . chapter - 1 } "
class = "p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title = "Previous chapter"
aria-label = "Previous chapter"
>
< svg class = "w-5 h-5" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</ svg >
</ a >
{ : else }
< div class = "w-9 h-9" ></ div >
{ /if }
<!-- Skip back 15s -->
< button
type = "button"
onclick = { skipBack }
class="p-2 rounded-full text- ( --color-muted ) hover:text- ( --color-text ) hover:bg- ( --color-surface-2 ) transition-colors "
aria-label = "Skip back 15 seconds"
title = "Back 15s"
<!-- Transport controls -->
< div class = "flex items-center justify-between pt-3 pb-4 shrink-0" >
<!-- Prev chapter — smaller, clearly secondary -->
{ #if audioStore . chapter > 1 && audioStore . slug }
< a
href = "/books/ { audioStore . slug } /chapters/ { audioStore . chapter - 1 } "
class = "p-2 rounded-full text-(--color-muted)/60 hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors "
title = "Previous chapter "
aria-label = "Previous chapter "
>
< svg class = "w-6 h-6 " fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8 z" />
< text x = "8.5" y = "14.5" font-size = "5" font-family = "sans-serif" font-weight = "bold" fill = "currentColor" > 15</ text >
< svg class = "w-5 h-5 " fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M6 6h2v12H6zm3.5 6l8.5 6V6 z" />
</ svg >
</ button >
</ a >
{ : else }
< div class = "w-9 h-9" ></ div >
{ /if }
<!-- Play / Pause -->
< button
type = "button"
onclick = { togglePlay }
class="w-16 h-16 rounded-full bg -( --color-bran d ) text- ( --color-surface ) flex items-center justify-center hover:bg- ( --color-brand-dim ) transition-colors shadow-lg "
aria-label = { audioStore . isPlaying ? 'Pause' : 'Play' }
>
{ #if audioStore . isPlaying }
< svg class = "w-7 h-7" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M6 4h4v16H6V4zm8 0h4v16h-4V4 z" />
</ svg >
{ : else }
< svg class = "w-7 h-7 ml-1" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M8 5v14l11-7z" />
</ svg >
{ /if }
</ button >
<!-- Skip back 15s — medium -->
< button
type = "button"
onclick = { skipBack }
class="p-2 rounded-full text -( --color-mute d ) hover: text-( --color-text ) hover:bg- ( --color-surface-2 ) transition-colors "
aria-label = "Skip back 15 seconds"
title = "Back 15s"
>
< svg class = "w-7 h-7" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8 z" />
< text x = "8.5" y = "14.5" font-size = "5" font-family = "sans-serif" font-weight = "bold" fill = "currentColor" > 15</ text >
</ svg >
</ button >
<!-- Skip forward 30s -->
< button
type = "button"
onclick = { skipForward }
class="p-2 rounded-full text -( --color-mute d ) hover: text-( --color-text ) hover:bg- ( --color-surface-2 ) transition-colors "
aria-label = "Skip forward 30 seconds "
title = "Forward 30s"
>
< svg class = "w-6 h-6" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z "/ >
< text x = "8.5" y = "14.5" font-size = "5" font-family = "sans-serif" font-weight = "bold" fill = "currentColor" > 30</ text >
<!-- Play / Pause — largest, centred -->
< button
type = "button"
onclick = { togglePlay }
class="w-18 h-18 rounded-full bg -( --color-bran d ) text- ( --color-surface ) flex items-center justify-center hover:bg- ( --color-brand-dim ) transition-colors shadow-xl "
style = "width: 4.5rem; height: 4.5rem; "
aria-label = { audioStore . isPlaying ? 'Pause' : 'Play' }
>
{ #if audioStore . isPlaying }
< svg class = "w-8 h-8" fill = "currentColor" viewBox = "0 0 24 24 ">
< path d = "M6 4h4v16H6V4zm8 0h4v16h-4V4z" / >
</ svg >
</ button >
<!-- Next chapter -->
{ #if audioStore . nextChapter !== null && audioStore . slug }
< a
href = "/books/ { audioStore . slug } /chapters/ { audioStore . nextChapter } "
class = "p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title = "Next chapter"
aria-label = "Next chapter"
>
< svg class = "w-5 h-5" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z" />
</ svg >
</ a >
{ : else }
< div class = "w-9 h-9" ></ div >
< svg class = "w-8 h-8 ml-1" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M8 5v14l11-7z" />
</ svg >
{ /if }
</ div >
</ button >
<!-- Secondary controls: Speed · Auto-next · Sleep -->
< div class = "flex items-center justify-center gap-3 pb-3 shrink-0 flex-wrap" >
<!-- Speed -->
< div class = "flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)" >
{ #each SPEED_OPTIONS as s }
< button
type = "button "
onclick = {() => ( audioStore . speed = s )}
class= { cn (
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors' ,
audioStore . speed === s
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed = { audioStore . speed === s }
> { s } × </button>
{ /each }
</ div >
<!-- Skip forward 30s — medium -->
< button
type = "button"
onclick = { skipForward }
class="p-2 rounded-full text- ( --color-muted ) hover:text- ( --color-text ) hover:bg- ( --color-surface-2 ) transition-colors "
aria-label = "Skip forward 30 seconds"
title = "Forward 30s "
>
< svg class = "w-7 h-7" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z" />
< text x = "8.5" y = "14.5" font-size = "5" font-family = "sans-serif" font-weight = "bold" fill = "currentColor" > 30</ text >
</ svg >
</ button >
<!-- Auto-next -->
< button
type = "button"
onclick = {() => ( audioStore . autoNext = ! audioStore . autoNext )}
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors' ,
audioStore . autoNext
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed = { audioStore . autoNext }
title= { audioStore . autoNext ? 'Auto-next on' : 'Auto-next off' }
<!-- Next chapter — smaller, clearly secondary -->
{ #if audioStore . nextChapter !== null && audioStore . slug }
< a
href = "/books/ { audioStore . slug } /chapters/ { audioStore . nextChapter } "
class= "p-2 rounded-full text-(--color-muted)/60 hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title = "Next chapter"
aria-label = "Next chapter"
>
< svg class = "w-3. 5 h-3. 5" fill = "currentColor" viewBox = "0 0 24 24" >
< svg class = "w-5 h-5" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z" />
</ svg >
Auto
{ #if audioStore . autoNext && audioStore . nextStatus === 'prefetching' }
< span class = "w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse " ></ span >
{ :else if audioStore . autoNext && audioStore . nextStatus === 'prefetched' }
< span class = "w-1.5 h-1.5 rounded-full bg-green-400" ></ span >
{ /if }
</ button >
</ a >
{ : else }
< div class = "w-9 h-9 " ></ div >
{ /if }
</ div >
<!-- Sleep timer -->
< button
type = "button"
onclick = { cycleSleepTimer }
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors' ,
audioStore . sleepUntil || audioStore . sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title = "Sleep timer"
>
< svg class = "w-3.5 h-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</ svg >
{ sleepLabel }
</ button >
<!-- Secondary controls: unified single row — Speed · Auto · Sleep -->
< div class = "flex items-center justify-center gap-2 shrink-0" >
<!-- Speed — segmented pill -->
< div class = "flex items-center gap-0.5 bg-(--color-surface-2) rounded-full px-1.5 py-1 border border-(--color-border)" >
{ #each SPEED_OPTIONS as s }
< button
type = "button"
onclick = {() => ( audioStore . speed = s )}
class= { cn (
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors' ,
audioStore . speed === s
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed = { audioStore . speed === s }
> { s }× </button>
{ /each }
</ div >
<!-- Auto-next pill -->
< button
type = "button"
onclick = {() => ( audioStore . autoNext = ! audioStore . autoNext )}
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors' ,
audioStore . autoNext
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed = { audioStore . autoNext }
title= { audioStore . autoNext ? 'Auto-next on' : 'Auto-next off' }
>
< svg class = "w-3.5 h-3.5" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z" />
</ svg >
Auto
{ #if audioStore . autoNext && audioStore . nextStatus === 'prefetching' }
< span class = "w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse" ></ span >
{ :else if audioStore . autoNext && audioStore . nextStatus === 'prefetched' }
< span class = "w-1.5 h-1.5 rounded-full bg-green-400" ></ span >
{ /if }
</ button >
<!-- Sleep timer pill -->
< button
type = "button"
onclick = { cycleSleepTimer }
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors' ,
audioStore . sleepUntil || audioStore . sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title = "Sleep timer"
>
< svg class = "w-3.5 h-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</ svg >
{ sleepLabel }
</ button >
</ div >
</ div >