Compare commits

...

6 Commits

Author SHA1 Message Date
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
root
718929e9cd 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
- 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
2026-04-06 16:16:09 +05:00
root
e8870a11da feat(player): redesign ListeningMode UI
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m39s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m57s
Release / Docker / runner (push) Successful in 2m44s
Release / Upload source maps (push) Successful in 1m35s
Release / Docker / ui (push) Successful in 2m15s
Release / Gitea Release (push) Successful in 40s
- Full-bleed cover fills top ~52% of screen with top+bottom gradient
  overlays for header and track info legibility; eliminates the large
  dead space between cover and seek bar
- Chapter number shown as brand-coloured label above the chapter title
- Remaining time (−m:ss) displayed in the centre of the seek bar row
- Transport row uses justify-between; chapter-skip buttons are smaller
  (w-5, muted/60 opacity) vs time-skip (w-7, full muted) to clearly
  distinguish secondary from primary seek controls
- Speed / Auto-next / Sleep now sit on a single tidy row — no more
  wrapping or mixed visual styles between the segmented speed control
  and the two pill buttons
- Header buttons use frosted-glass style (bg-black/25 backdrop-blur)
  so they remain legible over the cover image
2026-04-06 15:51:06 +05:00
root
b70fed5cd7 fix(reader): clean up focus mode footer
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 1m35s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m45s
Release / Docker / runner (push) Successful in 2m32s
Release / Upload source maps (push) Successful in 1m31s
Release / Docker / ui (push) Successful in 2m48s
Release / Gitea Release (push) Successful in 45s
- Hide the global site footer on chapter pages (not useful mid-reading)
- Merge the three separate floating nav pills into a single unified pill
  with dividers, removing the visual clutter of multiple bordered bubbles
- Float the pill lower (bottom-6) when the mini-player is not active
2026-04-06 15:42:45 +05:00
root
5dd9dd2ebb feat(nav): make book title in chapter header a link back to the book page
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
2026-04-06 15:38:18 +05:00
root
1c5c25e5dd feat(reader): add lines-per-page setting for paginated mode
Some checks failed
Release / Test backend (push) Successful in 58s
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 / Check ui (push) Has been cancelled
Adds a PageLines preference (Few/Normal/Many) that adjusts the paginated
container height via a rem offset on the existing calc(). The setting row
appears in Reader Settings → Layout only when Pages mode is active, matching
the style of all other setting rows. Persisted in localStorage (reader_layout_v1).
2026-04-06 15:37:29 +05:00
3 changed files with 407 additions and 319 deletions

View File

