You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

226 lines
6.9 KiB
TypeScript

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<ChapterData | null>(null);
const [currentUrl, setCurrentUrl] = useState<string>('');
const [loadState, setLoadState] = useState<LoadState>('idle');
const [error, setError] = useState<string | null>(null);
const [needsAttention, setNeedsAttention] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
const [navError, setNavError] = useState<string | null>(null);
// Cache of loaded image data URIs
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
const reqIdRef = 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.
const hasChapterRef = useRef(false);
const currentUrlRef = useRef('');
useEffect(() => {
hasChapterRef.current = chapter !== null;
}, [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(() => {
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<ChapterReadyPayload>('chapter-update', (event) => {
const { reqId, data, href } = event.payload;
handleChapterData(reqId, data, href, false);
});
const unlistenReady = listen<ChapterReadyPayload>('chapter-ready', (event) => {
const { reqId, data, href } = event.payload;
handleChapterData(reqId, data, href, true);
});
const unlistenError = listen<ChapterErrorPayload>('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<ChapterProgressPayload>('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,
};
}