import { useState, useCallback, useRef, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import type { ChapterData } from '../types'; type LoadState = 'idle' | 'loading' | 'success' | 'error'; interface ChapterReadyPayload { reqId: number; data: ChapterData; href?: string; } interface ChapterErrorPayload { reqId: number; error: string; } interface ChapterProgressPayload { reqId: number; status: string; } export function useChapter() { const [chapter, setChapter] = useState(null); const [currentUrl, setCurrentUrl] = useState(''); const [loadState, setLoadState] = useState('idle'); const [error, setError] = useState(null); const [needsAttention, setNeedsAttention] = useState(false); 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(''); useEffect(() => { 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; handleChapterData(reqId, data, href, false); }); const unlistenReady = listen('chapter-ready', (event) => { const { reqId, data, href } = event.payload; 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) { setIsNavigating(false); setNavError(event.payload.error); } else { setError(event.payload.error); setLoadState('error'); } }); const unlistenProgress = listen('chapter-progress', (event) => { if (event.payload.reqId !== latestReqId.current) return; if (event.payload.status === 'needs-attention') { setNeedsAttention(true); } }); return () => { unlistenUpdate.then((fn) => fn()); unlistenReady.then((fn) => fn()); unlistenError.then((fn) => fn()); unlistenProgress.then((fn) => fn()); }; }, [fetchImage, prefetchNextChapter]); const loadChapter = useCallback(async (url: string) => { if (!url.trim()) return; const reqId = ++reqIdRef.current; latestReqId.current = reqId; currentUrlRef.current = url; setNeedsAttention(false); setNavError(null); if (hasChapterRef.current) { setIsNavigating(true); } else { setLoadState('loading'); setError(null); } try { await invoke('open_chapter', { url, reqId }); } catch (err) { if (reqId !== latestReqId.current) return; if (hasChapterRef.current) { setIsNavigating(false); setNavError(String(err)); } else { setError(String(err)); setLoadState('error'); } } }, []); const navigateTo = useCallback(async (url: string | null) => { if (!url) return; 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; } await loadChapter(url); }, [loadChapter, fetchImage, prefetchNextChapter]); return { chapter, currentUrl, loadState, error, needsAttention, isNavigating, navError, loadChapter, fetchImage, navigateTo, }; }