feat: background next chapter pre-harvest & instant image preloading

main
I Luk Kim 3 weeks ago
parent 4afbeb2331
commit 7a755f684f

@ -27,17 +27,19 @@ export function useChapter() {
const [loadState, setLoadState] = useState<LoadState>('idle'); const [loadState, setLoadState] = useState<LoadState>('idle');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [needsAttention, setNeedsAttention] = useState(false); 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 [isNavigating, setIsNavigating] = useState(false);
const [navError, setNavError] = useState<string | null>(null); const [navError, setNavError] = useState<string | null>(null);
// Cache of loaded image data URIs // Cache of loaded image data URIs
const imageCache = useRef<Map<string, string>>(new Map()); const imageCache = useRef<Map<string, string>>(new Map());
// Cache of harvested chapter metadata by URL
const chapterCache = useRef<Map<string, ChapterData>>(new Map());
// Monotonic request id: guards against stale responses from superseded loads // Monotonic request id: guards against stale responses from superseded loads
const reqIdRef = useRef(0); const reqIdRef = useRef(0);
const latestReqId = useRef(0); const latestReqId = useRef(0);
const prefetchingUrlRef = useRef<string | null>(null);
const prefetchingReqIdRef = useRef<number | null>(null);
// Mirrors `chapter !== null` for use inside the event listener closures below. // Mirrors `chapter !== null` for use inside the event listener closures below.
const hasChapterRef = useRef(false); const hasChapterRef = useRef(false);
const currentUrlRef = useRef(''); const currentUrlRef = useRef('');
@ -46,34 +48,85 @@ export function useChapter() {
hasChapterRef.current = chapter !== null; hasChapterRef.current = chapter !== null;
}, [chapter]); }, [chapter]);
const fetchImage = useCallback(async (imageUrl: string, referer: string): Promise<string> => {
const cached = imageCache.current.get(imageUrl);
if (cached) return cached;
try {
const dataUri = await invoke<string>('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(() => { useEffect(() => {
const unlistenUpdate = listen<ChapterReadyPayload>('chapter-update', (event) => { const handleChapterData = (reqId: number, data: ChapterData, href?: string, isReady = false) => {
const { reqId, data, href } = event.payload; const urlKey = href ?? currentUrlRef.current;
if (reqId !== latestReqId.current) return; // stale if (data && data.images && data.images.length > 0) {
chapterCache.current.set(urlKey, data);
}
if (reqId === latestReqId.current) {
setChapter(data); setChapter(data);
setCurrentUrl(href ?? currentUrlRef.current); if (href) {
// Change to success state so it shows up in MangaViewer immediately setCurrentUrl(href);
currentUrlRef.current = href;
}
setLoadState('success'); setLoadState('success');
setNeedsAttention(false); setNeedsAttention(false);
setNavError(null); 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<ChapterReadyPayload>('chapter-update', (event) => {
const { reqId, data, href } = event.payload;
handleChapterData(reqId, data, href, false);
}); });
const unlistenReady = listen<ChapterReadyPayload>('chapter-ready', (event) => { const unlistenReady = listen<ChapterReadyPayload>('chapter-ready', (event) => {
const { reqId, data, href } = event.payload; const { reqId, data, href } = event.payload;
if (reqId !== latestReqId.current) return; // stale handleChapterData(reqId, data, href, true);
setChapter(data);
setCurrentUrl(href ?? currentUrlRef.current);
setLoadState('success');
setNeedsAttention(false);
setIsNavigating(false);
setNavError(null);
}); });
const unlistenError = listen<ChapterErrorPayload>('chapter-error', (event) => { const unlistenError = listen<ChapterErrorPayload>('chapter-error', (event) => {
if (event.payload.reqId === prefetchingReqIdRef.current) {
prefetchingUrlRef.current = null;
return;
}
if (event.payload.reqId !== latestReqId.current) return; if (event.payload.reqId !== latestReqId.current) return;
setNeedsAttention(false); setNeedsAttention(false);
if (hasChapterRef.current) { if (hasChapterRef.current) {
// Keep showing the current chapter; surface the failure as a toast.
setIsNavigating(false); setIsNavigating(false);
setNavError(event.payload.error); setNavError(event.payload.error);
} else { } else {
@ -95,7 +148,7 @@ export function useChapter() {
unlistenError.then((fn) => fn()); unlistenError.then((fn) => fn());
unlistenProgress.then((fn) => fn()); unlistenProgress.then((fn) => fn());
}; };
}, []); }, [fetchImage, prefetchNextChapter]);
const loadChapter = useCallback(async (url: string) => { const loadChapter = useCallback(async (url: string) => {
if (!url.trim()) return; if (!url.trim()) return;
@ -126,24 +179,36 @@ export function useChapter() {
} }
}, []); }, []);
const fetchImage = useCallback(async (imageUrl: string, referer: string): Promise<string> => { const navigateTo = useCallback(async (url: string | null) => {
const cached = imageCache.current.get(imageUrl); if (!url) return;
if (cached) return cached;
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 { try {
const dataUri = await invoke<string>('fetch_image', { url: imageUrl, referer }); const origin = new URL(url).origin;
imageCache.current.set(imageUrl, dataUri); cached.images.forEach((imgUrl) => {
return dataUri; fetchImage(imgUrl, origin).catch(() => {});
} catch { });
return imageUrl; // Fallback: try direct URL } 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); await loadChapter(url);
}, [loadChapter]); }, [loadChapter, fetchImage, prefetchNextChapter]);
return { return {
chapter, chapter,

Loading…
Cancel
Save