Compare commits

...

3 Commits

Author SHA1 Message Date
root
bb61a4654a feat: portrait cover card + blurred bg in ListeningMode
Some checks failed
Release / Check ui (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Docker / caddy (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Test backend (push) Has been cancelled
Replace the full-bleed landscape hero with the Apple Music / Spotify
layout pattern:
- Full-screen blurred+darkened cover as atmospheric background layer
- Centered portrait 2/3 cover card (38svh tall, rounded-2xl, shadow-2xl)
- Track info (chapter label, title, book name) moved below cover card
- Radial vignette overlay for depth
- No dead empty space between art and controls
- Header bar and controls area lifted to z-index 2 above the bg layers
2026-04-06 17:22:31 +05:00
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
3 changed files with 111 additions and 84 deletions

View File

@@ -31,6 +31,15 @@
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
@@ -238,6 +247,7 @@
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={overlayEl}
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
style="
background: var(--color-surface);
@@ -246,96 +256,107 @@
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}
ontouchmove|nonpassive={onTouchMove}
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;">
{#if audioStore.cover}
<!-- Full-bleed cover image -->
<img
src={audioStore.cover}
alt=""
class="absolute inset-0 w-full h-full object-cover"
/>
{:else}
<!-- Fallback when no cover -->
<div class="absolute inset-0 flex items-center justify-center bg-(--color-surface-2)">
<svg class="w-20 h-20 text-(--color-muted)/30" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
</div>
{/if}
<!-- Top gradient: surface → transparent (for header legibility) -->
<div
class="absolute inset-x-0 top-0 h-28 pointer-events-none"
style="background: linear-gradient(to bottom, var(--color-surface) 0%, transparent 100%);"
<!-- ── Blurred background (full-screen atmospheric layer) ───────────── -->
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
aria-hidden="true"
></div>
<!-- Bottom gradient: transparent → surface (seamless blend into controls) -->
<div
class="absolute inset-x-0 bottom-0 h-40 pointer-events-none"
style="background: linear-gradient(to top, var(--color-surface) 0%, transparent 100%);"
aria-hidden="true"
></div>
class="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
style="filter: blur(40px) brightness(0.25) saturate(1.4); transform: scale(1.15); z-index: 0;"
/>
{:else}
<div class="absolute inset-0 pointer-events-none" style="background: var(--color-surface-2); z-index: 0;"></div>
{/if}
<!-- Subtle vignette overlay for depth -->
<div
class="absolute inset-0 pointer-events-none"
style="background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.55) 100%); z-index: 1;"
aria-hidden="true"
></div>
<!-- Header bar (sits over the top gradient) -->
<div class="relative z-10 flex items-center justify-between px-4 pt-3 pb-2">
<button
type="button"
onclick={onclose}
class="p-2 rounded-full text-(--color-text)/70 hover:text-(--color-text) hover:bg-black/20 transition-colors"
aria-label="Close listening mode"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-text)/60 uppercase tracking-wider">Now Playing</span>
<div class="flex items-center gap-2">
<!-- Chapters button -->
{#if audioStore.chapters.length > 0}
<button
type="button"
onclick={() => { showChapterModal = !showChapterModal; showVoiceModal = false; voiceSearch = ''; }}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showChapterModal
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-white/20 bg-black/25 text-(--color-text)/70 hover:text-(--color-text) backdrop-blur-sm'
)}
aria-label="Browse chapters"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
Chapters
</button>
{/if}
<!-- Voice selector button -->
<!-- ── Header bar ─────────────────────────────────────────────────────── -->
<div class="relative flex items-center justify-between px-4 pt-3 pb-2 shrink-0" style="z-index: 2;">
<button
type="button"
onclick={onclose}
class="p-2 rounded-full text-(--color-text)/70 hover:text-(--color-text) hover:bg-white/10 transition-colors"
aria-label="Close listening mode"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-text)/60 uppercase tracking-wider">Now Playing</span>
<div class="flex items-center gap-2">
<!-- Chapters button -->
{#if audioStore.chapters.length > 0}
<button
type="button"
onclick={() => { showVoiceModal = !showVoiceModal; showChapterModal = false; }}
onclick={() => { showChapterModal = !showChapterModal; showVoiceModal = false; voiceSearch = ''; }}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showVoiceModal
showChapterModal
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-white/20 bg-black/25 text-(--color-text)/70 hover:text-(--color-text) backdrop-blur-sm'
)}
aria-label="Browse chapters"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
Chapters
</button>
</div>
{/if}
<!-- Voice selector button -->
<button
type="button"
onclick={() => { showVoiceModal = !showVoiceModal; showChapterModal = false; }}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showVoiceModal
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-white/20 bg-black/25 text-(--color-text)/70 hover:text-(--color-text) backdrop-blur-sm'
)}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
</svg>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
</button>
</div>
</div>
<!-- ── Portrait cover card + track info ───────────────────────────────── -->
<div class="relative flex flex-col items-center gap-4 px-8 pt-2 pb-4 shrink-0" style="z-index: 2;">
<!-- Cover card -->
<div
class="rounded-2xl overflow-hidden shadow-2xl"
style="height: 38svh; min-height: 180px; max-height: 320px; aspect-ratio: 2/3;"
>
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-(--color-surface-2) flex items-center justify-center">
<svg class="w-16 h-16 text-(--color-muted)/30" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
</div>
{/if}
</div>
<!-- Track info (sits over the bottom gradient) -->
<div class="absolute inset-x-0 bottom-0 z-10 px-6 pb-3 text-center">
<!-- Track info -->
<div class="text-center w-full">
{#if audioStore.chapter > 0}
<p class="text-[10px] font-bold uppercase tracking-widest text-(--color-brand) mb-0.5">
Chapter {audioStore.chapter}
@@ -489,7 +510,7 @@
{/if}
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden">
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" style="z-index: 2; position: relative;">
<!-- Seek bar -->
<div class="shrink-0 mb-1">

View File

@@ -954,7 +954,7 @@
<!-- Listening mode — mounted at root level, independent of audioStore.active,
so closing/pausing audio never tears it down and loses context. -->
{#if listeningModeOpen}
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }}>
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}

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}`);