Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdc7275f8 | ||
|
|
9d925382b3 | ||
|
|
718929e9cd |
@@ -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;">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user