feat: enhance image loading in PhotoswipeReader with prefetching capabilities for current and next book pages

This commit is contained in:
Julien Froidefond
2025-10-22 21:25:34 +02:00
parent 0ba027b625
commit 07c6bae2c4
2 changed files with 158 additions and 32 deletions

View File

@@ -27,7 +27,12 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const { direction, toggleDirection, isRTL } = useReadingDirection(); const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen(); const { isFullscreen, toggleFullscreen } = useFullscreen();
const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode(); const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode();
const { loadedImages, imageBlobUrls, loadImageDimensions, handleForceReload, getPageUrl } = useImageLoader(book.id, pages); const { loadedImages, imageBlobUrls, prefetchPages, prefetchNextBook, handleForceReload, getPageUrl, prefetchCount } = useImageLoader({
bookId: book.id,
pages,
prefetchCount: 5,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null
});
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({ const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({
book, book,
pages, pages,
@@ -59,20 +64,22 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
}, []); }, []);
// Preload current and next pages dimensions // Prefetch current and next pages
useEffect(() => { useEffect(() => {
loadImageDimensions(currentPage); // Prefetch pages starting from current page
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length)) { prefetchPages(currentPage, prefetchCount);
loadImageDimensions(currentPage + 1);
// If double page mode, also prefetch additional pages for smooth double page navigation
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length) && currentPage + prefetchCount < pages.length) {
prefetchPages(currentPage + prefetchCount, 1);
} }
// Preload next
if (currentPage < pages.length) { // If we're near the end of the book, prefetch the next book
loadImageDimensions(currentPage + 1); const pagesFromEnd = pages.length - currentPage;
if (isDoublePage && currentPage + 1 < pages.length) { if (pagesFromEnd <= prefetchCount && nextBook) {
loadImageDimensions(currentPage + 2); prefetchNextBook(prefetchCount);
}
} }
}, [currentPage, isDoublePage, shouldShowDoublePage, loadImageDimensions, pages.length]); }, [currentPage, isDoublePage, shouldShowDoublePage, prefetchPages, prefetchNextBook, prefetchCount, pages.length, nextBook]);
// Keyboard events // Keyboard events
useEffect(() => { useEffect(() => {

View File

@@ -5,32 +5,148 @@ interface ImageDimensions {
height: number; height: number;
} }
export function useImageLoader(bookId: string, _pages: number[]) { type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1"
const [loadedImages, setLoadedImages] = useState<Record<number, ImageDimensions>>({});
const [imageBlobUrls, setImageBlobUrls] = useState<Record<number, string>>({});
const loadedImagesRef = useRef(loadedImages);
// Keep ref in sync with state interface UseImageLoaderProps {
bookId: string;
pages: number[];
prefetchCount?: number; // Nombre de pages à précharger (défaut: 2)
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
}
export function useImageLoader({ bookId, pages: _pages, prefetchCount = 2, nextBook }: UseImageLoaderProps) {
const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({});
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
const loadedImagesRef = useRef(loadedImages);
const imageBlobUrlsRef = useRef(imageBlobUrls);
// Keep refs in sync with state
useEffect(() => { useEffect(() => {
loadedImagesRef.current = loadedImages; loadedImagesRef.current = loadedImages;
}, [loadedImages]); }, [loadedImages]);
useEffect(() => {
imageBlobUrlsRef.current = imageBlobUrls;
}, [imageBlobUrls]);
const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId]); const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId]);
// Load image dimensions // Prefetch image and store dimensions
const loadImageDimensions = useCallback((pageNum: number) => { const prefetchImage = useCallback(async (pageNum: number) => {
if (loadedImagesRef.current[pageNum]) return; // Check if we already have both dimensions and blob URL
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const img = new Image(); if (hasDimensions && hasBlobUrl) {
img.onload = () => { return;
setLoadedImages(prev => ({ }
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight } try {
})); const response = await fetch(getPageUrl(pageNum));
}; if (!response.ok) {
img.src = getPageUrl(pageNum); return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages(prev => ({
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
...prev,
[pageNum]: blobUrl
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
}
}, [getPageUrl]); }, [getPageUrl]);
// Prefetch multiple pages starting from a given page
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = startPage + i;
if (pageNum <= _pages.length) {
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
// Prefetch if we don't have both dimensions AND blob URL
if (!hasDimensions || !hasBlobUrl) {
pagesToPrefetch.push(pageNum);
}
}
}
// Prefetch all pages in parallel
if (pagesToPrefetch.length > 0) {
await Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum)));
}
}, [prefetchImage, prefetchCount, _pages.length]);
// Prefetch pages from next book
const prefetchNextBook = useCallback(async (count: number = prefetchCount) => {
if (!nextBook) {
return;
}
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = i + 1; // Pages du livre suivant commencent à 1
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
const nextBookPageKey = `next-${pageNum}`;
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
if (!hasDimensions || !hasBlobUrl) {
pagesToPrefetch.push({ pageNum, nextBookPageKey });
}
}
// Prefetch all pages in parallel
if (pagesToPrefetch.length > 0) {
await Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`);
if (!response.ok) {
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages(prev => ({
...prev,
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
...prev,
[nextBookPageKey]: blobUrl
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
}
}));
}
}, [nextBook, prefetchCount]);
// Force reload handler // Force reload handler
const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => { const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => {
// Révoquer les anciennes URLs blob // Révoquer les anciennes URLs blob
@@ -89,20 +205,23 @@ export function useImageLoader(bookId: string, _pages: number[]) {
} }
}, [imageBlobUrls, getPageUrl]); }, [imageBlobUrls, getPageUrl]);
// Cleanup blob URLs on unmount // Cleanup blob URLs on unmount only
useEffect(() => { useEffect(() => {
return () => { return () => {
Object.values(imageBlobUrls).forEach(url => { Object.values(imageBlobUrlsRef.current).forEach(url => {
if (url) URL.revokeObjectURL(url); if (url) URL.revokeObjectURL(url);
}); });
}; };
}, [imageBlobUrls]); }, []); // Empty dependency array - only cleanup on unmount
return { return {
loadedImages, loadedImages,
imageBlobUrls, imageBlobUrls,
loadImageDimensions, prefetchImage,
prefetchPages,
prefetchNextBook,
handleForceReload, handleForceReload,
getPageUrl, getPageUrl,
prefetchCount,
}; };
} }