From f1349100d76f30344c9b7d2e6853269213a0d890 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 21:28:06 +0200 Subject: [PATCH] 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/reader-next-preload.spec.ts | 67 +++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../[id]/chapter/[chapter_id]/+page.svelte | 41 +++++++++++- 6 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 frontend/e2e/reader-next-preload.spec.ts 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} +