diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index 1f9c329..b035d40 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -6,12 +6,74 @@ import { majorityVisiblePage, type ReaderMode } from "./readerScroll"; import type { ReaderControls } from "./ReaderShell"; type PageCommitStrategy = "immediate" | "queued"; +type PreloadedComicImage = { + image: HTMLImageElement; + status: "loading" | "loaded" | "error"; +}; +type PreloadPriority = "adjacent" | "deep"; +type VerticalPreloadScheduler = { + active: number; + generation: number; + timer: number | null; + queuedPages: Set; + queues: Record; +}; const VERTICAL_ANCHOR_TOLERANCE_PX = 24; const VERTICAL_ANCHOR_STABLE_FRAMES = 18; const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; -const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 24; +const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3; +const VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100; +const VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90; +const VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD = [0, 1, -1, 2, -2, 3, -3, 4, -4]; +const VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"]; + +function scheduleReaderFrame(callback: FrameRequestCallback) { + if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback); + callback(0); + return 0; +} + +function cancelReaderFrame(frameId: number) { + if (frameId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frameId); +} + +function createVerticalPreloadScheduler(): VerticalPreloadScheduler { + return { + active: 0, + generation: 0, + timer: null, + queuedPages: new Set(), + queues: { + adjacent: [], + deep: [] + } + }; +} + +function uniqueReaderPages(pages: Array, currentPage: number, pageCount: number) { + const seen = new Set(); + return pages + .map((candidate) => (candidate ? clampReaderPage(candidate, pageCount) : null)) + .filter((candidate): candidate is number => { + if (!candidate || candidate === currentPage || seen.has(candidate)) return false; + seen.add(candidate); + return true; + }); +} + +function verticalImagePreloadPlan(currentPage: number, pageCount: number) { + const deepCenter = clampReaderPage(currentPage + VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET, pageCount); + return { + adjacent: uniqueReaderPages([currentPage - 1, currentPage + 1, currentPage + 2], currentPage, pageCount), + deep: uniqueReaderPages( + VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD.map((offset) => deepCenter + offset), + currentPage, + pageCount + ) + }; +} export function CbzReader({ bookId, @@ -40,7 +102,8 @@ export function CbzReader({ const verticalInitialSyncDoneRef = useRef(false); const verticalAnchorAttemptRef = useRef(0); const verticalAnchorStableFramesRef = useRef(0); - const preloadedImagesRef = useRef(new Map()); + const preloadedImagesRef = useRef(new Map()); + const preloadSchedulerRef = useRef(createVerticalPreloadScheduler()); const pendingImageSizesRef = useRef>({}); const imageSizeFlushFrameRef = useRef(null); const [pages, setPages] = useState(null); @@ -98,7 +161,7 @@ export function CbzReader({ const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => { pendingImageSizesRef.current[pageNumber] = size; if (imageSizeFlushFrameRef.current !== null) return; - imageSizeFlushFrameRef.current = requestAnimationFrame(() => { + imageSizeFlushFrameRef.current = scheduleReaderFrame(() => { imageSizeFlushFrameRef.current = null; const pending = pendingImageSizesRef.current; pendingImageSizesRef.current = {}; @@ -116,6 +179,70 @@ export function CbzReader({ }); }); }, []); + const drainVerticalPreloadQueue = useCallback(() => { + const scheduler = preloadSchedulerRef.current; + scheduler.timer = null; + if (typeof Image === "undefined") return; + + const nextPage = () => { + for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) { + const pageNumber = scheduler.queues[priority].shift(); + if (pageNumber) { + scheduler.queuedPages.delete(pageNumber); + return pageNumber; + } + } + return null; + }; + + while (scheduler.active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY) { + const pageNumber = nextPage(); + if (!pageNumber) return; + if (preloadedImagesRef.current.has(pageNumber)) continue; + + const generation = scheduler.generation; + const image = new Image(); + preloadedImagesRef.current.set(pageNumber, { image, status: "loading" }); + scheduler.active += 1; + image.decoding = "async"; + image.loading = "eager"; + image.onload = () => { + if (generation !== scheduler.generation) return; + scheduler.active -= 1; + preloadedImagesRef.current.set(pageNumber, { image, status: "loaded" }); + if (image.naturalWidth > 0 && image.naturalHeight > 0) { + queueImageSize(pageNumber, { width: image.naturalWidth, height: image.naturalHeight }); + } + drainVerticalPreloadQueue(); + }; + image.onerror = () => { + if (generation !== scheduler.generation) return; + scheduler.active -= 1; + preloadedImagesRef.current.set(pageNumber, { image, status: "error" }); + drainVerticalPreloadQueue(); + }; + image.src = api.cbzPageUrl(bookId, pageNumber); + } + }, [bookId, queueImageSize]); + const enqueueVerticalPreload = useCallback( + (plan: Record) => { + const scheduler = preloadSchedulerRef.current; + for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) { + if (priority === "deep") { + for (const pageNumber of scheduler.queues.deep) scheduler.queuedPages.delete(pageNumber); + scheduler.queues.deep = []; + } + for (const pageNumber of plan[priority]) { + if (preloadedImagesRef.current.has(pageNumber) || scheduler.queuedPages.has(pageNumber)) continue; + scheduler.queuedPages.add(pageNumber); + scheduler.queues[priority].push(pageNumber); + } + } + if (scheduler.timer !== null || scheduler.queuedPages.size === 0) return; + scheduler.timer = window.setTimeout(drainVerticalPreloadQueue, VERTICAL_IMAGE_PRELOAD_DELAY_MS); + }, + [drainVerticalPreloadQueue] + ); const commitVisiblePage = useCallback( (stage: HTMLElement, allowInitialCommit = false) => { if (!pages || !verticalTrackingReadyRef.current) return; @@ -219,46 +346,16 @@ export function CbzReader({ setImageSizes({}); pendingImageSizesRef.current = {}; preloadedImagesRef.current.clear(); + const scheduler = preloadSchedulerRef.current; + if (scheduler.timer !== null) window.clearTimeout(scheduler.timer); + preloadSchedulerRef.current = createVerticalPreloadScheduler(); + preloadSchedulerRef.current.generation = scheduler.generation + 1; }, [bookId, retryAttempt]); useEffect(() => { - if (mode !== "vertical" || !pages || typeof Image === "undefined") return; - let cancelled = false; - let nextIndex = 0; - let active = 0; - const pageItems = pages.pages; - - const preloadNext = () => { - if (cancelled) return; - while (active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY && nextIndex < pageItems.length) { - const item = pageItems[nextIndex]; - nextIndex += 1; - if (preloadedImagesRef.current.has(item.page)) continue; - const image = new Image(); - preloadedImagesRef.current.set(item.page, image); - active += 1; - image.decoding = "async"; - image.loading = "eager"; - image.onload = () => { - active -= 1; - if (!cancelled && image.naturalWidth > 0 && image.naturalHeight > 0) { - queueImageSize(item.page, { width: image.naturalWidth, height: image.naturalHeight }); - } - preloadNext(); - }; - image.onerror = () => { - active -= 1; - preloadNext(); - }; - image.src = api.cbzPageUrl(bookId, item.page); - } - }; - - preloadNext(); - return () => { - cancelled = true; - }; - }, [bookId, mode, pages, queueImageSize, retryAttempt]); + if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return; + enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount)); + }, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]); useEffect(() => { const frame = frameRef.current; @@ -366,7 +463,7 @@ export function CbzReader({ useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); useEffect( () => () => { - if (imageSizeFlushFrameRef.current !== null) cancelAnimationFrame(imageSizeFlushFrameRef.current); + if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current); }, [] ); @@ -421,10 +518,12 @@ export function CbzReader({ {item.name} { const { naturalWidth, naturalHeight } = event.currentTarget; + preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" }); queueImageSize(item.page, { width: naturalWidth, height: naturalHeight }); }} onError={() => setPageError(`Page ${item.page} indisponible.`)} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index fd99b2a..ef1ed0c 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -222,12 +222,20 @@ describe("reader runtime helpers", () => { expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}'); }); - it("preloads deep CBZ vertical pages without relying only on browser lazy loading", () => { + it("keeps CBZ vertical image loading on the rendered page elements", () => { const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8"); - expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY"); + expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3"); + expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100"); + expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90"); + expect(source).toContain('VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"]'); + expect(source).toContain("scheduler.queues.deep = []"); + expect(source).toContain("!verticalUserScrollRef.current"); + expect(source).toContain("preloadSchedulerRef"); + expect(source).toContain("verticalImagePreloadPlan"); expect(source).toContain("new Image()"); - expect(source).toContain('image.loading = "eager"'); + expect(source).toContain('fetchPriority={item.page === currentPage ? "high" : "auto"}'); + expect(source).toContain('loading={item.page === currentPage ? "eager" : "lazy"}'); expect(source).toContain("queueImageSize"); expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true"); });