"use client"; import { KomgaBook } from "@/types/komga"; import { ChevronLeft, ChevronRight, ImageOff, Loader2, LayoutTemplate, SplitSquareVertical, ChevronUp, } from "lucide-react"; import Image from "next/image"; import { useEffect, useState, useCallback, useRef } from "react"; interface PageCache { [pageNumber: number]: { blob: Blob; url: string; timestamp: number; }; } interface BookReaderProps { book: KomgaBook; pages: number[]; onClose?: () => void; } export function BookReader({ book, pages, onClose }: BookReaderProps) { const [currentPage, setCurrentPage] = useState(book.readProgress?.page || 1); const [isLoading, setIsLoading] = useState(true); const [imageError, setImageError] = useState(false); const [isDoublePage, setIsDoublePage] = useState(false); const [showNavigation, setShowNavigation] = useState(false); const pageCache = useRef({}); // Ajout d'un état pour le chargement des miniatures const [loadedThumbnails, setLoadedThumbnails] = useState<{ [key: number]: boolean }>({}); // Ajout d'un état pour les miniatures visibles const [visibleThumbnails, setVisibleThumbnails] = useState([]); const thumbnailObserver = useRef(null); const thumbnailRefs = useRef<{ [key: number]: HTMLButtonElement | null }>({}); // Effet pour synchroniser la progression initiale useEffect(() => { if (book.readProgress?.page) { syncReadProgress(book.readProgress.page); } }, []); // Fonction pour synchroniser la progression const syncReadProgress = useCallback( async (page: number) => { try { const completed = page === pages.length; await fetch(`/api/komga/books/${book.id}/read-progress`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ page, completed }), }); } catch (error) { console.error("Erreur lors de la synchronisation de la progression:", error); } }, [book.id, pages.length] ); // Fonction pour déterminer si on doit afficher une ou deux pages const shouldShowDoublePage = useCallback( (pageNumber: number) => { if (!isDoublePage) return false; // Toujours afficher la première page seule (couverture) if (pageNumber === 1) return false; // Vérifier si on a une page suivante disponible return pageNumber < pages.length; }, [isDoublePage, pages.length] ); const handlePreviousPage = useCallback(() => { if (currentPage > 1) { // En mode double page, reculer de 2 pages sauf si on est sur la page 2 const newPage = isDoublePage && currentPage > 2 ? currentPage - 2 : currentPage - 1; setCurrentPage(newPage); setIsLoading(true); setImageError(false); syncReadProgress(newPage); } }, [currentPage, isDoublePage, syncReadProgress]); const handleNextPage = useCallback(() => { if (currentPage < pages.length) { // En mode double page, avancer de 2 pages sauf si c'est la dernière paire const newPage = isDoublePage ? Math.min(currentPage + 2, pages.length) : currentPage + 1; setCurrentPage(newPage); setIsLoading(true); setImageError(false); syncReadProgress(newPage); } }, [currentPage, pages.length, isDoublePage, syncReadProgress]); // Fonction pour précharger une page const preloadPage = useCallback( async (pageNumber: number) => { if (pageNumber > pages.length || pageNumber < 1 || pageCache.current[pageNumber]) return; try { const response = await fetch(`/api/komga/books/${book.id}/pages/${pageNumber}`); const blob = await response.blob(); const url = URL.createObjectURL(blob); pageCache.current[pageNumber] = { blob, url, timestamp: Date.now(), }; } catch (error) { console.error(`Erreur lors du préchargement de la page ${pageNumber}:`, error); } }, [book.id, pages.length] ); // Fonction pour précharger les prochaines pages const preloadNextPages = useCallback( async (currentPageNumber: number, count: number = 2) => { const pagesToPreload = Array.from( { length: count }, (_, i) => currentPageNumber + i + 1 ).filter((page) => page <= pages.length); await Promise.all(pagesToPreload.map(preloadPage)); }, [pages.length, preloadPage] ); // Nettoyer le cache des pages trop anciennes const cleanCache = useCallback( (currentPageNumber: number, maxDistance: number = 5) => { const minPage = Math.max(1, currentPageNumber - maxDistance); const maxPage = Math.min(pages.length, currentPageNumber + maxDistance); Object.entries(pageCache.current).forEach(([pageNum, cache]) => { const page = parseInt(pageNum); if (page < minPage || page > maxPage) { URL.revokeObjectURL(cache.url); delete pageCache.current[page]; } }); }, [pages.length] ); // Fonction pour obtenir l'URL d'une page const getPageUrl = useCallback( (pageNumber: number) => { return `/api/komga/images/books/${book.id}/pages/${pageNumber}`; }, [book.id] ); // Fonction pour obtenir l'URL d'une miniature const getThumbnailUrl = useCallback( (pageNumber: number) => { return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail`; }, [book.id] ); // Effet pour précharger les pages au changement de page useEffect(() => { preloadNextPages(currentPage); cleanCache(currentPage); }, [currentPage, preloadNextPages, cleanCache]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowLeft") { handlePreviousPage(); } else if (event.key === "ArrowRight") { handleNextPage(); } else if (event.key === "Escape" && onClose) { onClose(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handlePreviousPage, handleNextPage, onClose]); // Fonction pour marquer une miniature comme chargée const handleThumbnailLoad = (pageNumber: number) => { setLoadedThumbnails((prev) => ({ ...prev, [pageNumber]: true })); }; // Fonction pour scroller jusqu'à la miniature active const scrollToActiveThumbnail = useCallback(() => { const container = document.getElementById("thumbnails-container"); const activeThumbnail = document.getElementById(`thumbnail-${currentPage}`); if (container && activeThumbnail) { const containerWidth = container.clientWidth; const thumbnailLeft = activeThumbnail.offsetLeft; const thumbnailWidth = activeThumbnail.clientWidth; // Centrer la miniature dans le conteneur container.scrollLeft = thumbnailLeft - containerWidth / 2 + thumbnailWidth / 2; } }, [currentPage]); // Effet pour scroller jusqu'à la miniature active au chargement et au changement de page useEffect(() => { if (showNavigation) { scrollToActiveThumbnail(); } }, [currentPage, showNavigation, scrollToActiveThumbnail]); // Effet pour scroller jusqu'à la miniature active quand la navigation devient visible useEffect(() => { if (showNavigation) { // Petit délai pour laisser le temps à la barre de s'afficher const timer = setTimeout(() => { scrollToActiveThumbnail(); }, 100); return () => clearTimeout(timer); } }, [showNavigation, scrollToActiveThumbnail]); // Fonction pour calculer les miniatures à afficher autour de la page courante const updateVisibleThumbnails = useCallback(() => { const windowSize = 20; // Nombre de miniatures à charger de chaque côté const start = Math.max(1, currentPage - windowSize); const end = Math.min(pages.length, currentPage + windowSize); const visibleRange = Array.from({ length: end - start + 1 }, (_, i) => start + i); setVisibleThumbnails(visibleRange); }, [currentPage, pages.length]); // Effet pour mettre à jour les miniatures visibles lors du changement de page useEffect(() => { updateVisibleThumbnails(); }, [currentPage, updateVisibleThumbnails]); // Fonction pour observer les miniatures const observeThumbnail = useCallback( (pageNumber: number) => { if (!thumbnailRefs.current[pageNumber]) return; if (!thumbnailObserver.current) { thumbnailObserver.current = new IntersectionObserver( (entries) => { entries.forEach((entry) => { const pageNumber = parseInt(entry.target.getAttribute("data-page") || "0"); if (entry.isIntersecting && !loadedThumbnails[pageNumber]) { // Charger la miniature uniquement si elle devient visible setLoadedThumbnails((prev) => ({ ...prev, [pageNumber]: false })); } }); }, { root: document.getElementById("thumbnails-container"), rootMargin: "50px", threshold: 0.1, } ); } thumbnailObserver.current.observe(thumbnailRefs.current[pageNumber]); }, [loadedThumbnails] ); // Nettoyer l'observer useEffect(() => { return () => { if (thumbnailObserver.current) { thumbnailObserver.current.disconnect(); } }; }, []); return (
{/* Contenu principal */}
{/* Boutons en haut */}
{/* Bouton précédent */} {currentPage > 1 && ( )} {/* Pages */}
{/* Page courante */}
{isLoading && (
)} {!imageError ? ( {`Page setIsLoading(false)} onError={() => { setIsLoading(false); setImageError(true); }} /> ) : (
)}
{/* Deuxième page en mode double page */} {shouldShowDoublePage(currentPage) && (
{`Page setImageError(true)} />
)}
{/* Bouton suivant */} {currentPage < pages.length && ( )} {/* Bouton fermer */} {onClose && ( )}
{/* Barre de navigation des pages */}
{showNavigation && ( <>
{pages.map((_, index) => { const pageNumber = index + 1; const isVisible = visibleThumbnails.includes(pageNumber); return ( ); })}
{/* Indicateur de page */}
Page {currentPage} {shouldShowDoublePage(currentPage) ? `-${currentPage + 1}` : ""} / {pages.length}
)}
); }