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 { isFullscreen, toggleFullscreen } = useFullscreen();
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({
book,
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(() => {
loadImageDimensions(currentPage);
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length)) {
loadImageDimensions(currentPage + 1);
// Prefetch pages starting from current page
prefetchPages(currentPage, prefetchCount);
// 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) {
loadImageDimensions(currentPage + 1);
if (isDoublePage && currentPage + 1 < pages.length) {
loadImageDimensions(currentPage + 2);
// If we're near the end of the book, prefetch the next book
const pagesFromEnd = pages.length - currentPage;
if (pagesFromEnd <= prefetchCount && nextBook) {
prefetchNextBook(prefetchCount);
}
}
}, [currentPage, isDoublePage, shouldShowDoublePage, loadImageDimensions, pages.length]);
}, [currentPage, isDoublePage, shouldShowDoublePage, prefetchPages, prefetchNextBook, prefetchCount, pages.length, nextBook]);
// Keyboard events
useEffect(() => {

View File

@@ -5,32 +5,148 @@ interface ImageDimensions {
height: number;
}
export function useImageLoader(bookId: string, _pages: number[]) {
const [loadedImages, setLoadedImages] = useState<Record<number, ImageDimensions>>({});
const [imageBlobUrls, setImageBlobUrls] = useState<Record<number, string>>({});
const loadedImagesRef = useRef(loadedImages);
type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1"
// 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(() => {
loadedImagesRef.current = loadedImages;
}, [loadedImages]);
useEffect(() => {
imageBlobUrlsRef.current = imageBlobUrls;
}, [imageBlobUrls]);
const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId]);
// Load image dimensions
const loadImageDimensions = useCallback((pageNum: number) => {
if (loadedImagesRef.current[pageNum]) return;
// Prefetch image and store dimensions
const prefetchImage = useCallback(async (pageNum: number) => {
// Check if we already have both dimensions and blob URL
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
if (hasDimensions && hasBlobUrl) {
return;
}
try {
const response = await fetch(getPageUrl(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,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
...prev,
[pageNum]: blobUrl
}));
};
img.src = getPageUrl(pageNum);
img.src = blobUrl;
} catch {
// Silently fail prefetch
}
}, [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
const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => {
// Révoquer les anciennes URLs blob
@@ -89,20 +205,23 @@ export function useImageLoader(bookId: string, _pages: number[]) {
}
}, [imageBlobUrls, getPageUrl]);
// Cleanup blob URLs on unmount
// Cleanup blob URLs on unmount only
useEffect(() => {
return () => {
Object.values(imageBlobUrls).forEach(url => {
Object.values(imageBlobUrlsRef.current).forEach(url => {
if (url) URL.revokeObjectURL(url);
});
};
}, [imageBlobUrls]);
}, []); // Empty dependency array - only cleanup on unmount
return {
loadedImages,
imageBlobUrls,
loadImageDimensions,
prefetchImage,
prefetchPages,
prefetchNextBook,
handleForceReload,
getPageUrl,
prefetchCount,
};
}