diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 0cda232..a8ce37b 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -73,50 +73,76 @@ ]; // ── Hero carousel ──────────────────────────────────────────────────────── + const CAROUSEL_INTERVAL = 6000; // ms const heroBooks = $derived(data.continueInProgress); let heroIndex = $state(0); const heroBook = $derived(heroBooks[heroIndex] ?? null); - // Shelf shows remaining books not in the hero - const shelfBooks = $derived( - heroBooks.length > 1 ? heroBooks.filter((_, i) => i !== heroIndex) : [] - ); + // Shelf always shows books at positions 1…n — stable regardless of heroIndex + // so that navigating the carousel doesn't reshuffle the shelf below. + const shelfBooks = $derived(heroBooks.length > 1 ? heroBooks.slice(1) : []); const streak = $derived(data.stats.streak ?? 0); - function heroPrev() { - heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length; - resetAutoAdvance(); - } - function heroNext() { - heroIndex = (heroIndex + 1) % heroBooks.length; - resetAutoAdvance(); - } function heroDot(i: number) { heroIndex = i; resetAutoAdvance(); } - // Auto-advance carousel every 6 s when there are multiple books. - // We use a $state counter as a "restart token" so the $effect can be - // re-triggered by manual navigation without reading heroIndex (which would - // cause an infinite loop when the interval itself mutates heroIndex). + // Auto-advance carousel every CAROUSEL_INTERVAL ms when there are multiple books. + // autoAdvanceSeed is bumped on manual swipe/dot to restart the interval. let autoAdvanceSeed = $state(0); + // progressStart tracks when the current interval began (for the progress bar). + let progressStart = $state(browser ? performance.now() : 0); $effect(() => { if (heroBooks.length <= 1) return; - // Subscribe to heroBooks.length and autoAdvanceSeed only — not heroIndex. const len = heroBooks.length; - void autoAdvanceSeed; // track the seed + void autoAdvanceSeed; // restart when seed changes + progressStart = browser ? performance.now() : 0; const id = setInterval(() => { heroIndex = (heroIndex + 1) % len; - }, 6000); + progressStart = browser ? performance.now() : 0; + }, CAROUSEL_INTERVAL); return () => clearInterval(id); }); function resetAutoAdvance() { - // Bump the seed to restart the interval after manual navigation. autoAdvanceSeed++; } + // ── Swipe handling ─────────────────────────────────────────────────────── + let swipeStartX = 0; + function onSwipeStart(e: TouchEvent) { + swipeStartX = e.touches[0].clientX; + } + function onSwipeEnd(e: TouchEvent) { + const dx = e.changedTouches[0].clientX - swipeStartX; + if (Math.abs(dx) < 40) return; // ignore tiny movements + if (dx < 0) { + // swipe left → next + heroIndex = (heroIndex + 1) % heroBooks.length; + } else { + // swipe right → prev + heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length; + } + resetAutoAdvance(); + } + + // ── Progress bar animation ─────────────────────────────────────────────── + // rAF loop drives a 0→1 progress value that resets on each advance. + let rafProgress = $state(0); + $effect(() => { + if (!browser || heroBooks.length <= 1) return; + void autoAdvanceSeed; // re-subscribe so effect re-runs on manual nav + void heroIndex; + let raf: number; + function tick() { + rafProgress = Math.min((performance.now() - progressStart) / CAROUSEL_INTERVAL, 1); + raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }); + function playChapter(slug: string, chapter: number) { audioStore.autoStartChapter = chapter; goto(`/books/${slug}/chapters/${chapter}`); @@ -131,8 +157,13 @@ {#if heroBook}
- - - - - {#if heroBooks.length > 1} - - - {/if}
- + {#if heroBooks.length > 1} -
+
{#each heroBooks as _, i} + : 'w-1.5 h-1.5 bg-(--color-border) group-hover/dot:bg-(--color-muted)'}"> + + {#if i === heroIndex} + + + + {/if} + {/each}
{/if}