feat(reader): prefetch the next chapter's first pages near the end

When the reader reaches within two pages of the end of a chapter (tracked in
both modes off the furthest page reached), warm the next chapter's first few
page images via hidden imgs so flipping over renders instantly. Fetched once
per next-chapter id, guarded against mid-flight chapter changes, and retried
on failure. e2e asserts the prefetch is absent at the chapter start and
present (with the next chapter's URLs) near the end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 21:28:06 +02:00
parent 3f1a5e9c41
commit f1349100d7
6 changed files with 112 additions and 6 deletions

View File

@@ -6,7 +6,7 @@
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { chapterLabel } from '$lib/api/chapters';
import { chapterLabel, getChapterPages } from '$lib/api/chapters';
import { resyncChapter, analyzePage } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
@@ -959,6 +959,38 @@
}
}
// ---- Next-chapter image preloading ----
// When the reader nears the end of the current chapter, warm the next
// chapter's first few page images so flipping over is instant. Works in
// both modes off the furthest page reached (single: `index`; continuous:
// the IntersectionObserver high-water `progressPage`).
const PRELOAD_WITHIN_PAGES = 2;
const NEXT_PRELOAD_COUNT = 3;
let nextPreloadUrls = $state<string[]>([]);
let preloadedForChapterId: string | null = null;
const furthestPage = $derived(mode === 'single' ? index + 1 : progressPage);
$effect(() => {
const nc = nextChapter;
if (!nc || pages.length === 0) return;
if (pages.length - furthestPage > PRELOAD_WITHIN_PAGES) return;
if (preloadedForChapterId === nc.id) return;
// Guard set before the await so overlapping effect runs don't
// double-fetch; cleared on failure so a later pass can retry.
preloadedForChapterId = nc.id;
getChapterPages(manga.id, nc.id)
.then((ps) => {
if (preloadedForChapterId !== nc.id) return; // chapter changed mid-flight
nextPreloadUrls = ps
.slice(0, NEXT_PRELOAD_COUNT)
.map((p) => fileUrl(p.storage_key));
})
.catch(() => {
if (preloadedForChapterId === nc.id) preloadedForChapterId = null;
});
});
onMount(() => {
window.addEventListener('pagehide', flushFinalProgress);
});
@@ -1369,6 +1401,13 @@
</div>
{/if}
<!-- Warm the next chapter's first images once the reader nears the end of
this one. Hidden (display:none still fetches) — a pure cache primer so
flipping to the next chapter shows pages immediately. -->
{#each nextPreloadUrls as u (u)}
<img src={u} alt="" aria-hidden="true" class="preload" loading="eager" data-testid="reader-next-preload" />
{/each}
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
scroll so left/right would steal panning. Tap left/right advances
within the chapter (falling through to adjacent chapters at the