|
|
|
|
@@ -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,20 @@
|
|
|
|
|
|
|
|
|
|
<!-- 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;
|
|
|
|
|
"
|
|
|
|
|
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;">
|
|
|
|
|
|