diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index d50ff92..7f39aef 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -129,6 +129,7 @@ const DEFAULT_TTS_SETTINGS: TtsSettings = { autoPlayNext: true, }; const SAVE_INTERVAL_MS = 10000; +const CHAPTER_SAVE_INTERVAL_MS = 20000; const TTS_RATE_STEPS = [-20, -10, 0, 10, 20, 35]; const TTS_PITCH_STEPS = [-10, 0, 10, 20]; const TTS_VOLUME_STEPS = [-10, 0, 10, 20]; @@ -310,6 +311,21 @@ function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, curre return ratio * maxCurrent; } +function encodeChapterScrollLocator(href: string, scrollTop: number, scrollHeight: number, clientHeight: number) { + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const ratio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; + return `${href}#scroll=${ratio.toFixed(6)}`; +} + +function parseChapterScrollLocator(value?: string) { + if (!value) return null; + const match = value.match(/(?:^|[#&?])scroll=([0-9.]+)/); + if (!match) return null; + const ratio = Number(match[1]); + if (!Number.isFinite(ratio)) return null; + return Math.max(0, Math.min(1, ratio)); +} + function flattenToc(items: TocItem[]): TocItem[] { return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]); } @@ -415,6 +431,12 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const [ttsSeekValue, setTtsSeekValue] = useState(0); const [ttsSeeking, setTtsSeeking] = useState(false); const scrollRef = useRef(null); + const restoredChapterPositionRef = useRef(false); + const chapterSaveTimerRef = useRef(null); + const pendingChapterRestoreRatioRef = useRef(null); + const currentIndexRef = useRef(0); + const lastChapterSavedAtRef = useRef(0); + const lastChapterSavedLocatorValueRef = useRef(manifest.lastRecord?.locator?.value || ''); const audioRef = useRef(null); const ttsChunksRef = useRef([]); const ttsCurrentChunkIndexRef = useRef(0); @@ -425,6 +447,8 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const currentChapterHref = chapters[currentIndex]?.href || ''; const currentChapterTitle = chapters[currentIndex]?.title || chapter?.title || ''; + useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); + useEffect(() => { setSettings(loadReaderSettings()); setTtsSettings(loadTtsSettings()); @@ -500,6 +524,61 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { return () => { cancelled = true; }; }, []); + const buildChapterReadRecord = useCallback((item: BookChapter, index: number): BookReadRecord => { + const node = scrollRef.current; + const scrollTop = node?.scrollTop || 0; + const scrollHeight = node?.scrollHeight || 0; + const clientHeight = node?.clientHeight || 0; + const chapterCount = Math.max(1, chapters.length); + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const chapterRatio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; + const progressPercent = chapters.length > 0 + ? Math.max(0, Math.min(100, ((index + chapterRatio) / chapterCount) * 100)) + : 0; + + return { + sourceId: manifest.book.sourceId, + sourceName: manifest.book.sourceName, + bookId: manifest.book.id, + title: manifest.book.title, + author: manifest.book.author, + cover: manifest.book.cover, + detailHref: manifest.book.detailHref, + acquisitionHref: manifest.acquisitionHref, + format: 'chapters', + locator: { + type: 'chapter', + value: encodeChapterScrollLocator(item.href, scrollTop, scrollHeight, clientHeight), + href: item.href, + chapterTitle: item.title, + }, + chapterTitle: item.title, + chapterHref: item.href, + progressPercent, + saveTime: Date.now(), + }; + }, [chapters.length, manifest]); + + const persistChapterProgress = useCallback((index = currentIndexRef.current) => { + const item = chapters[index]; + if (!item) return; + const record = buildChapterReadRecord(item, index); + if (record.locator.value === lastChapterSavedLocatorValueRef.current) return; + lastChapterSavedLocatorValueRef.current = record.locator.value; + lastChapterSavedAtRef.current = Date.now(); + void saveBookReadRecord(record.sourceId, record.bookId, record); + }, [buildChapterReadRecord, chapters]); + + const scheduleChapterProgressSave = useCallback(() => { + if (chapterSaveTimerRef.current) return; + const elapsed = Date.now() - lastChapterSavedAtRef.current; + const delay = Math.max(0, CHAPTER_SAVE_INTERVAL_MS - elapsed); + chapterSaveTimerRef.current = window.setTimeout(() => { + chapterSaveTimerRef.current = null; + persistChapterProgress(); + }, delay); + }, [persistChapterProgress]); + useEffect(() => { if (!manifest.chaptersUrl && !manifest.book.id) return; let cancelled = false; @@ -507,6 +586,10 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { setChaptersLoaded(false); setChapter(null); setCurrentIndex(0); + restoredChapterPositionRef.current = false; + pendingChapterRestoreRatioRef.current = null; + lastChapterSavedAtRef.current = 0; + lastChapterSavedLocatorValueRef.current = manifest.lastRecord?.locator?.value || ''; setLoading(true); setError(''); const url = manifest.chaptersUrl || `/api/books/read/chapters?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`; @@ -516,7 +599,7 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const list = (json.chapters || []) as BookChapter[]; setChapters(list); setChaptersLoaded(true); - const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value || ''; + const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value?.split('#scroll=')[0] || ''; const savedIndex = list.findIndex((item) => item.href === savedHref); setCurrentIndex(savedIndex >= 0 ? savedIndex : 0); }) @@ -534,33 +617,20 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { setLoading(true); setError(''); scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' }); + pendingChapterRestoreRatioRef.current = null; const params = new URLSearchParams({ sourceId: manifest.book.sourceId, href: item.href }); if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref); fetchJsonWithRetry(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' }) .then((json) => { + const shouldRestore = !restoredChapterPositionRef.current + && !initialChapterHref + && (manifest.lastRecord?.chapterHref === item.href || manifest.lastRecord?.locator?.href === item.href); + pendingChapterRestoreRatioRef.current = shouldRestore ? parseChapterScrollLocator(manifest.lastRecord?.locator?.value) : null; setChapter({ ...(json as BookChapterContent), title: (json as BookChapterContent).title || item.title }); - const progressPercent = chapters.length > 0 ? Math.round(((currentIndex + 1) / chapters.length) * 100) : 0; - const record: BookReadRecord = { - sourceId: manifest.book.sourceId, - sourceName: manifest.book.sourceName, - bookId: manifest.book.id, - title: manifest.book.title, - author: manifest.book.author, - cover: manifest.book.cover, - detailHref: manifest.book.detailHref, - acquisitionHref: manifest.acquisitionHref, - format: 'chapters', - locator: { type: 'chapter', value: item.href, href: item.href, chapterTitle: item.title }, - chapterTitle: item.title, - chapterHref: item.href, - progressPercent, - saveTime: Date.now(), - }; - void saveBookReadRecord(record.sourceId, record.bookId, record); }) .catch((err) => setError(err.message || '获取章节失败')) .finally(() => setLoading(false)); - }, [chapters, chaptersLoaded, currentIndex, manifest, stopTts]); + }, [chapters, chaptersLoaded, currentIndex, initialChapterHref, manifest, persistChapterProgress, stopTts]); useEffect(() => { window.dispatchEvent(new CustomEvent('books-read-update-header', { @@ -572,8 +642,14 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { })); }, [manifest, currentChapterTitle, settings.mode]); - const goPrevChapter = useCallback(() => setCurrentIndex((prev) => Math.max(0, prev - 1)), []); - const goNextChapter = useCallback(() => setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1)), [chapters.length]); + const goPrevChapter = useCallback(() => { + persistChapterProgress(); + setCurrentIndex((prev) => Math.max(0, prev - 1)); + }, [persistChapterProgress]); + const goNextChapter = useCallback(() => { + persistChapterProgress(); + setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1)); + }, [chapters.length, persistChapterProgress]); const turnPage = useCallback((direction: 1 | -1) => { const node = scrollRef.current; @@ -683,6 +759,68 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { await bootstrapTts(); }, [bootstrapTts, ttsAvailable, ttsStatus]); + useEffect(() => { + if (loading || !chapter || restoredChapterPositionRef.current) return; + const savedRatio = pendingChapterRestoreRatioRef.current; + if (savedRatio === null) { + restoredChapterPositionRef.current = true; + persistChapterProgress(); + return; + } + + let cancelled = false; + const restore = (attempt = 0) => { + if (cancelled) return; + const node = scrollRef.current; + if (!node) { + if (attempt < 20) window.setTimeout(() => restore(attempt + 1), 100); + return; + } + const maxScrollTop = Math.max(0, node.scrollHeight - node.clientHeight); + if (maxScrollTop <= 0 && attempt < 20) { + window.setTimeout(() => restore(attempt + 1), 100); + return; + } + node.scrollTo({ top: maxScrollTop * savedRatio, behavior: 'auto' }); + restoredChapterPositionRef.current = true; + pendingChapterRestoreRatioRef.current = null; + window.setTimeout(() => persistChapterProgress(), 0); + }; + + window.requestAnimationFrame(() => restore()); + return () => { + cancelled = true; + }; + }, [chapter, loading, persistChapterProgress]); + + useEffect(() => { + const node = scrollRef.current; + if (!node) return; + node.addEventListener('scroll', scheduleChapterProgressSave, { passive: true }); + return () => { + node.removeEventListener('scroll', scheduleChapterProgressSave); + if (chapterSaveTimerRef.current) { + window.clearTimeout(chapterSaveTimerRef.current); + chapterSaveTimerRef.current = null; + } + persistChapterProgress(); + }; + }, [chapter, currentChapterHref, loading, persistChapterProgress, scheduleChapterProgressSave]); + + useEffect(() => { + const flush = () => persistChapterProgress(); + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') flush(); + }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + flush(); + window.removeEventListener('pagehide', flush); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [persistChapterProgress]); + useEffect(() => { if (!audioRef.current) audioRef.current = new Audio(); const audio = audioRef.current; @@ -757,7 +895,7 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
{chapters.map((item, index) => { const active = index === currentIndex; - return ; + return ; })}