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
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,
|
|
};
|
|
}
|