|
|
|
@ -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,
|
|
|
|
|