Compare commits

..

3 Commits
v2.6.6 ... push

Author SHA1 Message Date
root
1cdc7275f8 fix: homepage overlay blocker and carousel auto-advance
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m43s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m31s
Release / Docker / runner (push) Successful in 2m36s
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 49s
- Add pointer-events:none to ListeningMode fly-transition wrapper div in
  +layout.svelte so the exiting animation div never blocks page interaction
- Add pointer-events:auto to ListeningMode root div so it still captures
  all touch/click events correctly despite the parent being pointer-events:none
- Rewrite carousel auto-advance using $effect + autoAdvanceSeed pattern:
  replaces the stale-closure setInterval in resetAutoAdvance() with a
  reactive $effect that owns the interval and re-starts cleanly on manual
  navigation by bumping a seed counter
2026-04-06 16:58:23 +05:00
root
9d925382b3 fix(player): register touchmove with passive:false via $effect
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m34s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m36s
Release / Docker / runner (push) Successful in 2m34s
Release / Upload source maps (push) Successful in 1m35s
Release / Docker / ui (push) Successful in 3m18s
Release / Gitea Release (push) Successful in 1m27s
Svelte 5 has no |nonpassive modifier. Register the touchmove listener
manually so e.preventDefault() can suppress page scroll during the
pull-down gesture.
2026-04-06 16:32:25 +05:00
root
718929e9cd feat(player): pull-down-to-dismiss gesture on ListeningMode
Some checks failed
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Failing after 31s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m33s
Release / Docker / runner (push) Successful in 2m30s
Release / Gitea Release (push) Has been skipped
- Slide-up transition on open (fly from bottom, 320ms)
- Drag-down on the overlay follows the finger in real time with no
  transition; on release springs back (0.32s cubic-bezier) if drag
  < 130px and velocity < 0.4px/ms, otherwise slides off-screen and
  calls onclose after 220ms
- Opacity fades as the overlay is pulled down (fully transparent at 500px)
- Touch guard: gesture does not activate if touch starts inside an
  .overflow-y-auto element (chapter/voice lists) or while a modal is open
2026-04-06 16:16:09 +05:00
3 changed files with 99 additions and 17 deletions

View File

@@ -26,6 +26,65 @@
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio: HTMLAudioElement | null = null;
// ── Pull-down-to-dismiss gesture ─────────────────────────────────────────
let dragY = $state(0);
let isDragging = $state(false);
let dragStartY = 0;
let dragStartTime = 0;
let overlayEl = $state<HTMLDivElement | null>(null);
// Register ontouchmove with passive:false so e.preventDefault() works.
// Svelte 5 does not support the |nonpassive modifier, so we use $effect.
$effect(() => {
if (!overlayEl) return;
overlayEl.addEventListener('touchmove', onTouchMove, { passive: false });
return () => overlayEl!.removeEventListener('touchmove', onTouchMove);
});
function onTouchStart(e: TouchEvent) {
// Don't hijack touches that start inside a scrollable element
const target = e.target as Element;
if (target.closest('.overflow-y-auto')) return;
// Don't activate if a modal is open (they handle their own scroll)
if (showVoiceModal || showChapterModal) return;
isDragging = true;
dragStartY = e.touches[0].clientY;
dragStartTime = Date.now();
dragY = 0;
}
function onTouchMove(e: TouchEvent) {
if (!isDragging) return;
const delta = e.touches[0].clientY - dragStartY;
// Only track downward movement
if (delta > 0) {
dragY = delta;
// Prevent page scroll while dragging the overlay down
e.preventDefault();
} else {
dragY = 0;
}
}
function onTouchEnd() {
if (!isDragging) return;
isDragging = false;
const elapsed = Date.now() - dragStartTime;
const velocity = dragY / Math.max(elapsed, 1); // px/ms
// Dismiss if dragged far enough (>130px) or flicked fast enough (>0.4px/ms)
if (dragY > 130 || velocity > 0.4) {
// Animate out: snap to bottom then close
dragY = window.innerHeight;
setTimeout(onclose, 220);
} else {
// Spring back to 0
dragY = 0;
}
}
// ── Voice search filtering ────────────────────────────────────────────────
const voiceSearchLower = $derived(voiceSearch.toLowerCase());
const filteredKokoro = $derived(kokoroVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
@@ -187,7 +246,21 @@
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-60 flex flex-col overflow-hidden" style="background: var(--color-surface);">
<div
bind:this={overlayEl}
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
style="
background: var(--color-surface);
transform: translateY({dragY}px);
opacity: {Math.max(0, 1 - dragY / 500)};
transition: {isDragging ? 'none' : 'transform 0.32s cubic-bezier(0.32,0.72,0,1), opacity 0.32s ease'};
will-change: transform;
touch-action: pan-x;
pointer-events: auto;
"
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
<!-- ── Full-bleed cover hero (top ~50% of screen) ────────────────────── -->
<div class="relative w-full shrink-0" style="height: 52svh; min-height: 220px; max-height: 380px;">

View File

@@ -12,6 +12,7 @@
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
import { fly } from 'svelte/transition';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -953,8 +954,10 @@
<!-- Listening mode — mounted at root level, independent of audioStore.active,
so closing/pausing audio never tears it down and loses context. -->
{#if listeningModeOpen}
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
</div>
{/if}

View File

@@ -95,22 +95,28 @@
resetAutoAdvance();
}
let autoAdvanceTimer = $state<ReturnType<typeof setInterval> | null>(null);
function resetAutoAdvance() {
if (autoAdvanceTimer) clearInterval(autoAdvanceTimer);
if (heroBooks.length > 1) {
autoAdvanceTimer = setInterval(() => {
heroIndex = (heroIndex + 1) % heroBooks.length;
}, 6000);
}
}
// 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).
let autoAdvanceSeed = $state(0);
$effect(() => {
resetAutoAdvance();
return () => { if (autoAdvanceTimer) clearInterval(autoAdvanceTimer); };
if (heroBooks.length <= 1) return;
// Subscribe to heroBooks.length and autoAdvanceSeed only — not heroIndex.
const len = heroBooks.length;
void autoAdvanceSeed; // track the seed
const id = setInterval(() => {
heroIndex = (heroIndex + 1) % len;
}, 6000);
return () => clearInterval(id);
});
function resetAutoAdvance() {
// Bump the seed to restart the interval after manual navigation.
autoAdvanceSeed++;
}
function playChapter(slug: string, chapter: number) {
audioStore.autoStartChapter = chapter;
goto(`/books/${slug}/chapters/${chapter}`);