From a47cc0e7110abd0caa68ae266294b7cbc8f6b27a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Apr 2026 23:35:49 +0500 Subject: [PATCH] feat: float player is now a draggable circle with viewport clamping and tap-to-play/pause --- ui/src/lib/components/AudioPlayer.svelte | 222 +++++++++++++---------- 1 file changed, 130 insertions(+), 92 deletions(-) diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index 900709d..a1e92e4 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -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); + }); @@ -1451,104 +1507,86 @@ {/if} {#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
-
- - -
- - - - - + + {#if audioStore.isPlaying} + + {/if} + + +
+ {#if audioStore.status === 'generating' || audioStore.status === 'loading'} + + + + - - {audioStore.chapterTitle || `Chapter ${audioStore.chapter}`} - - - {#if audioStore.isPlaying} - - {/if} -
- - - -
-
-
- - -
- - - - - - - - - - - - {formatTime(audioStore.currentTime)} - / - {formatDuration(audioStore.duration)} - - - - - {audioStore.speed}× - -
+ {:else if audioStore.isPlaying} + + + + + {:else} + + + + + {/if}
+ + + {#if audioStore.duration > 0} + {@const r = 26} + {@const circ = 2 * Math.PI * r} + {@const dash = (audioStore.currentTime / audioStore.duration) * circ} + + + + + {/if}
{/if}