From 7a755f684fe9c0e4f9c630e731f365f0a2e23b4c Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Wed, 22 Jul 2026 18:11:32 +0900 Subject: [PATCH] feat: background next chapter pre-harvest & instant image preloading --- src/hooks/useChapter.ts | 131 ++++++++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 33 deletions(-) diff --git a/src/hooks/useChapter.ts b/src/hooks/useChapter.ts index b5497e6..fc66179 100644 --- a/src/hooks/useChapter.ts +++ b/src/hooks/useChapter.ts @@ -27,17 +27,19 @@ export function useChapter() { const [loadState, setLoadState] = useState('idle'); const [error, setError] = useState(null); const [needsAttention, setNeedsAttention] = useState(false); - // True while re-loading a chapter (e.g. prev/next) when one is already on - // screen — kept separate from `loadState` so a slow/failed reload doesn't - // yank the reader back to the URL input screen. const [isNavigating, setIsNavigating] = useState(false); const [navError, setNavError] = useState(null); // Cache of loaded image data URIs const imageCache = useRef>(new Map()); + // Cache of harvested chapter metadata by URL + const chapterCache = useRef>(new Map()); // Monotonic request id: guards against stale responses from superseded loads const reqIdRef = useRef(0); const latestReqId = useRef(0); + const prefetchingUrlRef = useRef(null); + const prefetchingReqIdRef = useRef(null); + // Mirrors `chapter !== null` for use inside the event listener closures below. const hasChapterRef = useRef(false); const currentUrlRef = useRef(''); @@ -46,34 +48,85 @@ export function useChapter() { hasChapterRef.current = chapter !== null; }, [chapter]); + const fetchImage = useCallback(async (imageUrl: string, referer: string): Promise => { + const cached = imageCache.current.get(imageUrl); + if (cached) return cached; + + try { + const dataUri = await invoke('fetch_image', { url: imageUrl, referer }); + imageCache.current.set(imageUrl, dataUri); + return dataUri; + } catch { + return imageUrl; // Fallback: try direct URL + } + }, []); + + const prefetchNextChapter = useCallback((nextUrl: string) => { + if (!nextUrl || chapterCache.current.has(nextUrl) || prefetchingUrlRef.current === nextUrl) return; + prefetchingUrlRef.current = nextUrl; + const bgReqId = ++reqIdRef.current; + prefetchingReqIdRef.current = bgReqId; + + invoke('open_chapter', { url: nextUrl, reqId: bgReqId }).catch(() => { + if (prefetchingUrlRef.current === nextUrl) { + prefetchingUrlRef.current = null; + } + }); + }, []); + useEffect(() => { + const handleChapterData = (reqId: number, data: ChapterData, href?: string, isReady = false) => { + const urlKey = href ?? currentUrlRef.current; + if (data && data.images && data.images.length > 0) { + chapterCache.current.set(urlKey, data); + } + + if (reqId === latestReqId.current) { + setChapter(data); + if (href) { + setCurrentUrl(href); + currentUrlRef.current = href; + } + setLoadState('success'); + setNeedsAttention(false); + setNavError(null); + if (isReady) setIsNavigating(false); + + // Preload next chapter once current chapter has image data + if (data.next_chapter && isReady) { + prefetchNextChapter(data.next_chapter); + } + } else if (reqId === prefetchingReqIdRef.current || (prefetchingUrlRef.current && urlKey === prefetchingUrlRef.current)) { + // Background prefetch payload: pre-fetch images into imageCache + if (data && data.images && data.images.length > 0) { + try { + const origin = new URL(urlKey).origin; + data.images.forEach((imgUrl) => { + fetchImage(imgUrl, origin).catch(() => {}); + }); + } catch {} + } + } + }; + const unlistenUpdate = listen('chapter-update', (event) => { const { reqId, data, href } = event.payload; - if (reqId !== latestReqId.current) return; // stale - setChapter(data); - setCurrentUrl(href ?? currentUrlRef.current); - // Change to success state so it shows up in MangaViewer immediately - setLoadState('success'); - setNeedsAttention(false); - setNavError(null); + handleChapterData(reqId, data, href, false); }); const unlistenReady = listen('chapter-ready', (event) => { const { reqId, data, href } = event.payload; - if (reqId !== latestReqId.current) return; // stale - setChapter(data); - setCurrentUrl(href ?? currentUrlRef.current); - setLoadState('success'); - setNeedsAttention(false); - setIsNavigating(false); - setNavError(null); + handleChapterData(reqId, data, href, true); }); const unlistenError = listen('chapter-error', (event) => { + if (event.payload.reqId === prefetchingReqIdRef.current) { + prefetchingUrlRef.current = null; + return; + } if (event.payload.reqId !== latestReqId.current) return; setNeedsAttention(false); if (hasChapterRef.current) { - // Keep showing the current chapter; surface the failure as a toast. setIsNavigating(false); setNavError(event.payload.error); } else { @@ -95,7 +148,7 @@ export function useChapter() { unlistenError.then((fn) => fn()); unlistenProgress.then((fn) => fn()); }; - }, []); + }, [fetchImage, prefetchNextChapter]); const loadChapter = useCallback(async (url: string) => { if (!url.trim()) return; @@ -126,24 +179,36 @@ export function useChapter() { } }, []); - const fetchImage = useCallback(async (imageUrl: string, referer: string): Promise => { - const cached = imageCache.current.get(imageUrl); - if (cached) return cached; + const navigateTo = useCallback(async (url: string | null) => { + if (!url) return; - try { - const dataUri = await invoke('fetch_image', { url: imageUrl, referer }); - imageCache.current.set(imageUrl, dataUri); - return dataUri; - } catch { - return imageUrl; // Fallback: try direct URL + if (chapterCache.current.has(url)) { + const cached = chapterCache.current.get(url)!; + setChapter(cached); + setCurrentUrl(url); + currentUrlRef.current = url; + setLoadState('success'); + setIsNavigating(false); + setNavError(null); + setNeedsAttention(false); + + // Preload images for this cached chapter if not already in memory + try { + const origin = new URL(url).origin; + cached.images.forEach((imgUrl) => { + fetchImage(imgUrl, origin).catch(() => {}); + }); + } catch {} + + // Prefetch next chapter in background + if (cached.next_chapter) { + prefetchNextChapter(cached.next_chapter); + } + return; } - }, []); - const navigateTo = useCallback(async (url: string | null) => { - if (!url) return; - imageCache.current.clear(); await loadChapter(url); - }, [loadChapter]); + }, [loadChapter, fetchImage, prefetchNextChapter]); return { chapter,