@@ -946,22 +946,78 @@
// ── Float player drag state ──────────────────────────────────────────────
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
// Coordinate system: x/y are offsets from bottom-right corner (positive = toward center).
// right = calc(1rem + {-x}px) → x=0 means right:1rem, x=-50 means right:3.125rem
// bottom = calc(1rem + {-y}px) → y=0 means bottom:1rem
//
// To keep the circle in the viewport we clamp so that the element never goes
// outside any edge. Circle size = 56px (w-14), margin = 16px (1rem).
const FLOAT_SIZE = 56 ; // px — must match w-14
const FLOAT_MARGIN = 16 ; // px — 1rem
function clampFloatPos ( x : number , y : number ) : { x : number ; y : number } {
const vw = typeof window !== 'undefined' ? window . innerWidth : 400 ;
const vh = typeof window !== 'undefined' ? window . innerHeight : 800 ;
// right edge: element right = 1rem - x ≥ 0 → x ≤ FLOAT_MARGIN
const maxX = FLOAT_MARGIN ;
// left edge: element right + size ≤ vw → right = 1rem - x → 1rem - x + size ≤ vw
// x ≥ FLOAT_MARGIN + FLOAT_SIZE - vw
const minX = FLOAT_MARGIN + FLOAT_SIZE - vw ;
// top edge: element bottom + size ≤ vh → bottom = 1rem - y → 1rem - y + size ≤ vh
// y ≥ FLOAT_MARGIN + FLOAT_SIZE - vh
const minY = FLOAT_MARGIN + FLOAT_SIZE - vh ;
// bottom edge: element bottom = 1rem - y ≥ 0 → y ≤ FLOAT_MARGIN
const maxY = FLOAT_MARGIN ;
return {
x : Math.max ( minX , Math . min ( maxX , x )),
y : Math.max ( minY , Math . min ( maxY , y )),
};
}
let floatDragging = $state ( false );
let floatDragStart = $state ({ mx : 0 , my : 0 , ox : 0 , oy : 0 });
// Track total pointer movement to distinguish tap vs drag
let floatMoved = $state ( false );
function onFloatPointerDown ( e : PointerEvent ) {
e . preventDefault ();
floatDragging = true ;
floatMoved = false ;
floatDragStart = { mx : e.clientX , my : e.clientY , ox : audioStore.floatPos.x , oy : audioStore.floatPos.y };
( e . currentTarget as HTMLElement ). setPointerCapture ( e . pointerId );
}
function onFloatPointerMove ( e : PointerEvent ) {
if ( ! floatDragging ) return ;
audioStore . floatPos = {
x : floatDragStart.ox + ( e . clientX - floatDragStart . mx ),
y : floatDragStart.oy + ( e . clientY - floatDragStart . my )
const dx = e . clientX - floatDragStart . mx ;
const dy = e . clientY - floatDragStart . my ;
// Only start moving if dragged > 6px to preserve tap detection
if ( ! floatMoved && Math . hypot ( dx , dy ) < 6 ) return ;
floatMoved = true ;
const raw = {
x : floatDragStart.ox - dx , // x increases toward left (away from right edge)
y : floatDragStart.oy - dy , // y increases toward top (away from bottom edge)
};
audioStore . floatPos = clampFloatPos ( raw . x , raw . y );
}
function onFloatPointerUp() { floatDragging = false ; }
function onFloatPointerUp() {
if ( floatDragging && ! floatMoved ) {
// Tap: toggle play/pause
audioStore . toggleRequest ++ ;
}
floatDragging = false ;
}
// Clamp saved position to viewport on mount and on resize
$effect (() => {
if ( typeof window === 'undefined' ) return ;
const clamp = () => {
audioStore . floatPos = clampFloatPos ( audioStore . floatPos . x , audioStore . floatPos . y );
};
clamp ();
window . addEventListener ( 'resize' , clamp );
return () => window . removeEventListener ( 'resize' , clamp );
});
</ script >
< svelte:window onkeydown = { handleKeyDown } / >
@@ -1451,104 +1507,86 @@
{ /if }
<!-- ── Float player overlay ──────────────────────────────────────────────────
Rendered outside all containers so fixed positioning is never clipped .
A draggable circle anchored to the viewport .
Tap = toggle play/pause.
Drag = reposition (clamped to viewport).
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(1rem + {- audioStore. floatPos.y} px);
right: calc(1rem + {- audioStore. floatPos.x} px);
bottom: calc({ FLOAT_MARGIN } px + { - audioStore. floatPos. y } px);
right: calc({ FLOAT_MARGIN } px + { - audioStore. floatPos. x } px);
touch-action: none;
width: { FLOAT_SIZE } px;
height: { FLOAT_SIZE } px;
"
onpointerdown = { onFloatPointerDown }
onpointermove= { onFloatPointerMove }
onpointerup = { onFloatPointerUp }
onpointercancel= { onFloatPointerUp }
>
<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"/>
<!-- Pulsing ring when playing -->
{ #if audioStore . isPlaying }
< span class = "absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none" ></ span >
{ /if }
<!-- Circle button -->
< div
class = "absolute inset-0 rounded-full bg-(--color-brand) shadow-xl flex items-center justify-center { floatDragging ? 'cursor-grabbing' : 'cursor-grab' } transition-transform active:scale-95"
>
{ #if audioStore . status === 'generating' || audioStore . status === 'loading' }
<!-- Spinner -->
< svg class = "w-6 h-6 text-white 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 >
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
{audioStore.chapterTitle || ` Chapter $ { audioStore . chapter } ` }
</ span >
<!-- Status dot -- >
{ #if audioStore . isPlayin g}
< span class = "w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pu lse" ></ 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 >
{ :else if audioStore . isPlaying }
<!-- Pause icon -->
< svg class = "w-6 h-6 text-white pointer-events-none" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M6 4h4v16H6V4zm8 0h4v16h-4V4z" / >
</ sv g>
{ : e lse}
<!-- Play icon -->
< svg class = "w-6 h-6 text-white ml-0.5 pointer-events-none" fill = "currentColor" viewBox = "0 0 24 24" >
< path d = "M8 5v14l11-7z" />
</ svg >
{ /if }
</ div >
<!-- Progress arc ring (thin, overlaid on circle edge) -->
{ #if audioStore . duration > 0 }
{ @const r = 26 }
{ @const circ = 2 * Math . PI * r }
{ @const dash = ( audioStore . currentTime / audioStore . duration ) * circ }
< svg
class = "absolute inset-0 pointer-events-none -rotate-90"
width = { FLOAT_SIZE }
height= { FLOAT_SIZE }
viewBox = "0 0 { FLOAT_SIZE } { FLOAT_SIZE } "
>
< circle
cx = { FLOAT_SIZE / 2 }
cy= { FLOAT_SIZE / 2 }
r = { r }
fill="none"
stroke = "rgba(255,255,255,0.25)"
stroke-width = "2.5"
/>
< circle
cx = { FLOAT_SIZE / 2 }
cy= { FLOAT_SIZE / 2 }
r = { r }
fill="none"
stroke = "white"
stroke-width = "2.5"
stroke-linecap = "round"
stroke-dasharray = " { circ } "
stroke-dashoffset = " { circ - dash } "
style = "transition: stroke-dashoffset 0.5s linear;"
/>
</ svg >
{ /if }
</ div >
{ /if }