@@ -0,0 +1,451 @@
< script lang = "ts" >
import { audioStore } from '$lib/audio.svelte' ;
import { cn } from '$lib/utils' ;
import type { Voice } from '$lib/types' ;
interface Props {
/** Called when the user closes the overlay. */
onclose : () => void ;
}
let { onclose } : Props = $props ();
// Voices come from the store (populated by AudioPlayer on mount/play)
const voices = $derived ( audioStore . voices );
const kokoroVoices = $derived ( voices . filter (( v ) => v . engine === 'kokoro' ));
const pocketVoices = $derived ( voices . filter (( v ) => v . engine === 'pocket-tts' ));
const cfaiVoices = $derived ( voices . filter (( v ) => v . engine === 'cfai' ));
let showVoicePanel = $state ( false );
let samplePlayingVoice = $state < string | null >( null );
let sampleAudio : HTMLAudioElement | null = null ;
function voiceLabel ( v : Voice | string ) : string {
if ( typeof v === 'string' ) {
const found = voices . find (( x ) => x . id === v );
if ( found ) return voiceLabel ( found );
const id = v as string ;
return id . replace ( /_/g , ' ' ). replace ( /\b\w/g , ( c ) => c . toUpperCase ());
}
const base = v . id . replace ( /_/g , ' ' ). replace ( /\b\w/g , ( c ) => c . toUpperCase ());
return v . lang !== 'en-us' ? ` ${ base } ( ${ v . lang } )` : base ;
}
function stopSample() {
if ( sampleAudio ) {
sampleAudio . pause ();
sampleAudio . src = '' ;
sampleAudio = null ;
}
samplePlayingVoice = null ;
}
async function playSample ( voiceId : string ) {
if ( samplePlayingVoice === voiceId ) { stopSample (); return ; }
stopSample ();
samplePlayingVoice = voiceId ;
try {
const res = await fetch ( `/api/presign/voice-sample?voice= ${ encodeURIComponent ( voiceId ) } ` );
if ( ! res . ok ) { samplePlayingVoice = null ; return ; }
const { url } = ( await res . json ()) as { url : string };
sampleAudio = new Audio ( url );
sampleAudio . onended = () => stopSample ();
sampleAudio . onerror = () => stopSample ();
sampleAudio . play (). catch (() => stopSample ());
} catch {
samplePlayingVoice = null ;
}
}
function selectVoice ( voiceId : string ) {
stopSample ();
audioStore . voice = voiceId ;
showVoicePanel = false ;
}
// ── Speed ────────────────────────────────────────────────────────────────
const SPEED_OPTIONS = [ 0.75 , 1 , 1.25 , 1.5 , 2 ] as const ;
// ── Sleep timer ──────────────────────────────────────────────────────────
const SLEEP_OPTIONS = [ 15 , 30 , 45 , 60 ]; // minutes
let sleepRemainingSec = $derived . by (() => {
void audioStore . currentTime ; // re-run every second while playing
if ( ! audioStore . sleepUntil ) return 0 ;
return Math . max ( 0 , Math . floor (( audioStore . sleepUntil - Date . now ()) / 1000 ));
});
function cycleSleepTimer() {
if ( ! audioStore . sleepUntil && ! audioStore . sleepAfterChapter ) {
audioStore . sleepAfterChapter = true ;
} else if ( audioStore . sleepAfterChapter ) {
audioStore . sleepAfterChapter = false ;
audioStore . sleepUntil = Date . now () + SLEEP_OPTIONS [ 0 ] * 60 * 1000 ;
} else {
const remaining = audioStore . sleepUntil - Date . now ();
const currentMin = Math . round ( remaining / 60000 );
const idx = SLEEP_OPTIONS . findIndex (( m ) => m >= currentMin );
if ( idx === - 1 || idx === SLEEP_OPTIONS . length - 1 ) {
audioStore . sleepUntil = 0 ;
} else {
audioStore . sleepUntil = Date . now () + SLEEP_OPTIONS [ idx + 1 ] * 60 * 1000 ;
}
}
}
function formatSleepRemaining ( secs : number ) : string {
if ( secs <= 0 ) return 'Off' ;
const m = Math . floor ( secs / 60 );
const s = secs % 60 ;
return m > 0 ? ` ${ m } m ${ s > 0 ? ` ${ s } s` : '' } ` : ` ${ s } s` ;
}
const sleepLabel = $derived (
audioStore . sleepAfterChapter
? 'End Ch.'
: audioStore . sleepUntil > Date . now ()
? formatSleepRemaining ( sleepRemainingSec )
: 'Sleep'
);
// ── Format time ──────────────────────────────────────────────────────────
function formatTime ( s : number ) : string {
if ( ! isFinite ( s ) || s < 0 ) return '0:00' ;
const m = Math . floor ( s / 60 );
const sec = Math . floor ( s % 60 );
return ` ${ m } : ${ sec . toString (). padStart ( 2 , '0' ) } ` ;
}
// ── Playback controls ────────────────────────────────────────────────────
function seek ( e : Event ) {
audioStore . seekRequest = Number (( e . currentTarget as HTMLInputElement ). value );
}
function skipBack() {
audioStore . seekRequest = Math . max ( 0 , audioStore . currentTime - 15 );
}
function skipForward() {
audioStore . seekRequest = Math . min ( audioStore . duration || 0 , audioStore . currentTime + 30 );
}
function togglePlay() {
audioStore . toggleRequest ++ ;
}
// Close on Escape
$effect (() => {
function onKey ( e : KeyboardEvent ) {
if ( e . key === 'Escape' ) {
if ( showVoicePanel ) { showVoicePanel = false ; }
else { onclose (); }
}
}
window . addEventListener ( 'keydown' , onKey );
return () => window . removeEventListener ( 'keydown' , onKey );
});
</ script >
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
< div
class = "fixed inset-0 z-60 flex flex-col overflow-hidden"
style = "background: var(--color-surface);"
>
<!-- Blurred cover background -->
{ #if audioStore . cover }
< div
class = "absolute inset-0 bg-cover bg-center opacity-10 blur-2xl scale-110"
style = "background-image: url(' { audioStore . cover } ');"
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 >
<!-- Voice selector button -->
< button
type = "button"
onclick = {() => ( showVoicePanel = ! showVoicePanel )}
class= { cn (
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors' ,
showVoicePanel
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
>
< 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 >
<!-- Voice panel (inline dropdown below header) -->
{ #if showVoicePanel && voices . length > 0 }
< div class = "relative mx-4 mb-2 bg-(--color-surface-2) border border-(--color-border) rounded-xl p-3 z-10 overflow-y-auto max-h-56 shrink-0" >
< div class = "flex items-center justify-between mb-2" >
< p class = "text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider" > Select Voice</ p >
< button type = "button" onclick = {() => { stopSample (); showVoicePanel = false ; }} class="text-(--color-muted) hover:text- ( --color-text ) transition-colors " aria-label = "Close voice panel" >
< 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 = "M6 18L18 6M6 6l12 12" />
</ svg >
</ button >
</ div >
{ #each ([[ 'Kokoro' , kokoroVoices ], [ 'Pocket TTS' , pocketVoices ], [ 'CF AI' , cfaiVoices ]] as [ string , Voice []][]) as [ label , group ]}
{ #if group . length > 0 }
< p class = "text-[10px] text-(--color-muted) opacity-60 mb-1 mt-2 first:mt-0" > { label } </ p >
< div class = "flex flex-wrap gap-1.5" >
{ #each group as v ( v . id )}
< div class = "flex items-center rounded-lg border overflow-hidden text-xs
{ audioStore . voice === v . id
? 'border-(--color-brand) bg-(--color-brand)/10'
: 'border-(--color-border) bg-(--color-surface-3)' } " >
< button
type = "button"
onclick = {() => selectVoice ( v . id )}
class="px-2 py-1 font-medium transition-colors
{ audioStore . voice === v . id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)' } "
> { voiceLabel ( v )} </ button >
< button
type = "button"
onclick = {( e ) => { e . stopPropagation (); playSample ( v . id ); }}
class="px-1.5 py-1 border-l border- ( --color-border ) transition-colors
{ samplePlayingVoice === v . id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)' } "
title = { samplePlayingVoice === v . id ? 'Stop sample' : 'Play sample' }
>
{ #if samplePlayingVoice === v . id }
< svg class = "w-3 h-3" fill = "currentColor" viewBox = "0 0 24 24" >< path d = "M6 4h4v16H6V4zm8 0h4v16h-4V4z" /></ svg >
{ : else }
< svg class = "w-3 h-3" fill = "currentColor" viewBox = "0 0 24 24" >< path d = "M8 5v14l11-7z" /></ svg >
{ /if }
</ button >
</ div >
{ /each }
</ div >
{ /if }
{ /each }
</ div >
{ /if }
<!-- Scrollable body -->
< div class = "relative flex-1 overflow-y-auto flex flex-col" >
<!-- Cover art + track info -->
< div class = "flex flex-col items-center px-8 pt-4 pb-6 shrink-0" >
{ #if audioStore . cover }
< img
src = { audioStore . cover }
alt=""
class = "w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
/>
{ : else }
< div class = "w-40 h-56 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>
</div>
<!-- Seek bar -->
<div class="px-6 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 px-6 pt-5 pb-2 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"
>
<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-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
</svg>
</button>
<!-- Play / Pause -->
<button
type="button"
onclick={togglePlay}
class="w-16 h-16 rounded-full bg-(--color-brand) 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-4V4z"/>
</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 forward 30s -->
<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-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>
</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>
{/if}
</div>
<!-- Secondary controls: Speed · Auto-next · Sleep -->
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
<!-- 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>
<!-- 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'}
>
<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 -->
<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>
<!-- Chapter list -->
{#if audioStore.chapters.length > 0}
<div class="mx-4 mb-6 bg-(--color-surface-2) rounded-xl border border-(--color-border) overflow-hidden shrink-0">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2.5 border-b border-(--color-border)">Chapters</p>
<div class="overflow-y-auto max-h-64">
{#each audioStore.chapters as ch (ch.number)}
<a
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={onclose}
class="flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3)
{ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'}"
>
<span class="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
<span class="flex-1 truncate">{ch.title || ` Chapter $ { ch . number } ` } </ span >
{ #if ch . number === audioStore . chapter }
< svg class = "w-3 h-3 shrink-0 text-(--color-brand)" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M8 5v14l11-7z" />
</ svg >
{ /if }
</ a >
{ /each }
</ div >
</ div >
{ /if }
</ div >
</ div >