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);
+ });