@@ -50,6 +50,7 @@
import { audioStore } from '$lib/audio.svelte' ;
import { goto } from '$app/navigation' ;
import { untrack } from 'svelte' ;
import { Button } from '$lib/components/ui/button' ;
import { cn } from '$lib/utils' ;
import type { Voice } from '$lib/types' ;
@@ -946,22 +947,85 @@
// ── 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 . stopPropagation ();
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 ;
// right = MARGIN - x → drag right (dx>0) should decrease right → x increases → x = ox + dx
// bottom = MARGIN - y → drag down (dy>0) should decrease bottom → y increases → y = oy + dy
const raw = {
x : floatDragStart.ox + dx ,
y : floatDragStart.oy + dy ,
};
audioStore . floatPos = clampFloatPos ( raw . x , raw . y );
}
function onFloatPointerUp() { floatDragging = false ; }
function onFloatPointerUp( e : PointerEvent ) {
if ( ! floatDragging ) return ;
if ( floatDragging && ! floatMoved ) {
// Tap: toggle play/pause
audioStore . toggleRequest ++ ;
}
floatDragging = false ;
try { ( e . currentTarget as HTMLElement ). releasePointerCapture ( e . pointerId ); } catch { /* ignore */ }
}
// Clamp saved position to viewport on mount and on resize.
// Use untrack() when reading floatPos to avoid a reactive loop
// (reading + writing the same state inside $effect would re-trigger forever).
$effect (() => {
if ( typeof window === 'undefined' ) return ;
const clamp = () => {
const { x , y } = untrack (() => audioStore . floatPos );
audioStore . floatPos = clampFloatPos ( x , y );
};
clamp ();
window . addEventListener ( 'resize' , clamp );
return () => window . removeEventListener ( 'resize' , clamp );
});
</ script >
< svelte:window onkeydown = { handleKeyDown } / >
@@ -1212,15 +1276,18 @@
</ svg >
</ button >
<!-- Seek bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
< div
role = "none "
class = "flex-1 h-1.5 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 >
<!-- Seek bar — proper range input so drag works on iOS too -->
< input
type = "range"
aria-label = "Seek "
min = "0 "
max = { audioStore . duration || 0 }
value= { audioStore . currentTime }
oninput = {( e ) => { audioStore . seekRequest = parseFloat (( e . target as HTMLInputElement ). value ); }}
onchange= {( e ) => { audioStore . seekRequest = parseFloat (( e . target as HTMLInputElement ). value ); }}
class = "flex-1 h-1.5 cursor-pointer"
style = "accent-color: var(--color-brand);"
/>
<!-- Time -->
< span class = "flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)" >
@@ -1443,7 +1510,7 @@
{ #if showChapterPanel && audioStore . chapters . length > 0 }
< ChapterPickerOverlay
chapters = { audioStore . chapters }
activeChapter={ chapter}
activeChapter={ audioStore . chapter}
zIndex = "z-[60]"
onselect = { playChapter }
onclose= {() => { showChapterPanel = false ; }}
@@ -1451,104 +1518,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= {( e ) => { floatDragging = false ; try { ( e . currentTarget as HTMLElement ). releasePointerCapture ( e . pointerId ); } catch { /* ignore */ } }}
>
<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 }