diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 17c3a96..eb444a3 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.101.0"
+version = "0.102.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 05bfcf7..07fc8d0 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.101.0"
+version = "0.102.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/reader-next-preload.spec.ts b/frontend/e2e/reader-next-preload.spec.ts
new file mode 100644
index 0000000..7059d41
--- /dev/null
+++ b/frontend/e2e/reader-next-preload.spec.ts
@@ -0,0 +1,67 @@
+import { test, expect, type Page } from './fixtures';
+
+// As the reader nears the end of a chapter, the next chapter's first page
+// images are prefetched (hidden) so flipping over is instant. They must NOT
+// be present at the start of the chapter.
+
+const MANGA_ID = 'm1';
+const CH1 = 'c1';
+const CH2 = 'c2';
+
+function pages(prefix: string, n: number) {
+ return Array.from({ length: n }, (_, i) => ({
+ id: `${prefix}p${i + 1}`,
+ chapter_id: prefix,
+ page_number: i + 1,
+ storage_key: `${prefix}/${i + 1}`,
+ content_type: 'image/svg+xml'
+ }));
+}
+
+function chapter(id: string, number: number, count: number) {
+ return { id, manga_id: MANGA_ID, number, title: null, page_count: count, created_at: `2026-0${number}-01T00:00:00Z`, size_bytes: 0 };
+}
+
+async function mockReader(page: Page) {
+ await page.route('**/api/v1/**', async (route) => {
+ const { pathname } = new URL(route.request().url());
+ const json = (status: number, body: unknown) =>
+ route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
+
+ if (pathname.includes('/files/')) {
+ return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '' });
+ }
+ if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
+ if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ if (pathname.endsWith(`/chapters/${CH1}/pages`)) return json(200, { pages: pages(CH1, 5) });
+ if (pathname.endsWith(`/chapters/${CH2}/pages`)) return json(200, { pages: pages(CH2, 4) });
+ if (pathname.endsWith(`/chapters/${CH1}`)) return json(200, chapter(CH1, 1, 5));
+ if (pathname.endsWith(`/chapters/${CH2}`)) return json(200, chapter(CH2, 2, 4));
+ if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
+ // Oldest-first, as the reader expects (next = index + 1).
+ return json(200, { items: [chapter(CH1, 1, 5), chapter(CH2, 2, 4)], page: { limit: 200, offset: 0, total: 2 } });
+ }
+ if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
+ if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
+ });
+}
+
+test('prefetches the next chapter first pages only when near the end', async ({ page }) => {
+ await mockReader(page);
+ await page.goto(`/manga/${MANGA_ID}/chapter/${CH1}`);
+ await expect(page.getByTestId('reader-page')).toBeVisible();
+
+ // Start of a 5-page chapter → not near the end → no next-chapter preload.
+ await expect(page.getByTestId('reader-next-preload')).toHaveCount(0);
+
+ // Jump to the last page.
+ await page.keyboard.press('End');
+
+ // Next chapter's first pages are now prefetched.
+ const preloads = page.getByTestId('reader-next-preload');
+ await expect(preloads.first()).toBeAttached();
+ await expect(preloads).toHaveCount(3);
+ await expect(preloads.first()).toHaveAttribute('src', /c2\/1/);
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4e0da67..3eeb0dc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mangalord-frontend",
- "version": "0.101.0",
+ "version": "0.102.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mangalord-frontend",
- "version": "0.101.0",
+ "version": "0.102.0",
"devDependencies": {
"@lucide/svelte": "^1.16.0",
"@playwright/test": "^1.48.0",
diff --git a/frontend/package.json b/frontend/package.json
index ef6b757..58102a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.101.0",
+ "version": "0.102.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
index e376d94..116e60d 100644
--- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
+++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
@@ -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([]);
+ 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 @@
{/if}
+
+{#each nextPreloadUrls as u (u)}
+
+{/each}
+