From dc31a788fc7ee8b23f07f1cfdcd5748c26103c24 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 28 May 2026 09:52:41 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BE=8E=E5=8C=96=E7=94=B5=E5=AD=90=E4=B9=A6?= =?UTF-8?q?=E9=A6=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/books/catalog/page.tsx | 554 ++++-- src/app/books/detail/page.tsx | 323 +++- src/app/books/history/page.tsx | 478 +++-- src/app/books/page.tsx | 200 +- src/app/books/read/page.tsx | 2656 +++++++++++++++++++------- src/app/books/search/page.tsx | 665 +++++-- src/app/books/shelf/page.tsx | 113 +- src/components/books/BookCard.tsx | 56 +- src/components/books/BooksLayout.tsx | 157 +- 9 files changed, 3808 insertions(+), 1394 deletions(-) diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx index 046d07d..7cfc6a2 100644 --- a/src/app/books/catalog/page.tsx +++ b/src/app/books/catalog/page.tsx @@ -1,12 +1,26 @@ 'use client'; +import { AlertCircle } from 'lucide-react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + WheelEvent as ReactWheelEvent, +} from 'react'; + +import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types'; +import { + buildBookDetailPath, + cacheBookListItem, +} from '@/lib/book-route-cache.client'; import BookCard from '@/components/books/BookCard'; -import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client'; -import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types'; function makeHref(sourceId: string, item: BookListItem) { return buildBookDetailPath(sourceId, item.id); @@ -17,12 +31,18 @@ function CatalogSkeleton() {
{Array.from({ length: 4 }).map((_, index) => ( -
+
))}
{Array.from({ length: 5 }).map((_, index) => ( -
+
))}
@@ -66,7 +86,9 @@ export default function BooksCatalogPage() { const [selectedSourceId, setSelectedSourceId] = useState(sourceId); const [selectedHref, setSelectedHref] = useState(href); const [data, setData] = useState(null); - const [catalogNavigation, setCatalogNavigation] = useState([]); + const [catalogNavigation, setCatalogNavigation] = useState< + BookCatalogResult['navigation'] + >([]); const [entries, setEntries] = useState([]); const [nextHref, setNextHref] = useState(undefined); const [error, setError] = useState(''); @@ -79,9 +101,21 @@ export default function BooksCatalogPage() { const activeNavItemRef = useRef(null); const loadedPageHrefsRef = useRef>(new Set()); const failedPageHrefsRef = useRef>(new Set()); - const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); + const sourceDragStateRef = useRef<{ + pointerId: number; + startX: number; + startScrollLeft: number; + moved: boolean; + pointerType: string; + } | null>(null); const suppressSourceClickRef = useRef(false); - const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); + const navDragStateRef = useRef<{ + pointerId: number; + startX: number; + startScrollLeft: number; + moved: boolean; + pointerType: string; + } | null>(null); const suppressNavClickRef = useRef(false); const showImmediateContentLoading = useCallback(() => { @@ -92,12 +126,13 @@ export default function BooksCatalogPage() { }, []); useEffect(() => { - fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])); + fetch('/api/books/sources') + .then((res) => res.json()) + .then((json) => setSources(json.sources || [])); }, []); useEffect(() => { setSelectedSourceId(sourceId); - setSelectedHref(href); setCatalogNavigation([]); }, [sourceId]); @@ -106,7 +141,7 @@ export default function BooksCatalogPage() { }, [href]); useEffect(() => { - if (!sourceId || !href) return; + if (!sourceId || !href || catalogNavigation.length > 0) return; let cancelled = false; const loadRootNavigation = async () => { @@ -115,7 +150,8 @@ export default function BooksCatalogPage() { const res = await fetch(`/api/books/catalog?${params.toString()}`); const json = await res.json(); if (!res.ok) return; - if (!cancelled) setCatalogNavigation((json as BookCatalogResult).navigation || []); + if (!cancelled) + setCatalogNavigation((json as BookCatalogResult).navigation || []); } catch { // 当前分类内容仍可正常展示,根目录分类加载失败时忽略。 } @@ -125,76 +161,113 @@ export default function BooksCatalogPage() { return () => { cancelled = true; }; - }, [sourceId, href]); + }, [sourceId, href, catalogNavigation.length]); useEffect(() => { if (!sourceId || href || catalogNavigation.length === 0) return; const firstNavigationItem = catalogNavigation.find((item) => { const rel = (item.rel || '').toLowerCase(); - return item.href && rel !== 'next' && rel !== 'previous' && isMeaningfulNavTitle(item.title); + return ( + item.href && + rel !== 'next' && + rel !== 'previous' && + isMeaningfulNavTitle(item.title) + ); }); if (!firstNavigationItem?.href) return; setSelectedHref(firstNavigationItem.href); - router.replace(`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(firstNavigationItem.href)}`); + router.replace( + `/books/catalog?sourceId=${encodeURIComponent( + sourceId + )}&href=${encodeURIComponent(firstNavigationItem.href)}` + ); }, [catalogNavigation, href, router, sourceId]); - const mergeEntries = useCallback((prev: BookListItem[], next: BookListItem[]) => { - const seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`)); - const merged = [...prev]; - for (const item of next) { - const key = `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`; - if (!seen.has(key)) { - seen.add(key); - merged.push(item); + const mergeEntries = useCallback( + (prev: BookListItem[], next: BookListItem[]) => { + const seen = new Set( + prev.map( + (item) => + `${item.sourceId}::${item.id}::${ + item.detailHref || item.acquisitionLinks[0]?.href || '' + }` + ) + ); + const merged = [...prev]; + for (const item of next) { + const key = `${item.sourceId}::${item.id}::${ + item.detailHref || item.acquisitionLinks[0]?.href || '' + }`; + if (!seen.has(key)) { + seen.add(key); + merged.push(item); + } } - } - return merged; - }, []); + return merged; + }, + [] + ); - const loadCatalog = useCallback(async (targetHref?: string, append = false) => { - if (!sourceId) return; - const normalizedHref = targetHref || ''; - if (append) { - if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref) || failedPageHrefsRef.current.has(normalizedHref)) return; - setLoadingMore(true); - } else { - setError(''); - setLoadingCatalog(true); - if (!normalizedHref) setData(null); - setEntries([]); - setNextHref(undefined); - loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']); - failedPageHrefsRef.current = new Set(); - } - - try { - const params = new URLSearchParams({ sourceId }); - if (normalizedHref) params.set('href', normalizedHref); - const res = await fetch(`/api/books/catalog?${params.toString()}`); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || '获取目录失败'); - const nextData = json as BookCatalogResult; + const loadCatalog = useCallback( + async (targetHref?: string, append = false) => { + if (!sourceId) return; + const normalizedHref = targetHref || ''; if (append) { - loadedPageHrefsRef.current.add(normalizedHref); - setEntries((prev) => mergeEntries(prev, nextData.entries || [])); + if ( + !normalizedHref || + loadedPageHrefsRef.current.has(normalizedHref) || + failedPageHrefsRef.current.has(normalizedHref) + ) + return; + setLoadingMore(true); } else { - setData(nextData); - setCatalogNavigation((prev) => normalizedHref ? (prev.length > 0 ? prev : nextData.navigation || []) : nextData.navigation || []); - setEntries(nextData.entries || []); - } - setNextHref(nextData.nextHref || undefined); - if (!append) setData(nextData); - } catch (err) { - if (append && normalizedHref) { - failedPageHrefsRef.current.add(normalizedHref); + setError(''); + setLoadingCatalog(true); + if (!normalizedHref) setData(null); + setEntries([]); setNextHref(undefined); + loadedPageHrefsRef.current = new Set( + normalizedHref ? [normalizedHref] : ['__root__'] + ); + failedPageHrefsRef.current = new Set(); } - setError(err instanceof Error ? err.message : '获取目录失败'); - } finally { - if (!append) setLoadingCatalog(false); - setLoadingMore(false); - } - }, [mergeEntries, sourceId]); + + try { + const params = new URLSearchParams({ sourceId }); + if (normalizedHref) params.set('href', normalizedHref); + const res = await fetch(`/api/books/catalog?${params.toString()}`); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取目录失败'); + const nextData = json as BookCatalogResult; + if (append) { + loadedPageHrefsRef.current.add(normalizedHref); + setEntries((prev) => mergeEntries(prev, nextData.entries || [])); + } else { + setData(nextData); + setCatalogNavigation((prev) => + normalizedHref + ? prev.length > 0 + ? prev + : nextData.navigation || [] + : nextData.navigation || [] + ); + setEntries(nextData.entries || []); + } + setNextHref(nextData.nextHref || undefined); + if (!append) setData(nextData); + } catch (err) { + if (append && normalizedHref) { + failedPageHrefsRef.current.add(normalizedHref); + setNextHref(undefined); + } + setError(err instanceof Error ? err.message : '获取目录失败'); + } finally { + if (!append) setLoadingCatalog(false); + setLoadingMore(false); + } + }, + [mergeEntries, sourceId] + ); useEffect(() => { if (!sourceId) return; @@ -205,134 +278,175 @@ export default function BooksCatalogPage() { const node = loaderRef.current; if (!node || !nextHref || loadingMore || !data) return; - const observer = new IntersectionObserver((entries) => { - const entry = entries[0]; - if (entry?.isIntersecting && nextHref && !loadingMore) { - void loadCatalog(nextHref, true); - } - }, { rootMargin: '800px 0px' }); + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting && nextHref && !loadingMore) { + void loadCatalog(nextHref, true); + } + }, + { rootMargin: '800px 0px' } + ); observer.observe(node); return () => observer.disconnect(); }, [data, nextHref, loadingMore, loadCatalog]); - const handleSourcePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) return; - const node = sourceScrollerRef.current; - if (!node) return; - sourceDragStateRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startScrollLeft: node.scrollLeft, - moved: false, - pointerType: event.pointerType, - }; - suppressSourceClickRef.current = false; - if (event.pointerType !== 'mouse') { - node.setPointerCapture?.(event.pointerId); - } - }, []); + const handleSourcePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + const node = sourceScrollerRef.current; + if (!node) return; + sourceDragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: node.scrollLeft, + moved: false, + pointerType: event.pointerType, + }; + suppressSourceClickRef.current = false; + if (event.pointerType !== 'mouse') { + node.setPointerCapture?.(event.pointerId); + } + }, + [] + ); - const handleSourcePointerMove = useCallback((event: ReactPointerEvent) => { - const node = sourceScrollerRef.current; - const dragState = sourceDragStateRef.current; - if (!node || !dragState || dragState.pointerId !== event.pointerId) return; - const deltaX = event.clientX - dragState.startX; - const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; - if (Math.abs(deltaX) > moveThreshold) { - dragState.moved = true; - suppressSourceClickRef.current = true; - } - node.scrollLeft = dragState.startScrollLeft - deltaX; - }, []); + const handleSourcePointerMove = useCallback( + (event: ReactPointerEvent) => { + const node = sourceScrollerRef.current; + const dragState = sourceDragStateRef.current; + if (!node || !dragState || dragState.pointerId !== event.pointerId) + return; + const deltaX = event.clientX - dragState.startX; + const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; + if (Math.abs(deltaX) > moveThreshold) { + dragState.moved = true; + suppressSourceClickRef.current = true; + } + node.scrollLeft = dragState.startScrollLeft - deltaX; + }, + [] + ); - const handleSourcePointerUp = useCallback((event: ReactPointerEvent) => { - const node = sourceScrollerRef.current; - const dragState = sourceDragStateRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) return; - if (dragState.moved) { - event.preventDefault(); - window.setTimeout(() => { - suppressSourceClickRef.current = false; - }, 0); - } - sourceDragStateRef.current = null; - if (dragState.pointerType !== 'mouse') { - node?.releasePointerCapture?.(event.pointerId); - } - }, []); + const handleSourcePointerUp = useCallback( + (event: ReactPointerEvent) => { + const node = sourceScrollerRef.current; + const dragState = sourceDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + if (dragState.moved) { + event.preventDefault(); + window.setTimeout(() => { + suppressSourceClickRef.current = false; + }, 0); + } + sourceDragStateRef.current = null; + if (dragState.pointerType !== 'mouse') { + node?.releasePointerCapture?.(event.pointerId); + } + }, + [] + ); - const handleSourcePointerLeave = useCallback((event: ReactPointerEvent) => { - if (event.pointerType === 'mouse') return; - handleSourcePointerUp(event); - }, [handleSourcePointerUp]); + const handleSourcePointerLeave = useCallback( + (event: ReactPointerEvent) => { + if (event.pointerType === 'mouse') return; + handleSourcePointerUp(event); + }, + [handleSourcePointerUp] + ); - const handleSourceWheel = useCallback((event: ReactWheelEvent) => { - const node = sourceScrollerRef.current; - if (!node) return; - const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; - if (!delta) return; - node.scrollLeft += delta; - }, []); + const handleSourceWheel = useCallback( + (event: ReactWheelEvent) => { + const node = sourceScrollerRef.current; + if (!node) return; + const delta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.deltaY; + if (!delta) return; + node.scrollLeft += delta; + }, + [] + ); - const handleNavPointerDown = useCallback((event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) return; - const node = navScrollerRef.current; - if (!node) return; - navDragStateRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startScrollLeft: node.scrollLeft, - moved: false, - pointerType: event.pointerType, - }; - suppressNavClickRef.current = false; - if (event.pointerType !== 'mouse') { - node.setPointerCapture?.(event.pointerId); - } - }, []); + const handleNavPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + const node = navScrollerRef.current; + if (!node) return; + navDragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: node.scrollLeft, + moved: false, + pointerType: event.pointerType, + }; + suppressNavClickRef.current = false; + if (event.pointerType !== 'mouse') { + node.setPointerCapture?.(event.pointerId); + } + }, + [] + ); - const handleNavPointerMove = useCallback((event: ReactPointerEvent) => { - const node = navScrollerRef.current; - const dragState = navDragStateRef.current; - if (!node || !dragState || dragState.pointerId !== event.pointerId) return; - const deltaX = event.clientX - dragState.startX; - const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; - if (Math.abs(deltaX) > moveThreshold) { - dragState.moved = true; - suppressNavClickRef.current = true; - } - node.scrollLeft = dragState.startScrollLeft - deltaX; - }, []); + const handleNavPointerMove = useCallback( + (event: ReactPointerEvent) => { + const node = navScrollerRef.current; + const dragState = navDragStateRef.current; + if (!node || !dragState || dragState.pointerId !== event.pointerId) + return; + const deltaX = event.clientX - dragState.startX; + const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; + if (Math.abs(deltaX) > moveThreshold) { + dragState.moved = true; + suppressNavClickRef.current = true; + } + node.scrollLeft = dragState.startScrollLeft - deltaX; + }, + [] + ); - const handleNavPointerUp = useCallback((event: ReactPointerEvent) => { - const node = navScrollerRef.current; - const dragState = navDragStateRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) return; - if (dragState.moved) { - event.preventDefault(); - window.setTimeout(() => { - suppressNavClickRef.current = false; - }, 0); - } - navDragStateRef.current = null; - if (dragState.pointerType !== 'mouse') { - node?.releasePointerCapture?.(event.pointerId); - } - }, []); + const handleNavPointerUp = useCallback( + (event: ReactPointerEvent) => { + const node = navScrollerRef.current; + const dragState = navDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + if (dragState.moved) { + event.preventDefault(); + window.setTimeout(() => { + suppressNavClickRef.current = false; + }, 0); + } + navDragStateRef.current = null; + if (dragState.pointerType !== 'mouse') { + node?.releasePointerCapture?.(event.pointerId); + } + }, + [] + ); - const handleNavPointerLeave = useCallback((event: ReactPointerEvent) => { - if (event.pointerType === 'mouse') return; - handleNavPointerUp(event); - }, [handleNavPointerUp]); + const handleNavPointerLeave = useCallback( + (event: ReactPointerEvent) => { + if (event.pointerType === 'mouse') return; + handleNavPointerUp(event); + }, + [handleNavPointerUp] + ); - const handleNavWheel = useCallback((event: ReactWheelEvent) => { - const node = navScrollerRef.current; - if (!node) return; - const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; - if (!delta) return; - node.scrollLeft += delta; - }, []); + const handleNavWheel = useCallback( + (event: ReactWheelEvent) => { + const node = navScrollerRef.current; + if (!node) return; + const delta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.deltaY; + if (!delta) return; + node.scrollLeft += delta; + }, + [] + ); const navigationItems = useMemo(() => { const items = (catalogNavigation || []).filter((item) => { @@ -360,7 +474,11 @@ export default function BooksCatalogPage() { const containerRect = container.getBoundingClientRect(); const activeRect = activeItem.getBoundingClientRect(); - const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2; + const targetLeft = + container.scrollLeft + + activeRect.left - + containerRect.left - + (container.clientWidth - activeItem.clientWidth) / 2; container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' }); }); @@ -377,7 +495,11 @@ export default function BooksCatalogPage() { const containerRect = container.getBoundingClientRect(); const activeRect = activeItem.getBoundingClientRect(); - const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2; + const targetLeft = + container.scrollLeft + + activeRect.left - + containerRect.left - + (container.clientWidth - activeItem.clientWidth) / 2; container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' }); }); @@ -385,10 +507,10 @@ export default function BooksCatalogPage() { }, [selectedSourceId, sources.length]); return ( -
+
( event.preventDefault()} @@ -413,19 +537,22 @@ export default function BooksCatalogPage() { setSelectedHref(''); showImmediateContentLoading(); }} - className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === selectedSourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`} + className={`shrink-0 cursor-pointer whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${ + source.id === selectedSourceId + ? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20' + : 'border border-emerald-100 bg-white/70 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:border-emerald-500/10 dark:bg-gray-950/50 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200' + }`} > {source.name} ))}
- {error ?
{error}
: null} - {data || navigationItems.length > 0 ? ( + {data || navigationItems.length > 0 || error ? ( <> {navigationItems.length > 0 ? (
( event.preventDefault()} onClick={(event) => { @@ -449,24 +580,55 @@ export default function BooksCatalogPage() { setSelectedHref(item.href); showImmediateContentLoading(); }} - className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${item.href === selectedHref ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`} + className={`shrink-0 cursor-pointer whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${ + item.href === selectedHref + ? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20' + : 'border border-emerald-100 bg-white/70 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:border-emerald-500/10 dark:bg-gray-950/50 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200' + }`} > {item.title.trim()} ))}
) : null} - {loadingCatalog ? ( + {error ? ( +
+
+
+ +
+

+ 目录加载失败 +

+

+ {error} +

+
+
+ ) : loadingCatalog ? ( ) : (
- {entries.map((item) => cacheBookListItem(item)} />)} + {entries.map((item) => ( + cacheBookListItem(item)} + /> + ))}
)} {loadingMore ? : null} - {!loadingMore && nextHref ?
: null} + {!loadingMore && nextHref ? ( +
+ ) : null} - ) : !error ? : null} + ) : !error ? ( + + ) : null}
); } diff --git a/src/app/books/detail/page.tsx b/src/app/books/detail/page.tsx index 70352fe..adbbc3f 100644 --- a/src/app/books/detail/page.tsx +++ b/src/app/books/detail/page.tsx @@ -1,25 +1,34 @@ 'use client'; +import { BookmarkPlus, BookOpen, Download, FileText, Tags } from 'lucide-react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; -import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; -import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client'; +import { + deleteBookShelf, + getAllBookShelf, + saveBookShelf, +} from '@/lib/book.db.client'; import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types'; +import { + buildBookReadPath, + cacheBookDetail, + getBookRouteCache, +} from '@/lib/book-route-cache.client'; function DetailSkeleton() { return (
-
-
+
+
-
-
+
+
-
-
-
+
+
+
@@ -37,35 +46,54 @@ function parseDownloadFilename(disposition: string | null) { if (utf8Match?.[1]) { try { return decodeURIComponent(utf8Match[1]); - } catch {} + } catch { + return ''; + } } const plainMatch = disposition.match(/filename="?([^";]+)"?/i); return plainMatch?.[1] || ''; } function sanitizeFilename(name: string) { - return name.replace(/[\/:*?"<>|]/g, '_').trim(); + return name.replace(/[/:*?"<>|]/g, '_').trim(); } -async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf' | 'chapters', download = false, href?: string, title?: string) { +async function openBookFile( + sourceId: string, + bookId: string, + format?: 'epub' | 'pdf' | 'chapters', + download = false, + href?: string, + title?: string +) { const response = await fetch('/api/books/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sourceId, bookId, format: format || null, href: href || undefined }), + body: JSON.stringify({ + sourceId, + bookId, + format: format || null, + href: href || undefined, + }), }); if (!response.ok) { let message = '打开文件失败'; try { const json = await response.json(); message = json.error || message; - } catch {} + } catch { + // Keep fallback error message. + } throw new Error(message); } const blob = await response.blob(); const url = URL.createObjectURL(blob); if (download) { - const headerFilename = parseDownloadFilename(response.headers.get('content-disposition')); - const fallbackBaseName = sanitizeFilename(title || bookId || 'book') || 'book'; + const headerFilename = parseDownloadFilename( + response.headers.get('content-disposition') + ); + const fallbackBaseName = + sanitizeFilename(title || bookId || 'book') || 'book'; const extension = format === 'pdf' ? 'pdf' : 'epub'; const finalFilename = headerFilename || `${fallbackBaseName}.${extension}`; const link = document.createElement('a'); @@ -92,12 +120,17 @@ export default function BookDetailPage() { const [error, setError] = useState(''); const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>(''); - const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]); + const cached = useMemo( + () => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), + [sourceId, bookId] + ); useEffect(() => { - getAllBookShelf().then((items) => { - setShelf(items); - }).catch(() => undefined); + getAllBookShelf() + .then((items) => { + setShelf(items); + }) + .catch(() => undefined); }, []); useEffect(() => { @@ -127,13 +160,19 @@ export default function BookDetailPage() { const readable = detail?.acquisitionLinks.find((item) => { const type = item.type.toLowerCase(); - return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters'; + return ( + type.includes('epub') || + type.includes('pdf') || + type.includes('legado-chapters') || + item.rel === 'legado:chapters' + ); }); const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' - : readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters' - ? 'chapters' - : 'epub'; + : readable?.type.toLowerCase().includes('legado-chapters') || + readable?.rel === 'legado:chapters' + ? 'chapters' + : 'epub'; useEffect(() => { if (!detail || !readable || readableFormat !== 'chapters') { @@ -150,7 +189,9 @@ export default function BookDetailPage() { sourceId: detail.sourceId, bookId: detail.id, }); - fetch(`/api/books/read/chapters?${params.toString()}`, { cache: 'no-store' }) + fetch(`/api/books/read/chapters?${params.toString()}`, { + cache: 'no-store', + }) .then(async (res) => { const json = await res.json(); if (!res.ok) throw new Error(json.error || '获取章节失败'); @@ -199,74 +240,200 @@ export default function BookDetailPage() { cacheBookDetail(detail); }; - if (error) return
{error}
; + if (error) + return ( +
+ {error} +
+ ); if (!detail) return ; return (
-
-
- {detail.cover ? {detail.title} :
无封面
} -
-
-
-

{detail.title}

-
{detail.author || '未知作者'}
-
{detail.sourceName}
+
+
+
+
+ {detail.cover ? ( + // eslint-disable-next-line @next/next/no-img-element + {detail.title} + ) : ( +
+ + 无封面 +
+ )}
- {detail.summary ?
{detail.summary}
: null} -
- {(detail.categories || detail.tags || []).map((tag) => {tag})} -
-
- {readable ? cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读 : null} - - {readable && readableFormat !== 'chapters' ? : null} +
+
+
+ + {detail.sourceName} +
+

+ {detail.title} +

+
+ {detail.author || '未知作者'} +
+ {detail.summary ? ( +
+ {detail.summary} +
+ ) : null} +
+ {(detail.categories || detail.tags || []).map((tag) => ( + + + {tag} + + ))} +
+
+
+ {readable ? ( + cacheBookDetail(detail)} + className='inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-emerald-600/20 transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-gray-950' + > + + 在线阅读 + + ) : null} + + {readable && readableFormat !== 'chapters' ? ( + + ) : null} +
-
-

可用格式

+ +
+
+ +

+ 可用格式 +

+
{detail.acquisitionLinks.map((item) => { const type = item.type.toLowerCase(); - const format = type.includes('pdf') ? 'pdf' : type.includes('epub') ? 'epub' : type.includes('legado-chapters') || item.rel === 'legado:chapters' ? 'chapters' : undefined; + const format = type.includes('pdf') + ? 'pdf' + : type.includes('epub') + ? 'epub' + : type.includes('legado-chapters') || + item.rel === 'legado:chapters' + ? 'chapters' + : undefined; return ( -
-
-
{item.title || item.type}
-
{item.rel}
+
+
+
+ {item.title || item.type} +
+
+ {item.rel} +
- +
); })}
+ {readableFormat === 'chapters' ? ( -
+
-

章节目录

-
{chaptersLoading ? '加载中...' : `${chapters.length} 章`}
+

+ 章节目录 +

+
+ {chaptersLoading ? '加载中...' : `${chapters.length} 章`} +
- {chaptersError ?
{chaptersError}
: null} + {chaptersError ? ( +
{chaptersError}
+ ) : null} {!chaptersLoading && !chaptersError && chapters.length === 0 ? ( -
+
源站当前没有返回章节,这不是 EPUB 文件缺失;请换有章节的搜索结果。
) : null} @@ -275,9 +442,13 @@ export default function BookDetailPage() { {chapters.slice(0, 60).map((chapter) => ( cacheBookDetail(detail)} - className='truncate rounded-2xl bg-gray-50 px-4 py-3 text-sm hover:bg-sky-50 hover:text-sky-600 dark:bg-gray-900 dark:hover:bg-sky-950/40' + className='truncate rounded-2xl bg-emerald-50/70 px-4 py-3 text-sm text-slate-700 ring-1 ring-emerald-100 transition-colors duration-200 hover:bg-white hover:text-emerald-700 dark:bg-emerald-500/5 dark:text-slate-200 dark:ring-emerald-500/10 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200' title={chapter.title} > {chapter.title} @@ -285,7 +456,11 @@ export default function BookDetailPage() { ))}
) : null} - {chapters.length > 60 ?
仅预览前 60 章,完整目录请进入阅读页侧边栏查看。
: null} + {chapters.length > 60 ? ( +
+ 仅预览前 60 章,完整目录请进入阅读页侧边栏查看。 +
+ ) : null}
) : null}
diff --git a/src/app/books/history/page.tsx b/src/app/books/history/page.tsx index 99913f0..3dfb60f 100644 --- a/src/app/books/history/page.tsx +++ b/src/app/books/history/page.tsx @@ -1,20 +1,44 @@ 'use client'; -import { FolderCog, RefreshCw, Trash2, X } from 'lucide-react'; +import { + BookOpen, + Clock3, + Database, + FolderCog, + RefreshCw, + Trash2, + X, +} from 'lucide-react'; import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import { deleteCachedBookFile, listCachedBookFiles, type CachedBookFile } from '@/lib/book-cache.client'; -import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/lib/book-route-cache.client'; -import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf, getCachedBookReadRecordsSnapshot } from '@/lib/book.db.client'; +import { + deleteBookReadRecord, + getAllBookReadRecords, + getAllBookShelf, + getCachedBookReadRecordsSnapshot, +} from '@/lib/book.db.client'; import { BookReadRecord, BookShelfItem } from '@/lib/book.types'; +import { + type CachedBookFile, + deleteCachedBookFile, + listCachedBookFiles, +} from '@/lib/book-cache.client'; +import { + buildBookReadPath, + cacheBookReadRecord, + cacheBookShelfItem, +} from '@/lib/book-route-cache.client'; import { subscribeToDataUpdates } from '@/lib/db.client'; function looksLikeInternalHref(value?: string) { if (!value) return false; const normalized = value.trim().toLowerCase(); - return /\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) || /^nav\b/.test(normalized); + return ( + /\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) || + /^nav\b/.test(normalized) + ); } function getReadableChapterLabel(item: BookReadRecord) { @@ -36,16 +60,19 @@ function BookHistorySkeleton() { return (
{Array.from({ length: 6 }).map((_, index) => ( -
+
-
+
-
-
-
+
+
+
-
-
+
+
@@ -63,7 +90,11 @@ export default function BookHistoryPage() { const [cacheItems, setCacheItems] = useState([]); const [cacheLoading, setCacheLoading] = useState(false); const [mounted, setMounted] = useState(false); - const [confirmAction, setConfirmAction] = useState<{ type: 'delete-one' | 'clear-all'; key?: string; title?: string } | null>(null); + const [confirmAction, setConfirmAction] = useState<{ + type: 'delete-one' | 'clear-all'; + key?: string; + title?: string; + } | null>(null); const [displayAll, setDisplayAll] = useState(false); const updateRecords = (nextRecords: Record) => { @@ -83,10 +114,17 @@ export default function BookHistoryPage() { setLoading(false); } - getAllBookReadRecords().then(updateRecords).catch(() => undefined).finally(() => setLoading(false)); - getAllBookShelf().then(setShelf).catch(() => undefined); + getAllBookReadRecords() + .then(updateRecords) + .catch(() => undefined) + .finally(() => setLoading(false)); + getAllBookShelf() + .then(setShelf) + .catch(() => undefined); - const unsubscribeHistory = subscribeToDataUpdates>('bookHistoryUpdated', updateRecords); + const unsubscribeHistory = subscribeToDataUpdates< + Record + >('bookHistoryUpdated', updateRecords); return unsubscribeHistory; }, []); @@ -105,160 +143,342 @@ export default function BookHistoryPage() { void loadCacheItems(); }, [cacheModalOpen]); - const items = useMemo(() => Object.entries(records) - .map(([key, item]) => { - const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+'); - const shelfItem = shelf[key]; - return { - ...item, - storageKey: key, - sourceId: item.sourceId || shelfItem?.sourceId || fallbackSourceId, - bookId: item.bookId || shelfItem?.bookId || fallbackBookId, - sourceName: item.sourceName || shelfItem?.sourceName || '', - detailHref: item.detailHref || shelfItem?.detailHref, - acquisitionHref: item.acquisitionHref || shelfItem?.acquisitionHref, - cover: item.cover || shelfItem?.cover, - author: item.author || shelfItem?.author, - format: item.format || shelfItem?.format || 'epub', - }; - }) - .sort((a, b) => b.saveTime - a.saveTime), [records, shelf]); + const items = useMemo( + () => + Object.entries(records) + .map(([key, item]) => { + const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+'); + const shelfItem = shelf[key]; + return { + ...item, + storageKey: key, + sourceId: item.sourceId || shelfItem?.sourceId || fallbackSourceId, + bookId: item.bookId || shelfItem?.bookId || fallbackBookId, + sourceName: item.sourceName || shelfItem?.sourceName || '', + detailHref: item.detailHref || shelfItem?.detailHref, + acquisitionHref: item.acquisitionHref || shelfItem?.acquisitionHref, + cover: item.cover || shelfItem?.cover, + author: item.author || shelfItem?.author, + format: item.format || shelfItem?.format || 'epub', + }; + }) + .sort((a, b) => b.saveTime - a.saveTime), + [records, shelf] + ); const visibleItems = useMemo( () => (displayAll ? items : items.slice(0, 10)), [displayAll, items] ); - const cacheTotalSize = useMemo(() => cacheItems.reduce((sum, item) => sum + item.size, 0), [cacheItems]); + const cacheTotalSize = useMemo( + () => cacheItems.reduce((sum, item) => sum + item.size, 0), + [cacheItems] + ); return ( -
-
-
共 {items.length} 条阅读历史
- -
+
+
+
+
+
+
+ + Reading Timeline +
+

+ 阅读历史 +

+
+ 共 {items.length} 条记录 +
+
+ +
+
{loading ? ( ) : ( visibleItems.map((item) => ( -
+
-
{item.cover ? {item.title} : null}
+
+ {item.cover ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.title} + ) : ( +
+ +
+ )} +
-
{item.title}
-
{item.author || item.sourceName}
-
已读 {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}
+
+ {item.title} +
+
+ {item.author || item.sourceName} +
+
+
+
+
+ 已读 {Math.round(item.progressPercent || 0)}% ·{' '} + {getReadableChapterLabel(item)} +
{item.sourceId ? ( { cacheBookReadRecord(item); if (item.sourceId && item.bookId) { cacheBookShelfItem({ sourceId: item.sourceId, sourceName: item.sourceName, bookId: item.bookId, title: item.title, author: item.author, cover: item.cover, format: item.format, detailHref: item.detailHref, acquisitionHref: item.acquisitionHref, saveTime: item.saveTime }); } }} - className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white' + onClick={() => { + cacheBookReadRecord(item); + if (item.sourceId && item.bookId) { + cacheBookShelfItem({ + sourceId: item.sourceId, + sourceName: item.sourceName, + bookId: item.bookId, + title: item.title, + author: item.author, + cover: item.cover, + format: item.format, + detailHref: item.detailHref, + acquisitionHref: item.acquisitionHref, + saveTime: item.saveTime, + }); + } + }} + className='inline-flex cursor-pointer items-center gap-1.5 rounded-2xl bg-emerald-600 px-3 py-2 text-xs font-semibold text-white transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500' > 继续阅读 ) : ( - 历史记录缺少书源信息 + + 历史记录缺少书源信息 + )} - +
-
+
)) )} - {!loading && items.length === 0 ?
暂无阅读历史
: null} + {!loading && items.length === 0 ? ( +
+ 暂无阅读历史 +
+ ) : null} - {cacheModalOpen && mounted && createPortal( -
setCacheModalOpen(false)}> -
event.stopPropagation()}> -
-
-
-
缓存管理
-
已缓存 {cacheItems.length} 本 · {formatBytes(cacheTotalSize)}
-
-
- - - -
-
- - {cacheLoading ?
正在读取缓存…
: null} - {!cacheLoading && cacheItems.length === 0 ?
当前还没有缓存书籍
: null} - -
- {cacheItems.map((item) => ( -
-
-
-
{item.title}
-
格式 {item.format.toUpperCase()} · 大小 {formatBytes(item.size)}
-
最近打开 {new Date(item.lastOpenTime).toLocaleString()}
+ {cacheModalOpen && + mounted && + createPortal( +
setCacheModalOpen(false)} + > +
event.stopPropagation()} + > +
+
+
+
+
+ + 缓存管理
+
+ 已缓存 {cacheItems.length} 本 ·{' '} + {formatBytes(cacheTotalSize)} +
+
+
+ +
- ))} +
+ + {cacheLoading ? ( +
+ 正在读取缓存… +
+ ) : null} + {!cacheLoading && cacheItems.length === 0 ? ( +
+ 当前还没有缓存书籍 +
+ ) : null} + +
+ {cacheItems.map((item) => ( +
+
+
+
+ {item.title} +
+
+ 格式 {item.format.toUpperCase()} · 大小{' '} + {formatBytes(item.size)} +
+
+ 最近打开{' '} + {new Date(item.lastOpenTime).toLocaleString()} +
+
+ +
+
+ ))} +
-
-
, - document.body - )} +
, + document.body + )} - - {confirmAction && mounted && createPortal( -
setConfirmAction(null)}> -
event.stopPropagation()}> -
- {confirmAction.type === 'clear-all' ? '清空全部缓存' : '删除缓存'} + {confirmAction && + mounted && + createPortal( +
setConfirmAction(null)} + > +
event.stopPropagation()} + > +
+ + {confirmAction.type === 'clear-all' + ? '清空全部缓存' + : '删除缓存'} +
+
+ {confirmAction.type === 'clear-all' + ? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。' + : `确认删除《${ + confirmAction.title || '该书' + }》的本地缓存吗?`} +
+
+ + +
-
- {confirmAction.type === 'clear-all' - ? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。' - : `确认删除《${confirmAction.title || '该书'}》的本地缓存吗?`} -
-
- - -
-
-
, - document.body - )} +
, + document.body + )}
); } diff --git a/src/app/books/page.tsx b/src/app/books/page.tsx index 53084cd..3da3a29 100644 --- a/src/app/books/page.tsx +++ b/src/app/books/page.tsx @@ -1,7 +1,16 @@ 'use client'; +import { + BookOpen, + CheckCircle2, + Compass, + Library, + Search, + Sparkles, + XCircle, +} from 'lucide-react'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { BookSource } from '@/lib/book.types'; @@ -9,15 +18,18 @@ function BooksHomeSkeleton() { return (
{Array.from({ length: 6 }).map((_, index) => ( -
-
+
+
-
-
+
+
-
-
-
+
+
+
))} @@ -25,13 +37,39 @@ function BooksHomeSkeleton() { ); } +function CapabilityPill({ + enabled, + children, +}: { + enabled?: boolean; + children: React.ReactNode; +}) { + const Icon = enabled ? CheckCircle2 : XCircle; + return ( + + + {children} + + ); +} + export default function BooksHomePage() { const [sources, setSources] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useEffect(() => { - if (typeof window !== 'undefined' && !(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } }).RUNTIME_CONFIG?.BOOKS_ENABLED) { + if ( + typeof window !== 'undefined' && + !(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } }) + .RUNTIME_CONFIG?.BOOKS_ENABLED + ) { window.location.href = '/'; return; } @@ -42,31 +80,147 @@ export default function BooksHomePage() { .finally(() => setLoading(false)); }, []); + const stats = useMemo(() => { + const catalogCount = sources.filter( + (source) => source.capabilities?.catalogSupported + ).length; + const searchCount = sources.filter( + (source) => source.capabilities?.searchSupported + ).length; + return [ + { label: '可用书源', value: sources.length }, + { label: '支持目录', value: catalogCount }, + { label: '支持搜索', value: searchCount }, + ]; + }, [sources]); + return ( -
-
-

电子书源

+
+
+
+
+
+
+
+ + MoonTVPlus Reading Library +
+

+ 电子书馆 +

+
+ + + 搜索书籍 + + + + 我的书架 + +
+
+
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+
+
+
+

+ 书源入口 +

+

+ 选择一个书源开始浏览,或直接进入搜索。 +

+
+ +
+ {loading ? : null} - {error ?
{error}
: null} + {error ? ( +
+ {error} +
+ ) : null}
{sources.map((source) => ( -
-
{source.name}
-
{source.type === 'legado' ? 'Legado' : 'OPDS'}
-
- 分类{source.capabilities?.catalogSupported ? '可用' : '不可用'} - 搜索{source.capabilities?.searchSupported ? '可用' : '不可用'} +
+
+
+
+
+ {source.name} +
+
+ {source.type === 'legado' ? 'Legado' : 'OPDS'} +
+
+
+ +
-
- {source.capabilities?.catalogSupported && 浏览目录} - {source.capabilities?.searchSupported && 搜索书籍} +
+ + 分类{source.capabilities?.catalogSupported ? '可用' : '不可用'} + + + 搜索{source.capabilities?.searchSupported ? '可用' : '不可用'} +
-
+
+ {source.capabilities?.catalogSupported && ( + + 浏览目录 + + )} + {source.capabilities?.searchSupported && ( + + 搜索书籍 + + )} +
+
))}
+ + {!loading && !error && sources.length === 0 ? ( +
+ 暂无可用书源 +
+ ) : null}
); } diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index a4d00da..07d6e53 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -1,12 +1,36 @@ 'use client'; -import { BookOpen, ChevronRight, ChevronUp, Gauge, Headphones, Loader2, Moon, Pause, Play, SkipBack, SkipForward, Square, Sun, Volume2, Waves, X } from 'lucide-react'; +import { + BookOpen, + ChevronRight, + ChevronUp, + Gauge, + Headphones, + Loader2, + Moon, + Pause, + Play, + SkipBack, + SkipForward, + Square, + Sun, + Volume2, + Waves, + X, +} from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { saveBookReadRecord } from '@/lib/book.db.client'; -import { BookChapter, BookChapterContent, BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types'; +import { + BookChapter, + BookChapterContent, + BookReadManifest, + BookReadRecord, + BookTtsProgress, + BookTtsVoice, +} from '@/lib/book.types'; import { buildBookCacheKey, enforceBookCacheLimit, @@ -14,7 +38,10 @@ import { putCachedBookFile, touchCachedBookFile, } from '@/lib/book-cache.client'; -import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; +import { + cacheBookDetail, + getBookRouteCache, +} from '@/lib/book-route-cache.client'; import { buildBookTtsCacheKey, enforceBookTtsCacheLimit, @@ -22,7 +49,10 @@ import { putCachedBookTtsChunk, touchCachedBookTtsChunk, } from '@/lib/book-tts-cache.client'; -import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client'; +import { + getBookTtsProgress, + saveBookTtsProgress, +} from '@/lib/book-tts-progress.client'; declare global { interface Window { @@ -54,7 +84,10 @@ interface EpubThemes { } interface EpubBookInstance { - renderTo: (element: HTMLElement, options: Record) => EpubRendition; + renderTo: ( + element: HTMLElement, + options: Record + ) => EpubRendition; locations?: { percentageFromCfi?: (cfi: string) => number; generate?: (chars?: number) => Promise; @@ -78,7 +111,12 @@ interface EpubRendition { type ReaderTheme = 'light' | 'sepia' | 'dark'; type ReaderMode = 'paginated' | 'scrolled'; -type FileLoadState = 'preparing' | 'checking-cache' | 'downloading' | 'opening' | 'ready'; +type FileLoadState = + | 'preparing' + | 'checking-cache' + | 'downloading' + | 'opening' + | 'ready'; interface ReaderSettings { fontSize: number; @@ -141,7 +179,10 @@ 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]; -const THEME_STYLES: Record = { +const THEME_STYLES: Record< + ReaderTheme, + { bodyBg: string; bodyColor: string; panelBg: string } +> = { light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' }, sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' }, dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' }, @@ -152,7 +193,10 @@ function loadTtsSettings(): TtsSettings { try { const raw = localStorage.getItem(TTS_SETTINGS_STORAGE_KEY); if (!raw) return DEFAULT_TTS_SETTINGS; - return { ...DEFAULT_TTS_SETTINGS, ...(JSON.parse(raw) as Partial) }; + return { + ...DEFAULT_TTS_SETTINGS, + ...(JSON.parse(raw) as Partial), + }; } catch { return DEFAULT_TTS_SETTINGS; } @@ -177,10 +221,16 @@ function loadCachedTtsVoices(): TtsVoicesCache | null { function saveCachedTtsVoices(cache: Omit) { if (typeof window === 'undefined' || cache.voices.length === 0) return; - localStorage.setItem(TTS_VOICES_STORAGE_KEY, JSON.stringify({ ...cache, savedAt: Date.now() })); + localStorage.setItem( + TTS_VOICES_STORAGE_KEY, + JSON.stringify({ ...cache, savedAt: Date.now() }) + ); } -function applyTtsDefaults(settings: TtsSettings, defaults?: Partial): TtsSettings { +function applyTtsDefaults( + settings: TtsSettings, + defaults?: Partial +): TtsSettings { return { ...settings, voice: settings.voice || defaults?.voice || '', @@ -202,14 +252,20 @@ function formatSignedValue(value: number, suffix: '%' | 'Hz') { function loadScriptOnce(selector: string, src: string, errorMessage: string) { return new Promise((resolve, reject) => { - const existing = document.querySelector(selector) as HTMLScriptElement | null; + const existing = document.querySelector( + selector + ) as HTMLScriptElement | null; if (existing) { if (existing.dataset.loaded === 'true') { resolve(); return; } existing.addEventListener('load', () => resolve(), { once: true }); - existing.addEventListener('error', () => reject(new Error(errorMessage)), { once: true }); + existing.addEventListener( + 'error', + () => reject(new Error(errorMessage)), + { once: true } + ); return; } @@ -230,10 +286,18 @@ function loadScriptOnce(selector: string, src: string, errorMessage: string) { async function loadEpubScript() { if (window.ePub && window.JSZip) return; if (!window.JSZip) { - await loadScriptOnce('script[data-jszip]', 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js', 'JSZip 加载失败'); + await loadScriptOnce( + 'script[data-jszip]', + 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js', + 'JSZip 加载失败' + ); } if (!window.ePub) { - await loadScriptOnce('script[data-epubjs]', 'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js', 'epub.js 加载失败'); + await loadScriptOnce( + 'script[data-epubjs]', + 'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js', + 'epub.js 加载失败' + ); } } @@ -242,14 +306,20 @@ function loadReaderSettings(): ReaderSettings { try { const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); if (!raw) return DEFAULT_SETTINGS; - return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial) }; + return { + ...DEFAULT_SETTINGS, + ...(JSON.parse(raw) as Partial), + }; } catch { return DEFAULT_SETTINGS; } } - -function buildScrolledPositionKey(sourceId: string, bookId: string, href?: string) { +function buildScrolledPositionKey( + sourceId: string, + bookId: string, + href?: string +) { return `${sourceId}::${bookId}::${normalizeHrefForMatch(href)}`; } @@ -257,20 +327,30 @@ function loadScrolledPositions(): Record { if (typeof window === 'undefined') return {}; try { const raw = localStorage.getItem(SCROLLED_POSITION_STORAGE_KEY); - return raw ? (JSON.parse(raw) as Record) : {}; + return raw + ? (JSON.parse(raw) as Record) + : {}; } catch { return {}; } } -function saveScrolledPosition(sourceId: string, bookId: string, position: ScrolledReadingPosition) { +function saveScrolledPosition( + sourceId: string, + bookId: string, + position: ScrolledReadingPosition +) { if (typeof window === 'undefined') return; const all = loadScrolledPositions(); all[buildScrolledPositionKey(sourceId, bookId, position.href)] = position; localStorage.setItem(SCROLLED_POSITION_STORAGE_KEY, JSON.stringify(all)); } -function getScrolledPosition(sourceId: string, bookId: string, href?: string): ScrolledReadingPosition | null { +function getScrolledPosition( + sourceId: string, + bookId: string, + href?: string +): ScrolledReadingPosition | null { const all = loadScrolledPositions(); return all[buildScrolledPositionKey(sourceId, bookId, href)] || null; } @@ -298,15 +378,25 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) { } } - const root = doc ? (doc.scrollingElement || doc.documentElement || doc.body) : null; - const rootOverflow = root ? Math.max((root.scrollHeight || 0) - (root.clientHeight || win?.innerHeight || 0), 0) : 0; + const root = doc + ? doc.scrollingElement || doc.documentElement || doc.body + : null; + const rootOverflow = root + ? Math.max( + (root.scrollHeight || 0) - (root.clientHeight || win?.innerHeight || 0), + 0 + ) + : 0; if (root && rootOverflow >= bestOverflow) { return { iframe, root, scrollTop: Math.max(0, win?.scrollY || root.scrollTop || 0), - scrollHeight: Math.max(root.scrollHeight || 0, doc?.body?.scrollHeight || 0), + scrollHeight: Math.max( + root.scrollHeight || 0, + doc?.body?.scrollHeight || 0 + ), clientHeight: root.clientHeight || win?.innerHeight || 0, setScrollTop: (value: number) => { if (typeof root.scrollTo === 'function') { @@ -315,8 +405,10 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) { root.scrollTop = value; } }, - addScrollListener: (listener: () => void) => win?.addEventListener('scroll', listener, { passive: true }), - removeScrollListener: (listener: () => void) => win?.removeEventListener('scroll', listener), + addScrollListener: (listener: () => void) => + win?.addEventListener('scroll', listener, { passive: true }), + removeScrollListener: (listener: () => void) => + win?.removeEventListener('scroll', listener), interactionTarget: root, }; } @@ -332,8 +424,10 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) { setScrollTop: (value: number) => { scrollElement.scrollTo({ top: value, behavior: 'auto' }); }, - addScrollListener: (listener: () => void) => scrollElement.addEventListener('scroll', listener, { passive: true }), - removeScrollListener: (listener: () => void) => scrollElement.removeEventListener('scroll', listener), + addScrollListener: (listener: () => void) => + scrollElement.addEventListener('scroll', listener, { passive: true }), + removeScrollListener: (listener: () => void) => + scrollElement.removeEventListener('scroll', listener), interactionTarget: scrollElement, }; } @@ -341,7 +435,11 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) { return null; } -function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, currentScrollHeight: number, currentClientHeight: number) { +function computeScrolledTargetScrollTop( + position: ScrolledReadingPosition, + currentScrollHeight: number, + currentClientHeight: number +) { const maxSaved = Math.max(0, position.scrollHeight - position.clientHeight); const maxCurrent = Math.max(0, currentScrollHeight - currentClientHeight); if (maxCurrent <= 0) return 0; @@ -350,9 +448,15 @@ function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, curre return ratio * maxCurrent; } -function encodeChapterScrollLocator(href: string, scrollTop: number, scrollHeight: number, clientHeight: number) { +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; + const ratio = + maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; return `${href}#scroll=${ratio.toFixed(6)}`; } @@ -370,7 +474,12 @@ function flattenToc(items: TocItem[]): TocItem[] { } function tocItemIsActive(item: TocItem, currentHref: string): boolean { - return isSameTocTarget(currentHref, item.href) || (item.subitems || []).some((subitem) => tocItemIsActive(subitem, currentHref)); + return ( + isSameTocTarget(currentHref, item.href) || + (item.subitems || []).some((subitem) => + tocItemIsActive(subitem, currentHref) + ) + ); } function findTocLabelByHref(items: TocItem[], currentHref: string): string { @@ -382,7 +491,11 @@ function findTocLabelByHref(items: TocItem[], currentHref: string): string { return ''; } -async function fetchJsonWithRetry(url: string, init?: RequestInit, retries = 2): Promise { +async function fetchJsonWithRetry( + url: string, + init?: RequestInit, + retries = 2 +): Promise { let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt += 1) { try { @@ -393,7 +506,9 @@ async function fetchJsonWithRetry(url: string, init?: RequestInit, retries = } catch (error) { lastError = error; if (attempt < retries) { - await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))); + await new Promise((resolve) => + setTimeout(resolve, 300 * (attempt + 1)) + ); } } } @@ -440,7 +555,9 @@ async function downloadBookWithProgress( } } - return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' }); + return new Blob(chunks, { + type: response.headers.get('content-type') || 'application/epub+zip', + }); } function ChapterReader({ manifest }: { manifest: BookReadManifest }) { @@ -457,12 +574,16 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const [error, setError] = useState(''); const [ttsVoices, setTtsVoices] = useState([]); const [ttsAvailable, setTtsAvailable] = useState(false); - const [ttsSettings, setTtsSettings] = useState(() => loadTtsSettings()); + const [ttsSettings, setTtsSettings] = useState(() => + loadTtsSettings() + ); const [ttsStatus, setTtsStatus] = useState('idle'); const [ttsError, setTtsError] = useState(''); const [ttsChunks, setTtsChunks] = useState([]); const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); - const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState(null); + const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState< + number | null + >(null); const [ttsBarVisible, setTtsBarVisible] = useState(false); const [ttsPanelOpen, setTtsPanelOpen] = useState(false); const [ttsCurrentTime, setTtsCurrentTime] = useState(0); @@ -475,7 +596,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const pendingChapterRestoreRatioRef = useRef(null); const currentIndexRef = useRef(0); const lastChapterSavedAtRef = useRef(0); - const lastChapterSavedLocatorValueRef = useRef(manifest.lastRecord?.locator?.value || ''); + const lastChapterSavedLocatorValueRef = useRef( + manifest.lastRecord?.locator?.value || '' + ); const audioRef = useRef(null); const ttsChunksRef = useRef([]); const ttsCurrentChunkIndexRef = useRef(0); @@ -484,27 +607,44 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const ttsSeekingRef = useRef(false); const ttsResumeTimeRef = useRef(0); const currentChapterHref = chapters[currentIndex]?.href || ''; - const currentChapterTitle = chapters[currentIndex]?.title || chapter?.title || ''; + const currentChapterTitle = + chapters[currentIndex]?.title || chapter?.title || ''; - useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); + useEffect(() => { + currentIndexRef.current = currentIndex; + }, [currentIndex]); useEffect(() => { setSettings(loadReaderSettings()); }, []); useEffect(() => { - if (typeof window !== 'undefined') localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + if (typeof window !== 'undefined') + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); }, [settings]); useEffect(() => { - if (typeof window !== 'undefined') localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings)); + if (typeof window !== 'undefined') + localStorage.setItem( + TTS_SETTINGS_STORAGE_KEY, + JSON.stringify(ttsSettings) + ); ttsSettingsRef.current = ttsSettings; }, [ttsSettings]); - useEffect(() => { ttsChunksRef.current = ttsChunks; }, [ttsChunks]); - useEffect(() => { ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; }, [ttsCurrentChunkIndex]); - useEffect(() => { ttsStatusRef.current = ttsStatus; }, [ttsStatus]); - useEffect(() => { ttsSeekingRef.current = ttsSeeking; if (!ttsSeeking) setTtsSeekValue(ttsCurrentTime); }, [ttsCurrentTime, ttsSeeking]); + useEffect(() => { + ttsChunksRef.current = ttsChunks; + }, [ttsChunks]); + useEffect(() => { + ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; + }, [ttsCurrentChunkIndex]); + useEffect(() => { + ttsStatusRef.current = ttsStatus; + }, [ttsStatus]); + useEffect(() => { + ttsSeekingRef.current = ttsSeeking; + if (!ttsSeeking) setTtsSeekValue(ttsCurrentTime); + }, [ttsCurrentTime, ttsSeeking]); const stopTts = useCallback((clearQueue = false) => { const audio = audioRef.current; @@ -528,15 +668,33 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { }, []); useEffect(() => { - const handleToggleChapters = () => { setTocOpen((prev) => !prev); setSettingsOpen(false); setTtsPanelOpen(false); }; - const handleToggleSettings = () => { setSettingsOpen((prev) => !prev); setTocOpen(false); setTtsPanelOpen(false); }; - const handleToggleTts = () => { setTtsBarVisible((prev) => !prev); setTocOpen(false); setSettingsOpen(false); }; + const handleToggleChapters = () => { + setTocOpen((prev) => !prev); + setSettingsOpen(false); + setTtsPanelOpen(false); + }; + const handleToggleSettings = () => { + setSettingsOpen((prev) => !prev); + setTocOpen(false); + setTtsPanelOpen(false); + }; + const handleToggleTts = () => { + setTtsBarVisible((prev) => !prev); + setTocOpen(false); + setSettingsOpen(false); + }; window.addEventListener('books-read-toggle-chapters', handleToggleChapters); window.addEventListener('books-read-toggle-settings', handleToggleSettings); window.addEventListener('books-read-toggle-tts', handleToggleTts); return () => { - window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); - window.removeEventListener('books-read-toggle-settings', handleToggleSettings); + window.removeEventListener( + 'books-read-toggle-chapters', + handleToggleChapters + ); + window.removeEventListener( + 'books-read-toggle-settings', + handleToggleSettings + ); window.removeEventListener('books-read-toggle-tts', handleToggleTts); }; }, []); @@ -558,7 +716,10 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { setTtsAvailable(true); setTtsVoices(json.voices || []); setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults)); - saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} }); + saveCachedTtsVoices({ + voices: json.voices || [], + defaults: json.defaults || {}, + }); }) .catch((err) => { if (!cancelled) { @@ -567,53 +728,74 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { setTtsError(err.message || '朗读能力不可用'); } }); - return () => { cancelled = true; }; + 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; + 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, + 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, - }, - chapterTitle: item.title, - chapterHref: item.href, - progressPercent, - saveTime: Date.now(), - }; - }, [chapters.length, manifest]); + 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 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; @@ -635,22 +817,39 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { restoredChapterPositionRef.current = false; pendingChapterRestoreRatioRef.current = null; lastChapterSavedAtRef.current = 0; - lastChapterSavedLocatorValueRef.current = manifest.lastRecord?.locator?.value || ''; + 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)}`; + const url = + manifest.chaptersUrl || + `/api/books/read/chapters?sourceId=${encodeURIComponent( + manifest.book.sourceId + )}&bookId=${encodeURIComponent(manifest.book.id)}`; fetchJsonWithRetry<{ chapters?: BookChapter[] }>(url, { cache: 'no-store' }) .then((json) => { if (cancelled) return; const list = (json.chapters || []) as BookChapter[]; setChapters(list); setChaptersLoaded(true); - const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value?.split('#scroll=')[0] || ''; + 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); }) - .catch((err) => { if (!cancelled) { setError(err.message || '获取目录失败'); setChaptersLoaded(true); } }); - return () => { cancelled = true; }; + .catch((err) => { + if (!cancelled) { + setError(err.message || '获取目录失败'); + setChaptersLoaded(true); + } + }); + return () => { + cancelled = true; + }; }, [initialChapterHref, manifest]); useEffect(() => { @@ -664,28 +863,57 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { 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' }) + 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 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, + }); }) .catch((err) => setError(err.message || '获取章节失败')) .finally(() => setLoading(false)); - }, [chapters, chaptersLoaded, currentIndex, initialChapterHref, manifest, persistChapterProgress, stopTts]); + }, [ + chapters, + chaptersLoaded, + currentIndex, + initialChapterHref, + manifest, + persistChapterProgress, + stopTts, + ]); useEffect(() => { - window.dispatchEvent(new CustomEvent('books-read-update-header', { - detail: { - title: manifest.book.title, - subtitle: currentChapterTitle || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'), - backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, - }, - })); + window.dispatchEvent( + new CustomEvent('books-read-update-header', { + detail: { + title: manifest.book.title, + subtitle: + currentChapterTitle || + manifest.book.author || + (settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'), + backHref: `/books/detail?sourceId=${encodeURIComponent( + manifest.book.sourceId + )}&bookId=${encodeURIComponent(manifest.book.id)}`, + }, + }) + ); }, [manifest, currentChapterTitle, settings.mode]); const goPrevChapter = useCallback(() => { @@ -697,77 +925,89 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1)); }, [chapters.length, persistChapterProgress]); - const turnPage = useCallback((direction: 1 | -1) => { - const node = scrollRef.current; - if (!node) return; - if (settings.mode === 'scrolled') return; - const delta = Math.max(240, node.clientHeight * 0.88) * direction; - const maxTop = Math.max(0, node.scrollHeight - node.clientHeight); - const nextTop = Math.max(0, Math.min(maxTop, node.scrollTop + delta)); - if (direction > 0 && node.scrollTop >= maxTop - 8) { - if (currentIndex < chapters.length - 1) goNextChapter(); - return; - } - if (direction < 0 && node.scrollTop <= 8) { - if (currentIndex > 0) goPrevChapter(); - return; - } - node.scrollTo({ top: nextTop, behavior: 'smooth' }); - }, [chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode]); + const turnPage = useCallback( + (direction: 1 | -1) => { + const node = scrollRef.current; + if (!node) return; + if (settings.mode === 'scrolled') return; + const delta = Math.max(240, node.clientHeight * 0.88) * direction; + const maxTop = Math.max(0, node.scrollHeight - node.clientHeight); + const nextTop = Math.max(0, Math.min(maxTop, node.scrollTop + delta)); + if (direction > 0 && node.scrollTop >= maxTop - 8) { + if (currentIndex < chapters.length - 1) goNextChapter(); + return; + } + if (direction < 0 && node.scrollTop <= 8) { + if (currentIndex > 0) goPrevChapter(); + return; + } + node.scrollTo({ top: nextTop, behavior: 'smooth' }); + }, + [chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode] + ); const getChapterPlainText = useCallback(() => { const html = chapter?.content || ''; if (!html) return ''; - if (typeof document === 'undefined') return sanitizeTtsText(html.replace(/<[^>]*>/g, ' ')); + if (typeof document === 'undefined') + return sanitizeTtsText(html.replace(/<[^>]*>/g, ' ')); const div = document.createElement('div'); div.innerHTML = html; div.querySelectorAll('script,style,img').forEach((node) => node.remove()); return sanitizeTtsText(div.innerText || div.textContent || ''); }, [chapter]); - const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => { - if (!manifest) throw new Error('书籍信息未准备好'); - const response = await fetch('/api/books/tts/synthesize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourceId: manifest.book.sourceId, - bookId: manifest.book.id, - chapterHref, - text: chunk.text, - voice: ttsSettingsRef.current.voice, - rate: ttsSettingsRef.current.rate, - pitch: ttsSettingsRef.current.pitch, - volume: ttsSettingsRef.current.volume, - }), - }); - const json = await response.json(); - if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); - return URL.createObjectURL(decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg')); - }, [manifest]); + const fetchTtsChunkAudioUrl = useCallback( + async (chunk: TtsChunk, chapterHref: string) => { + if (!manifest) throw new Error('书籍信息未准备好'); + const response = await fetch('/api/books/tts/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + chapterHref, + text: chunk.text, + voice: ttsSettingsRef.current.voice, + rate: ttsSettingsRef.current.rate, + pitch: ttsSettingsRef.current.pitch, + volume: ttsSettingsRef.current.volume, + }), + }); + const json = await response.json(); + if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); + return URL.createObjectURL( + decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg') + ); + }, + [manifest] + ); - const playTtsChunk = useCallback(async (index: number) => { - const chunks = ttsChunksRef.current; - const chunk = chunks[index]; - if (!chunk || !currentChapterHref) return; - try { - setTtsError(''); - setTtsLoadingChunkIndex(index); - setTtsStatus('loading'); - const url = await fetchTtsChunkAudioUrl(chunk, currentChapterHref); - if (!audioRef.current) audioRef.current = new Audio(); - audioRef.current.src = url; - await audioRef.current.play(); - ttsCurrentChunkIndexRef.current = index; - setTtsCurrentChunkIndex(index); - setTtsStatus('playing'); - setTtsLoadingChunkIndex(null); - } catch (err) { - setTtsStatus('error'); - setTtsLoadingChunkIndex(null); - setTtsError((err as Error).message || '朗读失败'); - } - }, [currentChapterHref, fetchTtsChunkAudioUrl]); + const playTtsChunk = useCallback( + async (index: number) => { + const chunks = ttsChunksRef.current; + const chunk = chunks[index]; + if (!chunk || !currentChapterHref) return; + try { + setTtsError(''); + setTtsLoadingChunkIndex(index); + setTtsStatus('loading'); + const url = await fetchTtsChunkAudioUrl(chunk, currentChapterHref); + if (!audioRef.current) audioRef.current = new Audio(); + audioRef.current.src = url; + await audioRef.current.play(); + ttsCurrentChunkIndexRef.current = index; + setTtsCurrentChunkIndex(index); + setTtsStatus('playing'); + setTtsLoadingChunkIndex(null); + } catch (err) { + setTtsStatus('error'); + setTtsLoadingChunkIndex(null); + setTtsError((err as Error).message || '朗读失败'); + } + }, + [currentChapterHref, fetchTtsChunkAudioUrl] + ); const bootstrapTts = useCallback(async () => { if (!ttsAvailable) return; @@ -842,7 +1082,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { useEffect(() => { const node = scrollRef.current; if (!node) return; - node.addEventListener('scroll', scheduleChapterProgressSave, { passive: true }); + node.addEventListener('scroll', scheduleChapterProgressSave, { + passive: true, + }); return () => { node.removeEventListener('scroll', scheduleChapterProgressSave); if (chapterSaveTimerRef.current) { @@ -851,7 +1093,13 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { } persistChapterProgress(); }; - }, [chapter, currentChapterHref, loading, persistChapterProgress, scheduleChapterProgressSave]); + }, [ + chapter, + currentChapterHref, + loading, + persistChapterProgress, + scheduleChapterProgressSave, + ]); useEffect(() => { const flush = () => persistChapterProgress(); @@ -882,9 +1130,16 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { setTtsDuration(audio.duration || 0); if (!ttsSeekingRef.current) setTtsSeekValue(audio.currentTime || 0); }; - const handlePause = () => { if (!audio.ended && ttsStatusRef.current === 'playing') setTtsStatus('paused'); }; + const handlePause = () => { + if (!audio.ended && ttsStatusRef.current === 'playing') + setTtsStatus('paused'); + }; const handleLoadedMetadata = () => { - if (ttsResumeTimeRef.current > 0 && audio.duration > 0) audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, audio.duration - 0.25)); + if (ttsResumeTimeRef.current > 0 && audio.duration > 0) + audio.currentTime = Math.min( + ttsResumeTimeRef.current, + Math.max(0, audio.duration - 0.25) + ); ttsResumeTimeRef.current = 0; setTtsDuration(audio.duration || 0); }; @@ -901,13 +1156,18 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { }; }, [playTtsChunk, stopTts]); - const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice); + const selectedVoice = ttsVoices.find( + (item) => item.shortName === ttsSettings.voice + ); const currentChunk = ttsChunks[ttsCurrentChunkIndex]; const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%'); const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz'); const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%'); const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime; - const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0; + const ttsChunkPercent = + ttsChunks.length > 0 + ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 + : 0; const palette = THEME_STYLES[settings.theme]; if (error) return
{error}
; @@ -918,7 +1178,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
-
章节加载中...
+
+ 章节加载中... +
); @@ -927,62 +1189,525 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { return (
- 暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB 文件;如果详情接口显示章节数为 0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。 + 暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB + 文件;如果详情接口显示章节数为 + 0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。
); } return ( -
- {tocOpen && typeof document !== 'undefined' ? createPortal( -
setTocOpen(false)}> -
event.stopPropagation()}> -
- {chapters.map((item, index) => { - const active = index === currentIndex; - return ; - })} -
-
-
, document.body +
+ {tocOpen && typeof document !== 'undefined' + ? createPortal( +
setTocOpen(false)} + > +
event.stopPropagation()} + > +
+ {chapters.map((item, index) => { + const active = index === currentIndex; + return ( + + ); + })} +
+
+
, + document.body + ) + : null} + + {settingsOpen && typeof document !== 'undefined' + ? createPortal( +
setSettingsOpen(false)} + > +
event.stopPropagation()} + > +
+
+ 阅读设置 +
+
+ Legado 源支持翻页和滚动阅读 +
+
+
+
+
阅读模式
+
+ {( + [ + { + key: 'paginated', + label: '翻页模式', + desc: '左右点击翻页/章节', + }, + { + key: 'scrolled', + label: '滚动模式', + desc: '上下连续滚动', + }, + ] as { key: ReaderMode; label: string; desc: string }[] + ).map((mode) => ( + + ))} +
+
+
+
主题
+
+ {(['light', 'sepia', 'dark'] as ReaderTheme[]).map( + (theme) => ( + + ) + )} +
+
+
+
+ 字号 {settings.fontSize}% +
+ + setSettings((prev) => ({ + ...prev, + fontSize: Number(e.target.value), + })) + } + className='w-full' + /> +
+
+
+ 行距 {settings.lineHeight.toFixed(1)} +
+ + setSettings((prev) => ({ + ...prev, + lineHeight: Number(e.target.value), + })) + } + className='w-full' + /> +
+
+ +
+
+
+
, + document.body + ) + : null} + + {settings.mode === 'paginated' && !tocOpen && !settingsOpen ? ( + <> + )}
-
主题
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => )}
-
字号 {settings.fontSize}%
setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' />
-
行距 {settings.lineHeight.toFixed(1)}
setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' />
-
+
+
+ {loading ? ( +
+
+ +
+ 加载中... +
+
-
-
, document.body - ) : null} - - {settings.mode === 'paginated' && !tocOpen && !settingsOpen ? <>
: null} + {settings.mode === 'scrolled' ? ( +
+ + +
+ ) : null}
- {ttsBarVisible ? <> -
-
-
setTtsSeeking(true)} onChange={(e) => setTtsSeekValue(Number(e.target.value))} onPointerUp={(e) => { const next = Number((e.target as HTMLInputElement).value); if (audioRef.current && Number.isFinite(next)) audioRef.current.currentTime = next; setTtsCurrentTime(next); setTtsSeeking(false); }} className='w-full accent-sky-500' />
-
{currentChapterTitle || '语音朗读'}
{!ttsAvailable ? '服务异常' : ttsStatus === 'playing' ? '正在播放' : ttsStatus === 'paused' ? '已暂停' : ttsLoadingChunkIndex !== null ? '生成语音中...' : '待播放'}{ttsChunks.length > 0 ? {ttsCurrentChunkIndex + 1}/{ttsChunks.length} : null}
{selectedVoice?.displayName || '默认音色'}{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}
+ {ttsBarVisible ? ( + <> +
+
+
+ setTtsSeeking(true)} + onChange={(e) => setTtsSeekValue(Number(e.target.value))} + onPointerUp={(e) => { + const next = Number((e.target as HTMLInputElement).value); + if (audioRef.current && Number.isFinite(next)) + audioRef.current.currentTime = next; + setTtsCurrentTime(next); + setTtsSeeking(false); + }} + className='w-full accent-emerald-500' + /> +
+
+
+ +
+
+ {currentChapterTitle || '语音朗读'} +
+
+ + {!ttsAvailable + ? '服务异常' + : ttsStatus === 'playing' + ? '正在播放' + : ttsStatus === 'paused' + ? '已暂停' + : ttsLoadingChunkIndex !== null + ? '生成语音中...' + : '待播放'} + + {ttsChunks.length > 0 ? ( + + {ttsCurrentChunkIndex + 1}/{ttsChunks.length} + + ) : null} +
+
+ +
+
+ {selectedVoice?.displayName || '默认音色'} + + {formatDurationTime(displayedTtsTime)} /{' '} + {formatDurationTime(ttsDuration || 0)} + +
+
+
-
- {ttsPanelOpen ?
听书控制
{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}{Math.round(ttsChunkPercent)}%
{ttsError ?
{ttsError}
: null}
: null} - : null} + {ttsPanelOpen ? ( +
+
+
+
+ + 听书控制 +
+ +
+
+ + {currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'} + + + {Math.round(ttsChunkPercent)}% + +
+
+ + + + +
+ + + + + {ttsError ? ( +
{ttsError}
+ ) : null} +
+
+ ) : null} + + ) : null}
); } @@ -991,9 +1716,18 @@ function normalizeHrefForMatch(href?: string) { if (!href) return ''; try { const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim(); - return normalized.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, ''); + return normalized + .split('#')[0] + .split('?')[0] + .replace(/^\.\//, '') + .replace(/^\//, ''); } catch { - return href.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '').trim(); + return href + .split('#')[0] + .split('?')[0] + .replace(/^\.\//, '') + .replace(/^\//, '') + .trim(); } } @@ -1001,7 +1735,9 @@ function isSameTocTarget(currentHref?: string, tocHref?: string) { const current = normalizeHrefForMatch(currentHref); const target = normalizeHrefForMatch(tocHref); if (!current || !target) return false; - return current === target || current.endsWith(target) || target.endsWith(current); + return ( + current === target || current.endsWith(target) || target.endsWith(current) + ); } function formatBytes(size: number): string { @@ -1014,7 +1750,10 @@ function formatDurationTime(value: number) { const totalSeconds = Math.max(0, Math.floor(value || 0)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; - return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart( + 2, + '0' + )}`; } function sanitizeTtsText(text: string): string { @@ -1031,7 +1770,10 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] { const normalized = sanitizeTtsText(text); if (!normalized) return []; - const paragraphs = normalized.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean); + const paragraphs = normalized + .split(/\n{2,}/) + .map((item) => item.trim()) + .filter(Boolean); const chunks: TtsChunk[] = []; let buffer = ''; let start = 0; @@ -1070,7 +1812,10 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] { return; } - const sentences = trimmed.split(/(?<=[。!?!?;;])/).map((item) => item.trim()).filter(Boolean); + const sentences = trimmed + .split(/(?<=[。!?!?;;])/) + .map((item) => item.trim()) + .filter(Boolean); let local = ''; let localStart = cursor; for (const sentence of sentences) { @@ -1081,7 +1826,12 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] { cursor += sentence.length; } else { if (local) { - chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); + chunks.push({ + index: chunks.length, + text: local, + start: localStart, + end: localStart + local.length, + }); local = ''; } if (sentence.length <= maxChars) { @@ -1091,14 +1841,24 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] { } else { for (let i = 0; i < sentence.length; i += maxChars) { const part = sentence.slice(i, i + maxChars); - chunks.push({ index: chunks.length, text: part, start: cursor, end: cursor + part.length }); + chunks.push({ + index: chunks.length, + text: part, + start: cursor, + end: cursor + part.length, + }); cursor += part.length; } } } } if (local) { - chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); + chunks.push({ + index: chunks.length, + text: local, + start: localStart, + end: localStart + local.length, + }); } }; @@ -1109,7 +1869,6 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] { return chunks; } - function getRenditionOptions(mode: ReaderMode) { return mode === 'scrolled' ? { @@ -1141,18 +1900,24 @@ export default function BookReadPage() { const searchParams = useSearchParams(); const sourceId = searchParams.get('sourceId') || ''; const bookId = searchParams.get('bookId') || ''; - const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]); + const cached = useMemo( + () => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), + [sourceId, bookId] + ); const [manifest, setManifest] = useState(null); const [error, setError] = useState(''); const [ready, setReady] = useState(false); - const [fileLoadState, setFileLoadState] = useState('preparing'); + const [fileLoadState, setFileLoadState] = + useState('preparing'); const [downloadedBytes, setDownloadedBytes] = useState(0); const [totalBytes, setTotalBytes] = useState(null); const [cacheHit, setCacheHit] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [tocOpen, setTocOpen] = useState(false); const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [ttsSettings, setTtsSettings] = useState(() => loadTtsSettings()); + const [ttsSettings, setTtsSettings] = useState(() => + loadTtsSettings() + ); const [tocItems, setTocItems] = useState([]); const [currentHref, setCurrentHref] = useState(''); const [currentChapter, setCurrentChapter] = useState(''); @@ -1165,7 +1930,9 @@ export default function BookReadPage() { const [ttsError, setTtsError] = useState(''); const [ttsChunks, setTtsChunks] = useState([]); const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); - const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState(null); + const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState< + number | null + >(null); const [ttsCurrentChapterHref, setTtsCurrentChapterHref] = useState(''); const [ttsCurrentChapterTitle, setTtsCurrentChapterTitle] = useState(''); const [ttsBarVisible, setTtsBarVisible] = useState(false); @@ -1176,7 +1943,9 @@ export default function BookReadPage() { const [ttsSeeking, setTtsSeeking] = useState(false); const [scrolledBottomReached, setScrolledBottomReached] = useState(false); const viewerRef = useRef(null); - const pendingScrolledRestoreRef = useRef(null); + const pendingScrolledRestoreRef = useRef( + null + ); const restoreTargetRef = useRef(undefined); const scrollListenerCleanupRef = useRef<(() => void) | null>(null); const scrolledAutoAdvanceLockRef = useRef(false); @@ -1201,7 +1970,9 @@ export default function BookReadPage() { const currentHrefRef = useRef(''); const audioRef = useRef(null); const ttsChunkAudioUrlRef = useRef>({}); - const ttsChunkBlobCacheRef = useRef>({}); + const ttsChunkBlobCacheRef = useRef< + Record + >({}); const ttsChunksRef = useRef([]); const ttsSettingsRef = useRef(DEFAULT_TTS_SETTINGS); const ttsCurrentChunkIndexRef = useRef(0); @@ -1226,7 +1997,10 @@ export default function BookReadPage() { useEffect(() => { if (typeof window !== 'undefined') { - localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings)); + localStorage.setItem( + TTS_SETTINGS_STORAGE_KEY, + JSON.stringify(ttsSettings) + ); } ttsSettingsRef.current = ttsSettings; }, [ttsSettings]); @@ -1250,7 +2024,6 @@ export default function BookReadPage() { scrolledBottomReachedRef.current = scrolledBottomReached; }, [scrolledBottomReached]); - useEffect(() => { const handleToggleSettings = () => { if (manifest?.format === 'chapters') return; @@ -1260,7 +2033,10 @@ export default function BookReadPage() { window.addEventListener('books-read-toggle-settings', handleToggleSettings); return () => { - window.removeEventListener('books-read-toggle-settings', handleToggleSettings); + window.removeEventListener( + 'books-read-toggle-settings', + handleToggleSettings + ); }; }, [manifest?.format]); @@ -1273,7 +2049,10 @@ export default function BookReadPage() { window.addEventListener('books-read-toggle-chapters', handleToggleChapters); return () => { - window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); + window.removeEventListener( + 'books-read-toggle-chapters', + handleToggleChapters + ); }; }, [manifest?.format]); @@ -1297,7 +2076,6 @@ export default function BookReadPage() { }; }, [manifest?.format]); - useEffect(() => { if (!sourceId || !bookId) return; fetch('/api/books/read/manifest', { @@ -1342,7 +2120,10 @@ export default function BookReadPage() { setTtsAvailable(true); setTtsVoices(json.voices || []); setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults)); - saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} }); + saveCachedTtsVoices({ + voices: json.voices || [], + defaults: json.defaults || {}, + }); }) .catch((err) => { if (cancelled) return; @@ -1355,43 +2136,54 @@ export default function BookReadPage() { }; }, [manifest]); - const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => { - if (!manifest) return null; - const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; - if (!locatorValue) return null; - 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: manifest.format, - locator: { - type: 'epub-cfi', - value: locatorValue, - href: location?.start?.href, + const buildReadRecord = useCallback( + ( + location: EpubLocation, + nextProgress = 0, + chapterTitle?: string + ): BookReadRecord | null => { + if (!manifest) return null; + const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; + if (!locatorValue) return null; + 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: manifest.format, + locator: { + type: 'epub-cfi', + value: locatorValue, + href: location?.start?.href, + chapterTitle, + }, chapterTitle, - }, - chapterTitle, - chapterHref: location?.start?.href, - progressPercent: nextProgress, - saveTime: Date.now(), - }; - }, [manifest]); + chapterHref: location?.start?.href, + progressPercent: nextProgress, + saveTime: Date.now(), + }; + }, + [manifest] + ); - const queueReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { - const record = buildReadRecord(location, nextProgress, chapterTitle); - if (!record) return; - pendingRecordRef.current = record; - pendingRecordDirtyRef.current = true; - }, [buildReadRecord]); + const queueReadRecord = useCallback( + (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { + const record = buildReadRecord(location, nextProgress, chapterTitle); + if (!record) return; + pendingRecordRef.current = record; + pendingRecordDirtyRef.current = true; + }, + [buildReadRecord] + ); const flushPendingReadRecord = useCallback(async () => { const record = pendingRecordRef.current; - if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) return; + if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) + return; saveInFlightRef.current = true; try { @@ -1408,21 +2200,26 @@ export default function BookReadPage() { } }, []); - - const persistScrolledPosition = useCallback((fallbackHref?: string) => { - if (!manifest || settingsRef.current.mode !== 'scrolled') return; - const metrics = getIframeScrollMetrics(viewerRef.current); - const href = fallbackHref || currentHrefRef.current || lastLocationRef.current?.start?.href || ''; - if (!metrics || !href) return; - saveScrolledPosition(manifest.book.sourceId, manifest.book.id, { - href, - scrollTop: metrics.scrollTop, - scrollHeight: metrics.scrollHeight, - clientHeight: metrics.clientHeight, - updatedAt: Date.now(), - }); - }, [manifest]); - + const persistScrolledPosition = useCallback( + (fallbackHref?: string) => { + if (!manifest || settingsRef.current.mode !== 'scrolled') return; + const metrics = getIframeScrollMetrics(viewerRef.current); + const href = + fallbackHref || + currentHrefRef.current || + lastLocationRef.current?.start?.href || + ''; + if (!metrics || !href) return; + saveScrolledPosition(manifest.book.sourceId, manifest.book.id, { + href, + scrollTop: metrics.scrollTop, + scrollHeight: metrics.scrollHeight, + clientHeight: metrics.clientHeight, + updatedAt: Date.now(), + }); + }, + [manifest] + ); const applyPendingScrolledRestore = useCallback(() => { if (settingsRef.current.mode !== 'scrolled') return; @@ -1430,25 +2227,30 @@ export default function BookReadPage() { if (!pending) return; const metrics = getIframeScrollMetrics(viewerRef.current); if (!metrics) return; - const currentHrefValue = lastLocationRef.current?.start?.href || currentHrefRef.current; - if (!currentHrefValue || !isSameTocTarget(currentHrefValue, pending.href)) return; - const targetScrollTop = computeScrolledTargetScrollTop(pending, metrics.scrollHeight, metrics.clientHeight); + const currentHrefValue = + lastLocationRef.current?.start?.href || currentHrefRef.current; + if (!currentHrefValue || !isSameTocTarget(currentHrefValue, pending.href)) + return; + const targetScrollTop = computeScrolledTargetScrollTop( + pending, + metrics.scrollHeight, + metrics.clientHeight + ); metrics.setScrollTop(targetScrollTop); pendingScrolledRestoreRef.current = null; }, []); - - - - - useEffect(() => { applyPendingScrolledRestoreRef.current = applyPendingScrolledRestore; }, [applyPendingScrolledRestore]); const persistCurrentProgress = useCallback(() => { if (lastLocationRef.current) { - queueReadRecord(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current); + queueReadRecord( + lastLocationRef.current, + lastProgressRef.current, + lastChapterRef.current + ); } persistScrolledPosition(); void flushPendingReadRecord(); @@ -1483,233 +2285,285 @@ export default function BookReadPage() { await renditionRef.current.display(target); }, []); - const handleReaderTap = useCallback((zone: 'left' | 'center' | 'right') => { - if (!ready) return; - if (settings.mode === 'paginated' && zone === 'left') { - renditionRef.current?.prev?.(); - return; - } - if (settings.mode === 'paginated' && zone === 'right') { - renditionRef.current?.next?.(); - return; - } - setTocOpen(false); - setSettingsOpen(false); - }, [ready, settings.mode]); + const handleReaderTap = useCallback( + (zone: 'left' | 'center' | 'right') => { + if (!ready) return; + if (settings.mode === 'paginated' && zone === 'left') { + renditionRef.current?.prev?.(); + return; + } + if (settings.mode === 'paginated' && zone === 'right') { + renditionRef.current?.next?.(); + return; + } + setTocOpen(false); + setSettingsOpen(false); + }, + [ready, settings.mode] + ); const cleanupTtsAudioUrls = useCallback(() => { - Object.values(ttsChunkBlobCacheRef.current).forEach((item) => URL.revokeObjectURL(item.url)); + Object.values(ttsChunkBlobCacheRef.current).forEach((item) => + URL.revokeObjectURL(item.url) + ); ttsChunkBlobCacheRef.current = {}; ttsChunkAudioUrlRef.current = {}; }, []); - const stopTts = useCallback((clearQueue = false) => { - const audio = audioRef.current; - if (audio) { - audio.pause(); - audio.removeAttribute('src'); - audio.load(); - } - setTtsCurrentTime(0); - setTtsDuration(0); - setTtsSeekValue(0); - setTtsSeeking(false); - ttsPrefetchedFromChunkRef.current = null; - setTtsLoadingChunkIndex(null); - setTtsStatus('idle'); - if (clearQueue) { - setTtsChunks([]); - ttsChunksRef.current = []; - setTtsCurrentChunkIndex(0); - ttsCurrentChunkIndexRef.current = 0; - setTtsCurrentChapterHref(''); - ttsCurrentChapterHrefRef.current = ''; - setTtsCurrentChapterTitle(''); - ttsCurrentChapterTitleRef.current = ''; - cleanupTtsAudioUrls(); - } - }, [cleanupTtsAudioUrls]); + const stopTts = useCallback( + (clearQueue = false) => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + setTtsCurrentTime(0); + setTtsDuration(0); + setTtsSeekValue(0); + setTtsSeeking(false); + ttsPrefetchedFromChunkRef.current = null; + setTtsLoadingChunkIndex(null); + setTtsStatus('idle'); + if (clearQueue) { + setTtsChunks([]); + ttsChunksRef.current = []; + setTtsCurrentChunkIndex(0); + ttsCurrentChunkIndexRef.current = 0; + setTtsCurrentChapterHref(''); + ttsCurrentChapterHrefRef.current = ''; + setTtsCurrentChapterTitle(''); + ttsCurrentChapterTitleRef.current = ''; + cleanupTtsAudioUrls(); + } + }, + [cleanupTtsAudioUrls] + ); - const persistTtsProgress = useCallback((chunkIndex?: number) => { - if (!manifest) return; - const chunks = ttsChunksRef.current; - const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current; - const chunk = chunks[currentIndex]; - if (!chunk || !ttsCurrentChapterHrefRef.current || !ttsSettingsRef.current.voice) return; - const progress: BookTtsProgress = { - sourceId: manifest.book.sourceId, - bookId: manifest.book.id, - chapterHref: ttsCurrentChapterHrefRef.current, - chapterTitle: ttsCurrentChapterTitleRef.current || currentChapter, - chunkIndex: currentIndex, - charOffset: chunk.start, - currentTimeSec: audioRef.current?.currentTime || 0, - voice: ttsSettingsRef.current.voice, - rate: ttsSettingsRef.current.rate, - pitch: ttsSettingsRef.current.pitch, - volume: ttsSettingsRef.current.volume, - saveTime: Date.now(), - }; - saveBookTtsProgress(progress); - }, [manifest, currentChapter]); + const persistTtsProgress = useCallback( + (chunkIndex?: number) => { + if (!manifest) return; + const chunks = ttsChunksRef.current; + const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current; + const chunk = chunks[currentIndex]; + if ( + !chunk || + !ttsCurrentChapterHrefRef.current || + !ttsSettingsRef.current.voice + ) + return; + const progress: BookTtsProgress = { + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + chapterHref: ttsCurrentChapterHrefRef.current, + chapterTitle: ttsCurrentChapterTitleRef.current || currentChapter, + chunkIndex: currentIndex, + charOffset: chunk.start, + currentTimeSec: audioRef.current?.currentTime || 0, + voice: ttsSettingsRef.current.voice, + rate: ttsSettingsRef.current.rate, + pitch: ttsSettingsRef.current.pitch, + volume: ttsSettingsRef.current.volume, + saveTime: Date.now(), + }; + saveBookTtsProgress(progress); + }, + [manifest, currentChapter] + ); const getCurrentSpineDocumentText = useCallback(() => { const iframe = viewerRef.current?.querySelector('iframe'); const doc = iframe?.contentDocument; - const text = doc?.body?.innerText || doc?.documentElement?.textContent || ''; + const text = + doc?.body?.innerText || doc?.documentElement?.textContent || ''; return sanitizeTtsText(text); }, []); - const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => { - const cached = ttsChunkBlobCacheRef.current[chunk.index]; - if (cached?.text === chunk.text) return cached.url; - if (!manifest) throw new Error('书籍信息未准备好'); - const { cacheKey, textHash } = await buildBookTtsCacheKey({ - sourceId: manifest.book.sourceId, - bookId: manifest.book.id, - chapterHref, - chunkIndex: chunk.index, - text: chunk.text, - voice: ttsSettingsRef.current.voice, - rate: ttsSettingsRef.current.rate, - pitch: ttsSettingsRef.current.pitch, - volume: ttsSettingsRef.current.volume, - }); - - const persisted = await getCachedBookTtsChunk(cacheKey).catch(() => null); - if (persisted?.audioBlob) { - const url = URL.createObjectURL(persisted.audioBlob); - ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text }; - ttsChunkAudioUrlRef.current[chunk.index] = url; - void touchCachedBookTtsChunk(cacheKey).catch(() => undefined); - return url; - } - - const response = await fetch('/api/books/tts/synthesize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const fetchTtsChunkAudioUrl = useCallback( + async (chunk: TtsChunk, chapterHref: string) => { + const cached = ttsChunkBlobCacheRef.current[chunk.index]; + if (cached?.text === chunk.text) return cached.url; + if (!manifest) throw new Error('书籍信息未准备好'); + const { cacheKey, textHash } = await buildBookTtsCacheKey({ sourceId: manifest.book.sourceId, bookId: manifest.book.id, chapterHref, + chunkIndex: chunk.index, text: chunk.text, voice: ttsSettingsRef.current.voice, rate: ttsSettingsRef.current.rate, pitch: ttsSettingsRef.current.pitch, volume: ttsSettingsRef.current.volume, - }), - }); - const json = await response.json(); - if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); - const blob = decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg'); - const url = URL.createObjectURL(blob); - if (cached?.url) URL.revokeObjectURL(cached.url); - ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text }; - ttsChunkAudioUrlRef.current[chunk.index] = url; - void putCachedBookTtsChunk({ - cacheKey, - sourceId: manifest.book.sourceId, - bookId: manifest.book.id, - chapterHref, - chunkIndex: chunk.index, - textHash, - voice: ttsSettingsRef.current.voice, - rate: ttsSettingsRef.current.rate, - pitch: ttsSettingsRef.current.pitch, - volume: ttsSettingsRef.current.volume, - textPreview: chunk.text.slice(0, 80), - mimeType: json.mimeType || 'audio/mpeg', - audioBlob: blob, - size: blob.size, - createdAt: Date.now(), - lastAccessAt: Date.now(), - }) - .then(() => enforceBookTtsCacheLimit()) - .catch(() => undefined); - return url; - }, [manifest]); + }); - const prefetchTtsChunks = useCallback((fromIndex: number) => { - const chunks = ttsChunksRef.current; - const chapterHref = ttsCurrentChapterHrefRef.current; - if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return; - if (ttsPrefetchedFromChunkRef.current === fromIndex) return; - const nextIndex = fromIndex + 1; - if (nextIndex >= chunks.length) return; - ttsPrefetchedFromChunkRef.current = fromIndex; - void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(() => undefined); - }, [fetchTtsChunkAudioUrl]); + const persisted = await getCachedBookTtsChunk(cacheKey).catch(() => null); + if (persisted?.audioBlob) { + const url = URL.createObjectURL(persisted.audioBlob); + ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text }; + ttsChunkAudioUrlRef.current[chunk.index] = url; + void touchCachedBookTtsChunk(cacheKey).catch(() => undefined); + return url; + } + + const response = await fetch('/api/books/tts/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + chapterHref, + text: chunk.text, + voice: ttsSettingsRef.current.voice, + rate: ttsSettingsRef.current.rate, + pitch: ttsSettingsRef.current.pitch, + volume: ttsSettingsRef.current.volume, + }), + }); + const json = await response.json(); + if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); + const blob = decodeBase64Audio( + json.audioBase64 || '', + json.mimeType || 'audio/mpeg' + ); + const url = URL.createObjectURL(blob); + if (cached?.url) URL.revokeObjectURL(cached.url); + ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text }; + ttsChunkAudioUrlRef.current[chunk.index] = url; + void putCachedBookTtsChunk({ + cacheKey, + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + chapterHref, + chunkIndex: chunk.index, + textHash, + voice: ttsSettingsRef.current.voice, + rate: ttsSettingsRef.current.rate, + pitch: ttsSettingsRef.current.pitch, + volume: ttsSettingsRef.current.volume, + textPreview: chunk.text.slice(0, 80), + mimeType: json.mimeType || 'audio/mpeg', + audioBlob: blob, + size: blob.size, + createdAt: Date.now(), + lastAccessAt: Date.now(), + }) + .then(() => enforceBookTtsCacheLimit()) + .catch(() => undefined); + return url; + }, + [manifest] + ); + + const prefetchTtsChunks = useCallback( + (fromIndex: number) => { + const chunks = ttsChunksRef.current; + const chapterHref = ttsCurrentChapterHrefRef.current; + if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return; + if (ttsPrefetchedFromChunkRef.current === fromIndex) return; + const nextIndex = fromIndex + 1; + if (nextIndex >= chunks.length) return; + ttsPrefetchedFromChunkRef.current = fromIndex; + void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch( + () => undefined + ); + }, + [fetchTtsChunkAudioUrl] + ); useEffect(() => { ttsPrefetchFnRef.current = prefetchTtsChunks; }, [prefetchTtsChunks]); - const playTtsChunk = useCallback(async (index: number) => { - const chunks = ttsChunksRef.current; - const chunk = chunks[index]; - const chapterHref = ttsCurrentChapterHrefRef.current; - if (!chunk || !chapterHref || !manifest) return; - try { - setTtsError(''); - setTtsLoadingChunkIndex(index); - setTtsStatus('loading'); - const url = await fetchTtsChunkAudioUrl(chunk, chapterHref); - if (!audioRef.current) { - audioRef.current = new Audio(); + const playTtsChunk = useCallback( + async (index: number) => { + const chunks = ttsChunksRef.current; + const chunk = chunks[index]; + const chapterHref = ttsCurrentChapterHrefRef.current; + if (!chunk || !chapterHref || !manifest) return; + try { + setTtsError(''); + setTtsLoadingChunkIndex(index); + setTtsStatus('loading'); + const url = await fetchTtsChunkAudioUrl(chunk, chapterHref); + if (!audioRef.current) { + audioRef.current = new Audio(); + } + audioRef.current.src = url; + ttsResumeTimeRef.current = 0; + const saved = getBookTtsProgress( + manifest.book.sourceId, + manifest.book.id + ); + if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) { + ttsResumeTimeRef.current = saved.currentTimeSec || 0; + } + await audioRef.current.play(); + ttsCurrentChunkIndexRef.current = index; + setTtsCurrentChunkIndex(index); + setTtsStatus('playing'); + setTtsLoadingChunkIndex(null); + persistTtsProgress(index); + } catch (error) { + setTtsStatus('error'); + setTtsLoadingChunkIndex(null); + setTtsError((error as Error).message || '朗读失败'); } - audioRef.current.src = url; - ttsResumeTimeRef.current = 0; - const saved = getBookTtsProgress(manifest.book.sourceId, manifest.book.id); - if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) { - ttsResumeTimeRef.current = saved.currentTimeSec || 0; - } - await audioRef.current.play(); - ttsCurrentChunkIndexRef.current = index; - setTtsCurrentChunkIndex(index); - setTtsStatus('playing'); - setTtsLoadingChunkIndex(null); - persistTtsProgress(index); - } catch (error) { - setTtsStatus('error'); - setTtsLoadingChunkIndex(null); - setTtsError((error as Error).message || '朗读失败'); - } - }, [fetchTtsChunkAudioUrl, manifest, persistTtsProgress]); + }, + [fetchTtsChunkAudioUrl, manifest, persistTtsProgress] + ); - const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => { - if (!manifest || manifest.format !== 'epub') return; - const chapterHref = currentHref || manifest.lastRecord?.chapterHref || ''; - const chapterTitle = findTocLabelByHref(tocItemsRef.current, chapterHref) || currentChapter || manifest.book.title; - if (!chapterHref) { - setTtsError('当前章节尚未定位,稍后再试'); - setTtsStatus('error'); - return; - } - const text = getCurrentSpineDocumentText(); - if (!text) { - setTtsError('当前章节暂未提取到可朗读文本'); - setTtsStatus('error'); - return; - } - cleanupTtsAudioUrls(); - const chunks = chunkTtsText(text, 1200); - if (chunks.length === 0) { - setTtsError('当前章节没有可朗读内容'); - setTtsStatus('error'); - return; - } - const saved = resume ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id) : null; - const startIndex = saved?.chapterHref === chapterHref ? Math.min(saved.chunkIndex, chunks.length - 1) : 0; - setTtsChunks(chunks); - ttsChunksRef.current = chunks; - setTtsCurrentChunkIndex(startIndex); - ttsCurrentChunkIndexRef.current = startIndex; - setTtsCurrentChapterHref(chapterHref); - ttsCurrentChapterHrefRef.current = chapterHref; - setTtsCurrentChapterTitle(chapterTitle); - ttsCurrentChapterTitleRef.current = chapterTitle; - await playTtsChunk(startIndex); - }, [cleanupTtsAudioUrls, currentChapter, currentHref, getCurrentSpineDocumentText, manifest, playTtsChunk]); + const bootstrapTtsForCurrentChapter = useCallback( + async (resume = true) => { + if (!manifest || manifest.format !== 'epub') return; + const chapterHref = currentHref || manifest.lastRecord?.chapterHref || ''; + const chapterTitle = + findTocLabelByHref(tocItemsRef.current, chapterHref) || + currentChapter || + manifest.book.title; + if (!chapterHref) { + setTtsError('当前章节尚未定位,稍后再试'); + setTtsStatus('error'); + return; + } + const text = getCurrentSpineDocumentText(); + if (!text) { + setTtsError('当前章节暂未提取到可朗读文本'); + setTtsStatus('error'); + return; + } + cleanupTtsAudioUrls(); + const chunks = chunkTtsText(text, 1200); + if (chunks.length === 0) { + setTtsError('当前章节没有可朗读内容'); + setTtsStatus('error'); + return; + } + const saved = resume + ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id) + : null; + const startIndex = + saved?.chapterHref === chapterHref + ? Math.min(saved.chunkIndex, chunks.length - 1) + : 0; + setTtsChunks(chunks); + ttsChunksRef.current = chunks; + setTtsCurrentChunkIndex(startIndex); + ttsCurrentChunkIndexRef.current = startIndex; + setTtsCurrentChapterHref(chapterHref); + ttsCurrentChapterHrefRef.current = chapterHref; + setTtsCurrentChapterTitle(chapterTitle); + ttsCurrentChapterTitleRef.current = chapterTitle; + await playTtsChunk(startIndex); + }, + [ + cleanupTtsAudioUrls, + currentChapter, + currentHref, + getCurrentSpineDocumentText, + manifest, + playTtsChunk, + ] + ); const toggleTtsPlayback = useCallback(async () => { if (!ttsAvailable) return; @@ -1730,46 +2584,70 @@ export default function BookReadPage() { return; } await bootstrapTtsForCurrentChapter(true); - }, [bootstrapTtsForCurrentChapter, persistTtsProgress, ttsAvailable, ttsStatus]); - - - - - + }, [ + bootstrapTtsForCurrentChapter, + persistTtsProgress, + ttsAvailable, + ttsStatus, + ]); useEffect(() => { if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return; let destroyed = false; const currentSessionCfi = lastLocationRef.current?.start?.cfi || undefined; - const currentSessionHref = currentHrefRef.current || lastLocationRef.current?.start?.href || undefined; + const currentSessionHref = + currentHrefRef.current || + lastLocationRef.current?.start?.href || + undefined; setReady(false); setRestoredMessage(''); locationsReadyRef.current = false; lastLocationRef.current = null; setProgressPercent(manifest.lastRecord?.progressPercent || 0); - setCurrentChapter(manifest.lastRecord?.chapterTitle || manifest.lastRecord?.locator?.chapterTitle || ''); + setCurrentChapter( + manifest.lastRecord?.chapterTitle || + manifest.lastRecord?.locator?.chapterTitle || + '' + ); setFileLoadState('checking-cache'); setDownloadedBytes(0); setTotalBytes(null); setCacheHit(false); - const initialScrolledHref = currentSessionHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || undefined; - const cachedScrolledPosition = initialScrolledHref ? getScrolledPosition(manifest.book.sourceId, manifest.book.id, initialScrolledHref) : null; - pendingScrolledRestoreRef.current = settings.mode === 'scrolled' && !currentSessionHref ? cachedScrolledPosition : null; - restoreTargetRef.current = settings.mode === 'scrolled' - ? (initialScrolledHref || cachedScrolledPosition?.href || undefined) - : (currentSessionCfi || manifest.lastRecord?.locator?.value || undefined); + const initialScrolledHref = + currentSessionHref || + manifest.lastRecord?.chapterHref || + manifest.lastRecord?.locator?.href || + undefined; + const cachedScrolledPosition = initialScrolledHref + ? getScrolledPosition( + manifest.book.sourceId, + manifest.book.id, + initialScrolledHref + ) + : null; + pendingScrolledRestoreRef.current = + settings.mode === 'scrolled' && !currentSessionHref + ? cachedScrolledPosition + : null; + restoreTargetRef.current = + settings.mode === 'scrolled' + ? initialScrolledHref || cachedScrolledPosition?.href || undefined + : currentSessionCfi || manifest.lastRecord?.locator?.value || undefined; loadEpubScript() .then(async () => { if (!window.ePub || destroyed || !viewerRef.current) return; - const cacheKey = manifest.cacheKey || buildBookCacheKey( - manifest.book.sourceId, - manifest.book.id, - manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}` - ); + const cacheKey = + manifest.cacheKey || + buildBookCacheKey( + manifest.book.sourceId, + manifest.book.id, + manifest.acquisitionHref || + `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}` + ); let fileBuffer: ArrayBuffer; const cached = await getCachedBookFile(cacheKey).catch(() => null); @@ -1782,12 +2660,15 @@ export default function BookReadPage() { fileBuffer = await cached.blob.arrayBuffer(); } else { setFileLoadState('downloading'); - const blob = await downloadBookWithProgress(manifest, (received, total) => { - if (!destroyed) { - setDownloadedBytes(received); - setTotalBytes(total); + const blob = await downloadBookWithProgress( + manifest, + (received, total) => { + if (!destroyed) { + setDownloadedBytes(received); + setTotalBytes(total); + } } - }); + ); fileBuffer = await blob.arrayBuffer(); await putCachedBookFile({ key: cacheKey, @@ -1795,7 +2676,9 @@ export default function BookReadPage() { bookId: manifest.book.id, title: manifest.book.title, format: 'epub', - acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`, + acquisitionHref: + manifest.acquisitionHref || + `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`, blob, size: blob.size, mimeType: blob.type || 'application/epub+zip', @@ -1816,7 +2699,10 @@ export default function BookReadPage() { } }, 4000); - const rendition = book.renderTo(viewerRef.current, getRenditionOptions(settings.mode)); + const rendition = book.renderTo( + viewerRef.current, + getRenditionOptions(settings.mode) + ); bookRef.current = book; renditionRef.current = rendition; applyReaderTheme(settingsRef.current); @@ -1832,7 +2718,11 @@ export default function BookReadPage() { } if (restoreTarget && !restoreMessageShown) { restoreMessageShown = true; - setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`); + setRestoredMessage( + `已恢复到上次阅读位置(约 ${Math.round( + manifest.lastRecord?.progressPercent || 0 + )}%)` + ); window.setTimeout(() => setRestoredMessage(''), 3000); } lastLocationRef.current = location; @@ -1842,13 +2732,30 @@ export default function BookReadPage() { bindScrolledIframeListenerRef.current(); applyPendingScrolledRestoreRef.current(); }); - const hrefLabel = location?.start?.href ? findTocLabelByHref(tocItemsRef.current, location.start.href) : ''; - const chapterTitle = hrefLabel || location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title; + const hrefLabel = location?.start?.href + ? findTocLabelByHref(tocItemsRef.current, location.start.href) + : ''; + const chapterTitle = + hrefLabel || + location?.start?.displayed?.chapter || + location?.start?.href || + manifest.book.title; const cfi = location?.start?.cfi || ''; - const computedProgress = locationsReadyRef.current && cfi - ? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100)) - : null; - const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0; + const computedProgress = + locationsReadyRef.current && cfi + ? Math.max( + 0, + Math.min( + 100, + (book.locations?.percentageFromCfi?.(cfi) || 0) * 100 + ) + ) + : null; + const normalizedProgress = + computedProgress ?? + lastProgressRef.current ?? + manifest.lastRecord?.progressPercent ?? + 0; setProgressPercent(normalizedProgress); setCurrentChapter(chapterTitle); setCurrentHref(location?.start?.href || ''); @@ -1866,7 +2773,8 @@ export default function BookReadPage() { void (async () => { try { - const navigation = (await book.loaded?.navigation) || book.navigation; + const navigation = + (await book.loaded?.navigation) || book.navigation; if (!destroyed) setTocItems(navigation?.toc || []); } catch { if (!destroyed) setTocItems(book.navigation?.toc || []); @@ -1879,7 +2787,10 @@ export default function BookReadPage() { await book.locations?.generate?.(480); locationsReadyRef.current = true; if (lastLocationRef.current?.start?.cfi) { - const recomputed = book.locations?.percentageFromCfi?.(lastLocationRef.current.start.cfi) || 0; + const recomputed = + book.locations?.percentageFromCfi?.( + lastLocationRef.current.start.cfi + ) || 0; const nextProgress = Math.max(0, Math.min(100, recomputed * 100)); setProgressPercent(nextProgress); lastProgressRef.current = nextProgress; @@ -1902,7 +2813,14 @@ export default function BookReadPage() { renditionRef.current?.destroy?.(); bookRef.current?.destroy?.(); }; - }, [manifest, settings.mode, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]); + }, [ + manifest, + settings.mode, + applyReaderTheme, + persistCurrentProgress, + queueReadRecord, + navigateToTarget, + ]); useEffect(() => { const flushPendingReadRecordOnLeave = () => { @@ -1965,7 +2883,10 @@ export default function BookReadPage() { const handleLoadedMetadata = () => { const nextDuration = audio.duration || 0; if (ttsResumeTimeRef.current > 0 && nextDuration > 0) { - audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, nextDuration - 0.25)); + audio.currentTime = Math.min( + ttsResumeTimeRef.current, + Math.max(0, nextDuration - 0.25) + ); ttsResumeTimeRef.current = 0; } setTtsDuration(nextDuration); @@ -2010,8 +2931,6 @@ export default function BookReadPage() { } }, [currentHref, stopTts, ttsCurrentChapterHref]); - - useEffect(() => { if (!manifest || manifest.format !== 'pdf') return; let revokedUrl = ''; @@ -2044,28 +2963,40 @@ export default function BookReadPage() { }; }, [manifest]); - useEffect(() => { tocItemsRef.current = tocItems; }, [tocItems]); const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]); const activeTocHref = useMemo( - () => flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href || '', + () => + flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href || + '', [flatToc, currentHref] ); - const currentTocLabel = useMemo(() => findTocLabelByHref(tocItems, currentHref), [tocItems, currentHref]); + const currentTocLabel = useMemo( + () => findTocLabelByHref(tocItems, currentHref), + [tocItems, currentHref] + ); useEffect(() => { if (!manifest) return; - window.dispatchEvent(new CustomEvent('books-read-update-header', { - detail: { - title: manifest.book.title, - subtitle: currentTocLabel || currentChapter || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'), - backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, - }, - })); + window.dispatchEvent( + new CustomEvent('books-read-update-header', { + detail: { + title: manifest.book.title, + subtitle: + currentTocLabel || + currentChapter || + manifest.book.author || + (settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'), + backHref: `/books/detail?sourceId=${encodeURIComponent( + manifest.book.sourceId + )}&bookId=${encodeURIComponent(manifest.book.id)}`, + }, + }) + ); }, [manifest, currentChapter, currentTocLabel, settings.mode]); useEffect(() => { @@ -2075,7 +3006,9 @@ export default function BookReadPage() { activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, [tocOpen, activeTocHref]); const nextChapterHref = useMemo(() => { - const index = flatToc.findIndex((item) => isSameTocTarget(currentHref, item.href)); + const index = flatToc.findIndex((item) => + isSameTocTarget(currentHref, item.href) + ); if (index < 0) return flatToc[0]?.href || ''; return flatToc[index + 1]?.href || ''; }, [flatToc, currentHref]); @@ -2117,7 +3050,10 @@ export default function BookReadPage() { const isAtBottom = () => { const latestMetrics = getIframeScrollMetrics(viewerRef.current); if (!latestMetrics) return false; - const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; + const distanceToBottom = + latestMetrics.scrollHeight - + latestMetrics.clientHeight - + latestMetrics.scrollTop; return distanceToBottom <= 36; }; @@ -2144,7 +3080,10 @@ export default function BookReadPage() { const latestMetrics = getIframeScrollMetrics(viewerRef.current); if (!latestMetrics) return; persistScrolledPosition(); - const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; + const distanceToBottom = + latestMetrics.scrollHeight - + latestMetrics.clientHeight - + latestMetrics.scrollTop; if (distanceToBottom <= 36) { setBottomReached(true); scrolledAutoAdvanceLockRef.current = false; @@ -2179,23 +3118,52 @@ export default function BookReadPage() { }; metrics.addScrollListener(handleScroll); - metrics.interactionTarget?.addEventListener('wheel', handleWheel, { passive: true }); - metrics.interactionTarget?.addEventListener('touchstart', handleTouchStart, { passive: true }); - metrics.interactionTarget?.addEventListener('touchmove', handleTouchMove, { passive: true }); - metrics.interactionTarget?.addEventListener('touchend', handleTouchEnd, { passive: true }); - viewerRef.current?.addEventListener('wheel', handleWheel, { passive: true }); - viewerRef.current?.addEventListener('touchstart', handleTouchStart, { passive: true }); - viewerRef.current?.addEventListener('touchmove', handleTouchMove, { passive: true }); - viewerRef.current?.addEventListener('touchend', handleTouchEnd, { passive: true }); + metrics.interactionTarget?.addEventListener('wheel', handleWheel, { + passive: true, + }); + metrics.interactionTarget?.addEventListener( + 'touchstart', + handleTouchStart, + { passive: true } + ); + metrics.interactionTarget?.addEventListener( + 'touchmove', + handleTouchMove, + { passive: true } + ); + metrics.interactionTarget?.addEventListener('touchend', handleTouchEnd, { + passive: true, + }); + viewerRef.current?.addEventListener('wheel', handleWheel, { + passive: true, + }); + viewerRef.current?.addEventListener('touchstart', handleTouchStart, { + passive: true, + }); + viewerRef.current?.addEventListener('touchmove', handleTouchMove, { + passive: true, + }); + viewerRef.current?.addEventListener('touchend', handleTouchEnd, { + passive: true, + }); handleScroll(); scrollListenerCleanupRef.current = () => { if (retryTimer) window.clearTimeout(retryTimer); if (rafId) window.cancelAnimationFrame(rafId); metrics.removeScrollListener(handleScroll); metrics.interactionTarget?.removeEventListener('wheel', handleWheel); - metrics.interactionTarget?.removeEventListener('touchstart', handleTouchStart); - metrics.interactionTarget?.removeEventListener('touchmove', handleTouchMove); - metrics.interactionTarget?.removeEventListener('touchend', handleTouchEnd); + metrics.interactionTarget?.removeEventListener( + 'touchstart', + handleTouchStart + ); + metrics.interactionTarget?.removeEventListener( + 'touchmove', + handleTouchMove + ); + metrics.interactionTarget?.removeEventListener( + 'touchend', + handleTouchEnd + ); viewerRef.current?.removeEventListener('wheel', handleWheel); viewerRef.current?.removeEventListener('touchstart', handleTouchStart); viewerRef.current?.removeEventListener('touchmove', handleTouchMove); @@ -2206,55 +3174,78 @@ export default function BookReadPage() { attach(); }, [goToNextChapter, persistScrolledPosition]); - useEffect(() => { bindScrolledIframeListenerRef.current = bindScrolledIframeListener; }, [bindScrolledIframeListener]); - const renderTocItems = useCallback((items: TocItem[], depth = 0) => items.map((item) => { - const active = tocItemIsActive(item, currentHref); - const clickable = !!item.href; - return ( -
- + {item.subitems?.length + ? renderTocItems(item.subitems, depth + 1) + : null}
- - {item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null} -
- ); - }), [currentHref, navigateToTarget, persistScrolledPosition]); + ); + }), + [currentHref, navigateToTarget, persistScrolledPosition] + ); + const showScrolledNextChapter = + ready && + settings.mode === 'scrolled' && + !tocOpen && + !settingsOpen && + scrolledBottomReached && + !!nextChapterHref; - - const showScrolledNextChapter = ready && settings.mode === 'scrolled' && !tocOpen && !settingsOpen && scrolledBottomReached && !!nextChapterHref; - - const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes); - const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0; - const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice); + const progressLabel = totalBytes + ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + : formatBytes(downloadedBytes); + const ttsChunkPercent = + ttsChunks.length > 0 + ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 + : 0; + const selectedVoice = ttsVoices.find( + (item) => item.shortName === ttsSettings.voice + ); const currentChunk = ttsChunks[ttsCurrentChunkIndex]; const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%'); const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz'); @@ -2269,7 +3260,9 @@ export default function BookReadPage() {
-
准备阅读器中...
+
+ 准备阅读器中... +
); @@ -2280,14 +3273,25 @@ export default function BookReadPage() { } if (manifest.format === 'pdf') { - if (!pdfBlobUrl) return
PDF 加载中... {progressLabel}
; - return