@@ -70,8 +70,8 @@
voices? : Voice [];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired ?: () => void ;
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seek able p layer . */
playerStyle ?: 'standard' | 'compac t' ;
/** Visual style of the player card. 'standard' = inline card; 'float' = dragg able over lay. */
playerStyle ?: 'standard' | 'floa t' ;
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
wordCount? : number ;
}
@@ -891,18 +891,32 @@
audioStore . duration > 0 ? ( audioStore . currentTime / audioStore . duration ) * 100 : 0
);
const SPEED_OPTIONS = [ 0.75 , 1 , 1.25 , 1.5 , 2 ] as const ;
function cycleSpeed() {
const idx = SPEED_OPTIONS . indexOf ( audioStore . speed as ( typeof SPEED_OPTIONS )[ number ]);
audioStore . speed = SPEED_OPTIONS [( idx + 1 ) % SPEED_OPTIONS . length ];
}
function seekFromCompactBar ( e : MouseEvent ) {
function seekFromBar ( e : MouseEvent ) {
if ( audioStore . duration <= 0 ) return ;
const rect = ( e . currentTarget as HTMLElement ). getBoundingClientRect ();
const pct = Math . max ( 0 , Math . min ( 1 , ( e . clientX - rect . left ) / rect . width ));
audioStore . seekRequest = pct * audioStore . duration ;
}
// ── Float player drag state ──────────────────────────────────────────────
/** Position of the floating overlay (bottom-right anchor by default). */
let floatPos = $state ({ x : 0 , y : 0 });
let floatDragging = $state ( false );
let floatDragStart = $state ({ mx : 0 , my : 0 , ox : 0 , oy : 0 });
function onFloatPointerDown ( e : PointerEvent ) {
floatDragging = true ;
floatDragStart = { mx : e.clientX , my : e.clientY , ox : floatPos.x , oy : floatPos.y };
( e . currentTarget as HTMLElement ). setPointerCapture ( e . pointerId );
}
function onFloatPointerMove ( e : PointerEvent ) {
if ( ! floatDragging ) return ;
floatPos = {
x : floatDragStart.ox + ( e . clientX - floatDragStart . mx ),
y : floatDragStart.oy + ( e . clientY - floatDragStart . my )
};
}
function onFloatPointerUp() { floatDragging = false ; }
</ script >
< svelte:window onkeydown = { handleKeyDown } / >
@@ -953,121 +967,6 @@
</ div >
{ /snippet }
{ #if playerStyle === 'compact' }
<!-- ── Compact player ──────────────────────────────────────────────────────── -->
< div class = "mt-4 p-3 rounded-lg bg-(--color-surface-2) border border-(--color-border)" >
{ #if audioStore . isCurrentChapter ( slug , chapter )}
{ #if audioStore . status === 'idle' || audioStore . status === 'error' }
{ #if audioStore . status === 'error' }
< p class = "text-(--color-danger) text-xs mb-2" > { audioStore . errorMsg || 'Failed to load audio.' } </ p >
{ /if }
< Button variant = "default" size = "sm" onclick = { handlePlay } >
< svg class = "w-3.5 h-3.5" fill = "currentColor" viewBox = "0 0 24 24" >< path d = "M8 5v14l11-7z" /></ svg >
{ m . reader_play_narration ()}
</ Button >
{ :else if audioStore . status === 'loading' }
< div class = "flex items-center gap-2 text-xs text-(--color-muted)" >
< svg class = "w-3.5 h-3.5 animate-spin" fill = "none" viewBox = "0 0 24 24" >
< circle class = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" stroke-width = "4" ></ circle >
< path class = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" ></ path >
</ svg >
{ m . player_loading ()}
</ div >
{ :else if audioStore . status === 'generating' }
< div class = "space-y-1.5" >
< div class = "flex items-center justify-between text-xs text-(--color-muted)" >
< span > { m . reader_generating_narration ()} </ span >
< span class = "tabular-nums" > { Math . round ( audioStore . progress )} %</ span >
</ div >
< div class = "w-full h-1 bg-(--color-surface-3) rounded-full overflow-hidden" >
< div class = "h-full bg-(--color-brand) rounded-full transition-none" style = "width: { audioStore . progress } %" ></ div >
</ div >
</ div >
{ :else if audioStore . status === 'ready' }
< div class = "space-y-2" >
<!-- Seekable progress bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
< div
role = "none"
class = "w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer group"
onclick = { seekFromCompactBar }
>
< div class = "h-full bg-(--color-brand) rounded-full transition-none" style = "width: { playPct } %" ></ div >
</ div >
<!-- Controls row -->
< div class = "flex items-center gap-2" >
<!-- Skip back 15s -->
< button
type = "button"
onclick = {() => { audioStore . seekRequest = Math . max ( 0 , audioStore . currentTime - 15 ); }}
class="text-(--color-muted) hover:text- ( --color-text ) transition-colors flex-shrink-0 "
title = "-15s"
>
< svg class = "w-4 h-4" 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" />
</ svg >
</ button >
<!-- Play/pause -->
< button
type = "button"
onclick = {() => { audioStore . toggleRequest ++ ; }}
class="w-8 h-8 rounded-full bg- ( --color-brand ) text- ( --color-surface ) flex items-center justify-center hover:bg- ( --color-brand-dim ) transition-colors flex-shrink-0 "
>
{ #if audioStore . isPlaying }
< svg class = "w-3.5 h-3.5" fill = "currentColor" viewBox = "0 0 24 24" >< path d = "M6 4h4v16H6V4zm8 0h4v16h-4V4z" /></ svg >
{ : else }
< svg class = "w-3.5 h-3.5 ml-0.5" fill = "currentColor" viewBox = "0 0 24 24" >< path d = "M8 5v14l11-7z" /></ svg >
{ /if }
</ button >
<!-- Skip forward 30s -->
< button
type = "button"
onclick = {() => { audioStore . seekRequest = Math . min ( audioStore . duration || 0 , audioStore . currentTime + 30 ); }}
class="text-(--color-muted) hover:text- ( --color-text ) transition-colors flex-shrink-0 "
title = "+30s"
>
< svg class = "w-4 h-4" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z" />
</ svg >
</ button >
<!-- Time display -->
< span class = "flex-1 text-xs text-center tabular-nums text-(--color-muted)" >
{ formatTime ( audioStore . currentTime )} / { formatDuration ( audioStore . duration )}
</ span >
<!-- Speed cycle -->
< button
type = "button"
onclick = { cycleSpeed }
class="text-xs font-medium text- ( --color-muted ) hover:text- ( --color-text ) flex-shrink-0 tabular-nums transition-colors "
title = "Playback speed"
>
{ audioStore . speed } ×
</ button >
</ div >
</ div >
{ /if }
{ :else if audioStore . active }
< div class = "flex items-center justify-between gap-3" >
< p class = "text-xs text-(--color-muted)" >
{ m . reader_now_playing ({ title : audioStore.chapterTitle || `Ch.$ { audioStore . chapter } ` })}
</p>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
{m.reader_load_this_chapter()}
</Button>
</div>
{:else}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.reader_play_narration()}
</Button>
{/if}
</div>
{:else}
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
{ #if
@@ -1197,6 +1096,7 @@
{ : else }
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
{ #if ! ( playerStyle === 'float' && audioStore . isCurrentChapter ( slug , chapter ) && audioStore . active )}
< div class = "p-4" >
< div class = "flex items-center justify-end gap-2 mb-3" >
<!-- Chapter picker button -->
@@ -1402,7 +1302,6 @@
</div>
{/if}
{/if}
<!-- /playerStyle -->
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that
@@ -1479,3 +1378,106 @@
</div>
</div>
{/if}
<!-- ── Float player overlay ──────────────────────────────────────────────────
Rendered outside all containers so fixed positioning is never clipped.
Visible when playerStyle='float' and audio is active for this chapter. -->
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed z-[55] select-none"
style="
bottom: calc(4.5rem + {-floatPos.y}px);
right: calc(1rem + {-floatPos.x}px);
touch-action: none;
"
>
<div class="w-64 rounded-2xl bg-(--color-surface) border border-(--color-border) shadow-2xl overflow-hidden">
<!-- Drag handle + title row -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-2 px-3 pt-2.5 pb-1 cursor-grab active:cursor-grabbing"
onpointerdown={onFloatPointerDown}
onpointermove={onFloatPointerMove}
onpointerup={onFloatPointerUp}
onpointercancel={onFloatPointerUp}
>
<!-- Drag grip dots -->
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
</svg>
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
{audioStore.chapterTitle || ` Chapter $ { audioStore . chapter } ` }
</ span >
<!-- Status dot -->
{ #if audioStore . isPlaying }
< span class = "w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse" ></ span >
{ /if }
</ div >
<!-- Seek bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
< div
role = "none"
class = "mx-3 mb-2 h-1 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
onclick = { seekFromBar }
>
< div class = "h-full bg-(--color-brand) rounded-full transition-none" style = " width : { playPct } % "></div>
</div>
<!-- Controls row -->
<div class=" flex items - center gap - 1 px - 3 pb - 2.5 ">
<!-- Skip back 15s -->
<button
type=" button "
onclick = {() => { audioStore . seekRequest = Math . max ( 0 , audioStore . currentTime - 15 ); }}
class = "w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
title = "-15s"
>
< svg class = "w-4 h-4" 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" />
</ svg >
</ button >
<!-- Play/pause -->
< button
type = "button"
onclick = {() => { audioStore . toggleRequest ++ ; }}
class = "w-9 h-9 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0"
>
{ #if audioStore . isPlaying }
< svg class = "w-4 h-4" fill = "currentColor" viewBox = "0 0 24 24" >< path d = "M6 4h4v16H6V4zm8 0h4v16h-4V4z" /></ svg >
{ : else }
< svg class = "w-4 h-4 ml-0.5" fill = "currentColor" viewBox = "0 0 24 24" >< path d = "M8 5v14l11-7z" /></ svg >
{ /if }
</ button >
<!-- Skip forward 30s -->
< button
type = "button"
onclick = {() => { audioStore . seekRequest = Math . min ( audioStore . duration || 0 , audioStore . currentTime + 30 ); }}
class = "w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
title = "+30s"
>
< svg class = "w-4 h-4" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z" />
</ svg >
</ button >
<!-- Time -->
< span class = "flex-1 text-[11px] text-center tabular-nums text-(--color-muted)" >
{ formatTime ( audioStore . currentTime )}
< span class = "opacity-50" > / </ span >
{ formatDuration ( audioStore . duration )}
</ span >
<!-- Speed -->
< span class = "text-[11px] font-medium tabular-nums text-(--color-muted) flex-shrink-0" >
{ audioStore . speed } ×
</ span >
</ div >
</ div >
</ div >
{ /if }