Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e4d7b54d7 | ||
|
|
495f386b4f | ||
|
|
bb61a4654a |
@@ -241,9 +241,9 @@
|
||||
}
|
||||
|
||||
// Keep nextChapter in the store so the layout's onended can navigate.
|
||||
// NOTE: we do NOT clear on unmount here — the store retains the value so
|
||||
// onended (which may fire after {#key} unmounts this component) can still
|
||||
// read it. The value is superseded when the new chapter mounts.
|
||||
// We write null on mount (before deriving the real value) so there is no
|
||||
// stale window where the previous chapter's nextChapter is still set while
|
||||
// this chapter's AudioPlayer hasn't written its own value yet.
|
||||
$effect(() => {
|
||||
audioStore.nextChapter = nextChapter ?? null;
|
||||
});
|
||||
@@ -566,21 +566,27 @@
|
||||
audioStore.errorMsg = '';
|
||||
|
||||
try {
|
||||
// Fast path A: pre-fetch already landed for THIS chapter.
|
||||
// Fast path A: pre-fetch already confirmed audio is in MinIO for THIS chapter.
|
||||
// Re-presign instead of using the cached URL — it may have expired if the
|
||||
// user paused for a while between the prefetch and actually reaching this chapter.
|
||||
if (
|
||||
audioStore.nextStatus === 'prefetched' &&
|
||||
audioStore.nextChapterPrefetched === chapter &&
|
||||
audioStore.nextAudioUrl
|
||||
audioStore.nextChapterPrefetched === chapter
|
||||
) {
|
||||
const url = audioStore.nextAudioUrl;
|
||||
// Consume the pre-fetch — reset so it doesn't carry over
|
||||
// Consume the pre-fetch state first so it doesn't carry over on error.
|
||||
audioStore.resetNextPrefetch();
|
||||
audioStore.audioUrl = url;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore saved time for auto-next; position is 0
|
||||
// Immediately start pre-generating the chapter after this one.
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
// Fresh presign — audio is confirmed in MinIO so this is a fast, cheap call.
|
||||
const presigned = await tryPresign(slug, chapter, voice);
|
||||
if (presigned.ready) {
|
||||
audioStore.audioUrl = presigned.url;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore saved time for auto-next; position is 0.
|
||||
// Immediately start pre-generating the chapter after this one.
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
// Presign returned not-ready (race: MinIO object vanished?).
|
||||
// Fall through to the normal slow path below.
|
||||
}
|
||||
|
||||
// Fast path B: audio already in MinIO (presign check).
|
||||
|
||||
@@ -262,90 +262,101 @@
|
||||
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}
|
||||
@@ -499,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">
|
||||
@@ -527,16 +538,17 @@
|
||||
<div class="flex items-center justify-between pt-3 pb-4 shrink-0">
|
||||
<!-- Prev chapter — smaller, clearly secondary -->
|
||||
{#if audioStore.chapter > 1 && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(audioStore.chapter - 1)}
|
||||
class="p-2 rounded-full text-(--color-muted)/60 hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
<path d="M6 6h2v12H6zm2 6 8.5 6V6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
@@ -590,16 +602,17 @@
|
||||
|
||||
<!-- Next chapter — smaller, clearly secondary -->
|
||||
{#if audioStore.nextChapter !== null && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(audioStore.nextChapter!)}
|
||||
class="p-2 rounded-full text-(--color-muted)/60 hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
|
||||
@@ -567,7 +567,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page indicator + nav -->
|
||||
<!-- Page indicator + nav (hidden in focus mode — shown in floating pill instead) -->
|
||||
{#if !layout.focusMode}
|
||||
<div class="flex items-center justify-between mt-4 select-none">
|
||||
<button
|
||||
type="button"
|
||||
@@ -596,6 +597,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- ── Scroll reader ──────────────────────────────────────────────── -->
|
||||
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
|
||||
@@ -649,12 +651,13 @@
|
||||
|
||||
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
|
||||
{#if layout.focusMode}
|
||||
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50">
|
||||
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
|
||||
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
|
||||
<!-- Prev chapter -->
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors shrink-0"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -663,10 +666,41 @@
|
||||
{m.reader_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Page prev / counter / next (paginated mode only) -->
|
||||
{#if layout.readMode === 'paginated'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex > 0) pageIndex--; }}
|
||||
disabled={pageIndex === 0}
|
||||
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="px-2.5 py-2 tabular-nums text-(--color-muted) shrink-0 select-none">
|
||||
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
disabled={pageIndex === totalPages - 1}
|
||||
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<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="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Exit focus -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', false)}
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-brand) hover:bg-(--color-surface-3) transition-colors"
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-brand) hover:bg-(--color-surface-3) transition-colors shrink-0"
|
||||
aria-label="Exit focus mode"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -674,10 +708,12 @@
|
||||
</svg>
|
||||
Exit focus
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors shrink-0"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
{m.reader_chapter_n({ n: String(data.next) })}
|
||||
|
||||
Reference in New Issue
Block a user