Compare commits

...

4 Commits

Author SHA1 Message Date
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
root
5177320418 feat(player): scroll chapter list to current chapter on open
Some checks failed
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 1m41s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m42s
Release / Docker / runner (push) Successful in 2m35s
Release / Upload source maps (push) Successful in 1m31s
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Use a Svelte action on each chapter button that calls scrollIntoView with
behavior:'instant' so the list opens centred on the active chapter with
no visible scroll animation.
2026-04-06 15:31:24 +05:00
root
836c9855af fix(player): use untrack() in toggleRequest effect to prevent play/pause loop
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m40s
Release / Docker / caddy (push) Successful in 46s
Release / Docker / backend (push) Successful in 2m29s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 1m31s
Release / Docker / ui (push) Successful in 2m21s
Release / Gitea Release (push) Successful in 30s
Reading audioStore.isPlaying inside the toggleRequest $effect caused Svelte 5
to subscribe to it, so the effect re-ran on every isPlaying change. When
resuming from ListeningMode, play() would fire onplay → isPlaying=true →
effect re-ran → called pause() → onpause → isPlaying=false → effect re-ran
→ called play() → infinite loop. Wrapping the isPlaying read in untrack()
limits the effect's subscription to toggleRequest only.
2026-04-06 14:57:27 +05:00
3 changed files with 70 additions and 18 deletions

View File

@@ -34,6 +34,14 @@
// ── Chapter search ────────────────────────────────────────────────────────
let chapterSearch = $state('');
// Scroll the current chapter into view instantly (no animation) when the
// chapter modal opens. Applied to every chapter button; only scrolls when
// the chapter number matches the currently playing one. Runs once on mount
// before the browser paints so no scroll animation is ever visible.
function scrollIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
}
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
@@ -386,11 +394,12 @@
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
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)'
)}

View File

@@ -2,7 +2,7 @@
import '../app.css';
import { page, navigating } from '$app/state';
import { goto } from '$app/navigation';
import { setContext } from 'svelte';
import { setContext, untrack } from 'svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
@@ -156,15 +156,23 @@
});
// Handle toggle requests from AudioPlayer controller.
// IMPORTANT: isPlaying must be read inside untrack() so the effect only
// re-runs when toggleRequest increments, not every time isPlaying changes.
// Without untrack the effect subscribes to both toggleRequest AND isPlaying,
// causing an infinite play/pause loop: play() fires onplay → isPlaying=true
// → effect re-runs → sees isPlaying=true → calls pause() → onpause fires
// → isPlaying=false → effect re-runs → calls play() → …
$effect(() => {
// Read toggleRequest to subscribe; ignore value 0 (initial).
const _req = audioStore.toggleRequest;
if (!audioEl || _req === 0) return;
if (audioStore.isPlaying) {
audioEl.pause();
} else {
audioEl.play().catch(() => {});
}
untrack(() => {
if (audioStore.isPlaying) {
audioEl!.pause();
} else {
audioEl!.play().catch(() => {});
}
});
});
// Handle seek requests from AudioPlayer controller.
@@ -381,9 +389,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}

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
@@ -783,6 +796,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">