fix(web): lecteur mobile — remplacer le préchargeur CBZ vertical exhaustif (24 requêtes concurrentes sur tout le livre) par un planificateur à priorités (concurrence 3, délai 100ms, pages adjacentes + fenêtre profonde à +90, annulation par génération) et passer la page courante en fetchPriority high pour un chargement rapide des pages sur Firefox mobile (4e correctif bug #42)
This commit is contained in:
@ -6,12 +6,74 @@ import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
|
|||||||
import type { ReaderControls } from "./ReaderShell";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
|
||||||
type PageCommitStrategy = "immediate" | "queued";
|
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<number>;
|
||||||
|
queues: Record<PreloadPriority, number[]>;
|
||||||
|
};
|
||||||
|
|
||||||
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
|
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;
|
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<number>(),
|
||||||
|
queues: {
|
||||||
|
adjacent: [],
|
||||||
|
deep: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueReaderPages(pages: Array<number | null>, currentPage: number, pageCount: number) {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
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({
|
export function CbzReader({
|
||||||
bookId,
|
bookId,
|
||||||
@ -40,7 +102,8 @@ 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 preloadedImagesRef = useRef(new Map<number, PreloadedComicImage>());
|
||||||
|
const preloadSchedulerRef = useRef(createVerticalPreloadScheduler());
|
||||||
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
|
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
|
||||||
const imageSizeFlushFrameRef = useRef<number | null>(null);
|
const imageSizeFlushFrameRef = useRef<number | null>(null);
|
||||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||||
@ -98,7 +161,7 @@ export function CbzReader({
|
|||||||
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
|
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
|
||||||
pendingImageSizesRef.current[pageNumber] = size;
|
pendingImageSizesRef.current[pageNumber] = size;
|
||||||
if (imageSizeFlushFrameRef.current !== null) return;
|
if (imageSizeFlushFrameRef.current !== null) return;
|
||||||
imageSizeFlushFrameRef.current = requestAnimationFrame(() => {
|
imageSizeFlushFrameRef.current = scheduleReaderFrame(() => {
|
||||||
imageSizeFlushFrameRef.current = null;
|
imageSizeFlushFrameRef.current = null;
|
||||||
const pending = pendingImageSizesRef.current;
|
const pending = pendingImageSizesRef.current;
|
||||||
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<PreloadPriority, number[]>) => {
|
||||||
|
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(
|
const commitVisiblePage = useCallback(
|
||||||
(stage: HTMLElement, allowInitialCommit = false) => {
|
(stage: HTMLElement, allowInitialCommit = false) => {
|
||||||
if (!pages || !verticalTrackingReadyRef.current) return;
|
if (!pages || !verticalTrackingReadyRef.current) return;
|
||||||
@ -219,46 +346,16 @@ export function CbzReader({
|
|||||||
setImageSizes({});
|
setImageSizes({});
|
||||||
pendingImageSizesRef.current = {};
|
pendingImageSizesRef.current = {};
|
||||||
preloadedImagesRef.current.clear();
|
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]);
|
}, [bookId, retryAttempt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "vertical" || !pages || typeof Image === "undefined") return;
|
if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return;
|
||||||
let cancelled = false;
|
enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount));
|
||||||
let nextIndex = 0;
|
}, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]);
|
||||||
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;
|
||||||
@ -366,7 +463,7 @@ export function CbzReader({
|
|||||||
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (imageSizeFlushFrameRef.current !== null) cancelAnimationFrame(imageSizeFlushFrameRef.current);
|
if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@ -421,10 +518,12 @@ export function CbzReader({
|
|||||||
<img
|
<img
|
||||||
src={api.cbzPageUrl(bookId, item.page)}
|
src={api.cbzPageUrl(bookId, item.page)}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
loading="lazy"
|
fetchPriority={item.page === currentPage ? "high" : "auto"}
|
||||||
|
loading={item.page === currentPage ? "eager" : "lazy"}
|
||||||
style={verticalImageStyle(item.page)}
|
style={verticalImageStyle(item.page)}
|
||||||
onLoad={(event) => {
|
onLoad={(event) => {
|
||||||
const { naturalWidth, naturalHeight } = event.currentTarget;
|
const { naturalWidth, naturalHeight } = event.currentTarget;
|
||||||
|
preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" });
|
||||||
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
|
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
|
||||||
}}
|
}}
|
||||||
onError={() => setPageError(`Page ${item.page} indisponible.`)}
|
onError={() => setPageError(`Page ${item.page} indisponible.`)}
|
||||||
|
|||||||
@ -222,12 +222,20 @@ 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", () => {
|
it("keeps CBZ vertical image loading on the rendered page elements", () => {
|
||||||
const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
|
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("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("queueImageSize");
|
||||||
expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true");
|
expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user