@@ -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)));
@@ -188,67 +247,113 @@
<!-- 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);"
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}
>
<!-- Blurred cover background -->
{#if audioStore.cover}
<!-- ── 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-0 bg-cover bg-center opacity-10 blur-2xl scale-110"
style="background-image: url('{audioStore.cover}');"
class="absolute inset-x-0 top-0 h-28 pointer-events-none"
style="background: linear-gradient(to bottom, var(--color-surface) 0%, transparent 100%);"
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>
{/if}
<!-- Header bar -->
<div class="relative flex items-center justify-between px-4 py-3 shrink-0">
<button
type="button"
onclick={onclose}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) 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-muted) 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)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
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 (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={() => { 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)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
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-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 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>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
</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 -->
<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>
<!-- Track info (sits over the bottom gradient) -->
<div class="absolute inset-x-0 bottom-0 z-10 px-6 pb-3 text-center">
{#if audioStore.chapter > 0}
<p class="text-[10px] font-bold uppercase tracking-widest text-(--color-brand) mb-0.5">
Chapter {audioStore.chapter}
</p>
{/if}
<p class="text-lg font-bold text-(--color-text) leading-snug line-clamp-2">
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
</p>
<p class="text-sm text-(--color-text)/50 mt-0.5 truncate">{audioStore.bookTitle}</p>
</div>
</div>
@@ -273,7 +378,6 @@
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Select Voice</span>
</div>
<!-- Search input -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
@@ -288,7 +392,6 @@
/>
</div>
</div>
<!-- Voice list -->
<div class="flex-1 overflow-y-auto">
{#each ([['Kokoro', filteredKokoro], ['Pocket TTS', filteredPocket], ['CF AI', filteredCfai]] as [string, Voice[]][]) as [label, group]}
@@ -298,45 +401,24 @@
<div
class={cn(
'flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors',
audioStore.voice === v.id
? 'bg-(--color-brand)/8'
: 'hover:bg-(--color-surface-2)'
audioStore.voice === v.id ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Select voice -->
<button
type="button"
onclick={() => selectVoice(v.id)}
class="flex-1 flex items-center gap-3 text-left"
>
<!-- Selected indicator -->
<button type="button" onclick={() => selectVoice(v.id)} class="flex-1 flex items-center gap-3 text-left">
<span class={cn(
'w-4 h-4 shrink-0 rounded-full border-2 flex items-center justify-center transition-colors',
audioStore.voice === v.id
? 'border-(--color-brand) bg-(--color-brand)'
: 'border-(--color-border)'
audioStore.voice === v.id ? 'border-(--color-brand) bg-(--color-brand)' : 'border-(--color-border)'
)}>
{#if audioStore.voice === v.id}
<svg class="w-2 h-2 text-(--color-surface)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
<svg class="w-2 h-2 text-(--color-surface)" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
{/if}
</span>
<span class={cn(
'text-sm',
audioStore.voice === v.id ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{voiceLabel(v)}</span>
<span class={cn('text-sm', audioStore.voice === v.id ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{voiceLabel(v)}</span>
</button>
<!-- Sample play button -->
<button
type="button"
onclick={() => playSample(v.id)}
class={cn(
'shrink-0 p-2 rounded-full transition-colors',
samplePlayingVoice === v.id
? 'text-(--color-brand) bg-(--color-brand)/10'
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
)}
class={cn('shrink-0 p-2 rounded-full transition-colors', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)')}
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
>
@@ -360,11 +442,7 @@
<!-- Chapter modal (full-screen overlay) -->
{#if showChapterModal && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 z-70 flex flex-col"
style="background: var(--color-surface);"
>
<!-- Modal header -->
<div class="absolute inset-0 z-70 flex flex-col" style="background: var(--color-surface);">
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<button
type="button"
@@ -378,7 +456,6 @@
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search input -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -392,35 +469,24 @@
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
use:scrollIfActive={ch.number === audioStore.chapter}
class={cn(
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
use:scrollIfActive={ch.number === audioStore.chapter}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge (mirrors voice radio indicator) -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === audioStore.chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
{/each}
@@ -431,195 +497,173 @@
</div>
{/if}
<!-- Scrollable body — fills remaining height, content spread vertically -->
<div class="relative flex-1 overflow-y-auto flex flex-col justify-between py-4">
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden">
<!-- Cover art + track info -->
<div class="flex flex-col items-center px-8 shrink-0">
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-44 h-64 object-cover rounded-xl shadow-2xl mb-5"
/>
{:else}
<div class="w-44 h-64 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
<svg class="w-16 h-16 text-(--color-muted)/40" 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}
<p class="text-base font-bold text-(--color-text) text-center leading-snug">
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
</p>
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
<!-- Seek bar -->
<div class="shrink-0 mb-1">
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1.5 cursor-pointer block"
style="accent-color: var(--color-brand);"
/>
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
<span>{formatTime(audioStore.currentTime)}</span>
<!-- Remaining time in centre -->
{#if audioStore.duration > 0}
<span class="text-(--color-muted)/60">{formatTime(Math.max(0, audioStore.duration - audioStore.currentTime))}</span>
{/if}
<span>{formatTime(audioStore.duration)}</span>
</div>
</div>
<!-- Bottom controls cluster: seek + transport + secondary -->
<div class="flex flex-col gap-0 px-6 shrink-0">
<!-- Seek bar -->
<div class="shrink-0">
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
style="accent-color: var(--color-brand);"
/>
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
<span>{formatTime(audioStore.currentTime)}</span>
<span>{formatTime(audioStore.duration)}</span>
</div>
</div>
<!-- Transport controls -->
<div class="flex items-center justify-center gap-4 pt-5 pb-3 shrink-0">
<!-- Prev chapter -->
{#if audioStore.chapter > 1 && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
class="p-2 rounded-full text-(--color-muted) 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"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
<!-- Skip back 15s -->
<button
type="button"
onclick={skipBack}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip back 15 seconds"
title="Back 15s"
<!-- Transport controls -->
<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}"
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-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
<!-- Play / Pause -->
<button
type="button"
onclick={togglePlay}
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<!-- Skip back 15s — medium -->
<button
type="button"
onclick={skipBack}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip back 15 seconds"
title="Back 15s"
>
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
</svg>
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={skipForward}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip forward 30 seconds"
title="Forward 30s"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
<!-- Play / Pause — largest, centred -->
<button
type="button"
onclick={togglePlay}
class="w-18 h-18 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-xl"
style="width: 4.5rem; height: 4.5rem;"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
</button>
<!-- Next chapter -->
{#if audioStore.nextChapter !== null && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
class="p-2 rounded-full text-(--color-muted) 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"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
<svg class="w-8 h-8 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</div>
</button>
<!-- Secondary controls: Speed · Auto-next · Sleep -->
<div class="flex items-center justify-center gap-3 pb-3 shrink-0 flex-wrap">
<!-- Speed -->
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
{#each SPEED_OPTIONS as s}
<button
type="button"
onclick={() => (audioStore.speed = s)}
class={cn(
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
audioStore.speed === s
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
<!-- Skip forward 30s — medium -->
<button
type="button"
onclick={skipForward}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip forward 30 seconds"
title="Forward 30s"
>
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
</svg>
</button>
<!-- Auto-next -->
<button
type="button"
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.autoNext
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.autoNext}
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
<!-- Next chapter — smaller, clearly secondary -->
{#if audioStore.nextChapter !== null && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{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-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<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"/>
</svg>
Auto
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
</div>
<!-- Sleep timer -->
<button
type="button"
onclick={cycleSleepTimer}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title="Sleep timer"
>
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{sleepLabel}
</button>
<!-- Secondary controls: unified single row — Speed · Auto · Sleep -->
<div class="flex items-center justify-center gap-2 shrink-0">
<!-- Speed — segmented pill -->
<div class="flex items-center gap-0.5 bg-(--color-surface-2) rounded-full px-1.5 py-1 border border-(--color-border)">
{#each SPEED_OPTIONS as s}
<button
type="button"
onclick={() => (audioStore.speed = s)}
class={cn(
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
audioStore.speed === s
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
<!-- Auto-next pill -->
<button
type="button"
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.autoNext
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.autoNext}
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
<!-- Sleep timer pill -->
<button
type="button"
onclick={cycleSleepTimer}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title="Sleep timer"
>
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{sleepLabel}
</button>
</div>
</div>

View File

@@ -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();
@@ -389,9 +390,12 @@
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<span class="text-(--color-muted) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
<a
href="/books/{page.data.book.slug}"
class="text-(--color-muted) hover:text-(--color-text) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs transition-colors"
>
{page.data.book.title}
</span>
</a>
{/if}
{#if data.user}
@@ -718,7 +722,9 @@
{/key}
</main>
<footer class="border-t border-(--color-border) mt-auto">
<footer class="border-t border-(--color-border) mt-auto"
class:hidden={/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
>
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
<!-- Top row: site links -->
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
@@ -948,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 }}>
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
</div>
{/if}

View File

@@ -53,6 +53,8 @@
type ReadWidth = 'narrow' | 'normal' | 'wide';
type ParaStyle = 'spaced' | 'indented';
type PlayerStyle = 'standard' | 'compact';
/** Controls how many lines fit on a page by adjusting the container height offset. */
type PageLines = 'less' | 'normal' | 'more';
interface LayoutPrefs {
readMode: ReadMode;
@@ -61,12 +63,19 @@
paraStyle: ParaStyle;
focusMode: boolean;
playerStyle: PlayerStyle;
pageLines: PageLines;
}
const LAYOUT_KEY = 'reader_layout_v1';
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
/**
* Extra rem subtracted from (or added to) the paginated container height.
* Normal (0rem) keeps the existing calc(); Less (-4rem) makes the container
* shorter so fewer lines fit per page; More (+4rem) grows it for more lines.
*/
const PAGE_LINES_OFFSET: Record<PageLines, string> = { less: '4rem', normal: '0rem', more: '-4rem' };
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal' };
function loadLayout(): LayoutPrefs {
if (!browser) return DEFAULT_LAYOUT;
@@ -114,8 +123,10 @@
$effect(() => {
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
// Re-run when html changes or container is bound
// Re-run when html, container refs, or mini-player visibility changes
// (mini-player adds pb-24 which reduces the available viewport height)
void html; void paginatedContainerEl; void paginatedContentEl;
void audioStore.active; void audioExpanded;
requestAnimationFrame(() => {
if (!paginatedContainerEl || !paginatedContentEl) return;
const h = paginatedContainerEl.clientHeight;
@@ -542,7 +553,9 @@
role="none"
bind:this={paginatedContainerEl}
class="paginated-container mt-8"
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
style="height: {layout.focusMode
? 'calc(100svh - 8rem)'
: `calc(100svh - 26rem - ${PAGE_LINES_OFFSET[layout.pageLines]})`};"
onclick={handlePaginatedClick}
>
<div
@@ -636,40 +649,44 @@
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
{#if layout.focusMode}
<div class="fixed bottom-[4.5rem] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50">
<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">
{#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"
aria-label="Previous chapter"
>
<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>
{m.reader_chapter_n({ n: String(data.prev) })}
</a>
{/if}
<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"
aria-label="Exit focus mode"
>
<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"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
{m.reader_chapter_n({ n: String(data.prev) })}
</a>
{/if}
<button
type="button"
onclick={() => setLayout('focusMode', false)}
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-brand) text-xs transition-colors shadow-md"
aria-label="Exit focus mode"
>
<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="M6 18L18 6M6 6l12 12"/>
</svg>
Exit focus
</button>
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
>
{m.reader_chapter_n({ n: String(data.next) })}
<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>
</a>
{/if}
Exit focus
</button>
{#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"
aria-label="Next chapter"
>
{m.reader_chapter_n({ n: String(data.next) })}
<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>
</a>
{/if}
</div>
</div>
{/if}
@@ -783,6 +800,25 @@
</div>
</div>
{#if layout.readMode === 'paginated'}
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Lines</span>
<div class="flex gap-1.5 flex-1">
{#each ([['less', 'Few'], ['normal', 'Normal'], ['more', 'Many']] as const) as [v, lbl]}
<button
type="button"
onclick={() => setLayout('pageLines', v)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.pageLines === v
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.pageLines === v}
>{lbl}</button>
{/each}
</div>
</div>
{/if}
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
<div class="flex gap-1.5 flex-1">