fix(web): lecteur mobile — précharger les pages CBZ verticales en profondeur via un préchargeur concurrent (batch RAF des tailles d'images, tracking resynchronisé au scroll) pour éliminer les black screens sur les pages lointaines (3e correctif bug #42)
This commit is contained in:
@ -11,6 +11,7 @@ const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
|
|||||||
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
|
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
|
||||||
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
|
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
|
||||||
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
|
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
|
||||||
|
const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 24;
|
||||||
|
|
||||||
export function CbzReader({
|
export function CbzReader({
|
||||||
bookId,
|
bookId,
|
||||||
@ -39,6 +40,9 @@ export function CbzReader({
|
|||||||
const verticalInitialSyncDoneRef = useRef(false);
|
const verticalInitialSyncDoneRef = useRef(false);
|
||||||
const verticalAnchorAttemptRef = useRef(0);
|
const verticalAnchorAttemptRef = useRef(0);
|
||||||
const verticalAnchorStableFramesRef = useRef(0);
|
const verticalAnchorStableFramesRef = useRef(0);
|
||||||
|
const preloadedImagesRef = useRef(new Map<number, HTMLImageElement>());
|
||||||
|
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
|
||||||
|
const imageSizeFlushFrameRef = useRef<number | null>(null);
|
||||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||||
const [documentError, setDocumentError] = useState<string>();
|
const [documentError, setDocumentError] = useState<string>();
|
||||||
const [pageError, setPageError] = useState<string>();
|
const [pageError, setPageError] = useState<string>();
|
||||||
@ -91,6 +95,27 @@ export function CbzReader({
|
|||||||
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
|
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
|
||||||
verticalInitialSyncFrameRef.current = null;
|
verticalInitialSyncFrameRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
|
||||||
|
pendingImageSizesRef.current[pageNumber] = size;
|
||||||
|
if (imageSizeFlushFrameRef.current !== null) return;
|
||||||
|
imageSizeFlushFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
imageSizeFlushFrameRef.current = null;
|
||||||
|
const pending = pendingImageSizesRef.current;
|
||||||
|
pendingImageSizesRef.current = {};
|
||||||
|
setImageSizes((current) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...current };
|
||||||
|
for (const [pageKey, nextSize] of Object.entries(pending)) {
|
||||||
|
const pageNumber = Number(pageKey);
|
||||||
|
const currentSize = current[pageNumber];
|
||||||
|
if (currentSize?.width === nextSize.width && currentSize.height === nextSize.height) continue;
|
||||||
|
next[pageNumber] = nextSize;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? next : current;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
const commitVisiblePage = useCallback(
|
const commitVisiblePage = useCallback(
|
||||||
(stage: HTMLElement, allowInitialCommit = false) => {
|
(stage: HTMLElement, allowInitialCommit = false) => {
|
||||||
if (!pages || !verticalTrackingReadyRef.current) return;
|
if (!pages || !verticalTrackingReadyRef.current) return;
|
||||||
@ -192,8 +217,49 @@ export function CbzReader({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageSizes({});
|
setImageSizes({});
|
||||||
|
pendingImageSizesRef.current = {};
|
||||||
|
preloadedImagesRef.current.clear();
|
||||||
}, [bookId, retryAttempt]);
|
}, [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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = frameRef.current;
|
const frame = frameRef.current;
|
||||||
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
||||||
@ -275,6 +341,7 @@ export function CbzReader({
|
|||||||
commitVisiblePage(stage);
|
commitVisiblePage(stage);
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
|
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
|
||||||
if (frameId) return;
|
if (frameId) return;
|
||||||
frameId = requestAnimationFrame(updateVisiblePage);
|
frameId = requestAnimationFrame(updateVisiblePage);
|
||||||
};
|
};
|
||||||
@ -297,6 +364,12 @@ export function CbzReader({
|
|||||||
|
|
||||||
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
|
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
|
||||||
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (imageSizeFlushFrameRef.current !== null) cancelAnimationFrame(imageSizeFlushFrameRef.current);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
if (documentError) {
|
if (documentError) {
|
||||||
return (
|
return (
|
||||||
@ -352,10 +425,7 @@ export function CbzReader({
|
|||||||
style={verticalImageStyle(item.page)}
|
style={verticalImageStyle(item.page)}
|
||||||
onLoad={(event) => {
|
onLoad={(event) => {
|
||||||
const { naturalWidth, naturalHeight } = event.currentTarget;
|
const { naturalWidth, naturalHeight } = event.currentTarget;
|
||||||
setImageSizes((current) => ({
|
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
|
||||||
...current,
|
|
||||||
[item.page]: { width: naturalWidth, height: naturalHeight }
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
onError={() => setPageError(`Page ${item.page} indisponible.`)}
|
onError={() => setPageError(`Page ${item.page} indisponible.`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -222,6 +222,16 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}');
|
expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preloads deep CBZ vertical pages without relying only on browser lazy loading", () => {
|
||||||
|
const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY");
|
||||||
|
expect(source).toContain("new Image()");
|
||||||
|
expect(source).toContain('image.loading = "eager"');
|
||||||
|
expect(source).toContain("queueImageSize");
|
||||||
|
expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps unloaded PDF and CBZ vertical pages visually covered by a page-sized fallback", () => {
|
it("keeps unloaded PDF and CBZ vertical pages visually covered by a page-sized fallback", () => {
|
||||||
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user