Compare commits

...

2 Commits

Author SHA1 Message Date
root
a47cc0e711 feat: float player is now a draggable circle with viewport clamping and tap-to-play/pause
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 2m15s
Release / Docker (push) Successful in 6m36s
Release / Gitea Release (push) Successful in 39s
2026-04-11 23:35:49 +05:00
root
ac3d6e1784 fix: move hamburger backdrop outside <header> so drawer items are not blurred
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 6m21s
Release / Gitea Release (push) Successful in 28s
2026-04-11 23:22:32 +05:00
2 changed files with 141 additions and 101 deletions

View File

@@ -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.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>
{: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"/>
</svg>
{:else}
<!-- 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}

View File

@@ -754,15 +754,6 @@
></div>
{/if}
<!-- Backdrop for mobile hamburger menu -->
{#if menuOpen}
<div
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={() => { menuOpen = false; }}
aria-hidden="true"
></div>
{/if}
{:else}
<div class="ml-auto">
<a
@@ -895,6 +886,17 @@
{/if}
</header>
<!-- Backdrop for mobile hamburger menu — outside <header> so the blur
only affects page content below, not the drawer items themselves -->
{#if menuOpen}
<div
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={() => { menuOpen = false; }}
aria-hidden="true"
></div>
{/if}
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
{#key page.url.pathname + page.url.search}
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>