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
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
This commit is contained in:
@@ -26,6 +26,56 @@
|
|||||||
let samplePlayingVoice = $state<string | null>(null);
|
let samplePlayingVoice = $state<string | null>(null);
|
||||||
let sampleAudio: HTMLAudioElement | 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;
|
||||||
|
|
||||||
|
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 ────────────────────────────────────────────────
|
// ── Voice search filtering ────────────────────────────────────────────────
|
||||||
const voiceSearchLower = $derived(voiceSearch.toLowerCase());
|
const voiceSearchLower = $derived(voiceSearch.toLowerCase());
|
||||||
const filteredKokoro = $derived(kokoroVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
|
const filteredKokoro = $derived(kokoroVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
|
||||||
@@ -187,7 +237,20 @@
|
|||||||
|
|
||||||
<!-- Full-screen listening mode overlay -->
|
<!-- Full-screen listening mode overlay -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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
|
||||||
|
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}
|
||||||
|
ontouchmove|nonpassive={onTouchMove}
|
||||||
|
ontouchend={onTouchEnd}
|
||||||
|
>
|
||||||
|
|
||||||
<!-- ── Full-bleed cover hero (top ~50% of screen) ────────────────────── -->
|
<!-- ── Full-bleed cover hero (top ~50% of screen) ────────────────────── -->
|
||||||
<div class="relative w-full shrink-0" style="height: 52svh; min-height: 220px; max-height: 380px;">
|
<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 * as m from '$lib/paraglide/messages.js';
|
||||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||||
|
|
||||||
@@ -953,8 +954,10 @@
|
|||||||
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||||
so closing/pausing audio never tears it down and loses context. -->
|
so closing/pausing audio never tears it down and loses context. -->
|
||||||
{#if listeningModeOpen}
|
{#if listeningModeOpen}
|
||||||
<ListeningMode
|
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }}>
|
||||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
<ListeningMode
|
||||||
openChapters={listeningModeChapters}
|
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||||
/>
|
openChapters={listeningModeChapters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user