diff --git a/app/api/chapters/[chapterId]/meta/route.ts b/app/api/chapters/[chapterId]/meta/route.ts
new file mode 100644
index 0000000..2c324ab
--- /dev/null
+++ b/app/api/chapters/[chapterId]/meta/route.ts
@@ -0,0 +1,19 @@
+import { prisma } from "@/lib/db";
+
+type Params = { params: Promise<{ chapterId: string }> };
+
+export async function GET(_request: Request, { params }: Params) {
+ const { chapterId: raw } = await params;
+ const chapterId = parseInt(raw, 10);
+ if (isNaN(chapterId)) {
+ return Response.json({ error: "Invalid chapterId" }, { status: 400 });
+ }
+
+ const pages = await prisma.page.findMany({
+ where: { chapterId },
+ orderBy: { number: "asc" },
+ select: { number: true, width: true, height: true },
+ });
+
+ return Response.json(pages);
+}
diff --git a/app/manga/[slug]/[chapter]/page.tsx b/app/manga/[slug]/[chapter]/page.tsx
index cd8aefb..ee1ba58 100644
--- a/app/manga/[slug]/[chapter]/page.tsx
+++ b/app/manga/[slug]/[chapter]/page.tsx
@@ -26,17 +26,24 @@ export default async function ChapterReaderPage({ params }: Props) {
const chapterNum = parseInt(chapter, 10);
if (isNaN(chapterNum)) notFound();
- const manga = await prisma.manga.findUnique({
- where: { slug },
- include: {
- chapters: {
- orderBy: { number: "asc" },
- include: {
- _count: { select: { pages: true } },
+ const [manga, initialChapterMeta] = await Promise.all([
+ prisma.manga.findUnique({
+ where: { slug },
+ include: {
+ chapters: {
+ orderBy: { number: "asc" },
+ include: {
+ _count: { select: { pages: true } },
+ },
},
},
- },
- });
+ }),
+ prisma.page.findMany({
+ where: { chapter: { number: chapterNum, manga: { slug } } },
+ orderBy: { number: "asc" },
+ select: { number: true, width: true, height: true },
+ }),
+ ]);
if (!manga) notFound();
@@ -68,6 +75,7 @@ export default async function ChapterReaderPage({ params }: Props) {
prevChapter={prevChapter}
nextChapter={nextChapter}
chapters={allChapters}
+ initialChapterMeta={initialChapterMeta}
/>
);
}
diff --git a/components/ChapterList.tsx b/components/ChapterList.tsx
index 1853b0d..b2c5fd1 100644
--- a/components/ChapterList.tsx
+++ b/components/ChapterList.tsx
@@ -25,6 +25,7 @@ export function ChapterList({
diff --git a/components/PageReader.tsx b/components/PageReader.tsx
index 9270a2f..aa4c5fd 100644
--- a/components/PageReader.tsx
+++ b/components/PageReader.tsx
@@ -1,6 +1,14 @@
"use client";
-import { useState, useEffect, useRef, useCallback, useMemo } from "react";
+import {
+ Fragment,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -15,16 +23,7 @@ type ChapterMeta = {
totalPages: number;
};
-type LoadedPage = {
- chapterNumber: number;
- pageNumber: number;
- imageUrl: string;
-};
-
-type RawPage = {
- number: number;
- imageUrl: string;
-};
+type PageMeta = { number: number; width: number; height: number };
type PageReaderProps = {
mangaSlug: string;
@@ -33,10 +32,20 @@ type PageReaderProps = {
prevChapter: number | null;
nextChapter: number | null;
chapters: ChapterMeta[];
+ initialChapterMeta: PageMeta[];
};
-const BATCH_SIZE = 7;
-const PREFETCH_AT = 3;
+const PREFETCH_NEXT_AT = 3;
+const IMAGE_BATCH_RADIUS = 3;
+const DOUBLE_TAP_MS = 280;
+
+const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
+
+type IntersectingPage = {
+ chNum: number;
+ pNum: number;
+ el: HTMLDivElement;
+};
export function PageReader({
mangaSlug,
@@ -45,10 +54,14 @@ export function PageReader({
prevChapter,
nextChapter,
chapters,
+ initialChapterMeta,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
- const [pages, setPages] = useState
([]);
+ const [chapterMetas, setChapterMetas] = useState>({
+ [startChapterNumber]: initialChapterMeta,
+ });
+ const [images, setImages] = useState>({});
const [currentChapterNum, setCurrentChapterNum] =
useState(startChapterNumber);
const [currentPageNum, setCurrentPageNum] = useState(() => {
@@ -58,216 +71,209 @@ export function PageReader({
return 1;
});
- const hiddenByScrollRef = useRef(false);
- const fetchChapterIdxRef = useRef(
- chapters.findIndex((c) => c.number === startChapterNumber)
- );
- // Initialize offset from saved progress so the first fetch starts AT the
- // user's last-read page — previous pages are skipped entirely
- const offsetRef = useRef(0);
- const initialPageRef = useRef(1);
- const offsetInitedRef = useRef(false);
- if (!offsetInitedRef.current && typeof window !== "undefined") {
- offsetInitedRef.current = true;
- const p = readProgress(mangaSlug);
- if (p && p.chapter === startChapterNumber && p.page > 1) {
- offsetRef.current = p.page - 1;
- initialPageRef.current = p.page;
- }
- }
- const loadingRef = useRef(false);
- const doneRef = useRef(false);
- // Count of pages already loaded — tracked via ref so fetchBatch stays stable
- // (otherwise every batch re-creates fetchBatch and tears down the observer)
- const loadedCountRef = useRef(0);
- const triggerIndicesRef = useRef>(new Set());
+ // Observer stays stable across state updates.
+ const imagesRef = useRef(images);
+ const chapterMetasRef = useRef(chapterMetas);
+ useEffect(() => {
+ imagesRef.current = images;
+ }, [images]);
+ useEffect(() => {
+ chapterMetasRef.current = chapterMetas;
+ }, [chapterMetas]);
+
+ const metaInflightRef = useRef>(new Set());
+ const imagesInflightRef = useRef>(new Set());
+ const pageElRef = useRef