'use client'; 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 { BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types'; import { buildBookCacheKey, enforceBookCacheLimit, getCachedBookFile, putCachedBookFile, touchCachedBookFile, } from '@/lib/book-cache.client'; import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; import { buildBookTtsCacheKey, enforceBookTtsCacheLimit, getCachedBookTtsChunk, putCachedBookTtsChunk, touchCachedBookTtsChunk, } from '@/lib/book-tts-cache.client'; import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client'; declare global { interface Window { ePub?: (input: string | ArrayBuffer) => EpubBookInstance; JSZip?: unknown; } } interface EpubLocation { start?: { cfi?: string; href?: string; displayed?: { chapter?: string } }; end?: { cfi?: string }; } interface TocItem { id?: string; label: string; href: string; subitems?: TocItem[]; } interface EpubNavigation { toc?: TocItem[]; } interface EpubThemes { fontSize?: (value: string) => void; default?: (styles: Record>) => void; override?: (name: string, value: string) => void; } interface EpubBookInstance { renderTo: (element: HTMLElement, options: Record) => EpubRendition; locations?: { percentageFromCfi?: (cfi: string) => number; generate?: (chars?: number) => Promise; }; loaded?: { navigation?: Promise; }; navigation?: EpubNavigation; ready?: Promise; destroy?: () => void; } interface EpubRendition { display: (target?: string) => Promise; on: (event: 'relocated', callback: (location: EpubLocation) => void) => void; prev?: () => void; next?: () => void; destroy?: () => void; themes?: EpubThemes; } type ReaderTheme = 'light' | 'sepia' | 'dark'; type ReaderMode = 'paginated' | 'scrolled'; type FileLoadState = 'preparing' | 'checking-cache' | 'downloading' | 'opening' | 'ready'; interface ReaderSettings { fontSize: number; lineHeight: number; theme: ReaderTheme; mode: ReaderMode; } interface TtsChunk { index: number; text: string; start: number; end: number; } interface TtsSettings { voice: string; rate: string; pitch: string; volume: string; autoPlayNext: boolean; } interface ScrolledReadingPosition { href: string; scrollTop: number; scrollHeight: number; clientHeight: number; updatedAt: number; } type TtsStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'; const SETTINGS_STORAGE_KEY = 'books_epub_reader_settings'; const SCROLLED_POSITION_STORAGE_KEY = 'books_epub_scrolled_positions'; const TTS_SETTINGS_STORAGE_KEY = 'books_epub_tts_settings'; const DEFAULT_SETTINGS: ReaderSettings = { fontSize: 100, lineHeight: 1.7, theme: 'light', mode: 'paginated', }; const DEFAULT_TTS_SETTINGS: TtsSettings = { voice: '', rate: '+0%', pitch: '+0Hz', volume: '+0%', autoPlayNext: true, }; const SAVE_INTERVAL_MS = 10000; 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 = { light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' }, sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' }, dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' }, }; function loadTtsSettings(): TtsSettings { if (typeof window === 'undefined') return DEFAULT_TTS_SETTINGS; try { const raw = localStorage.getItem(TTS_SETTINGS_STORAGE_KEY); if (!raw) return DEFAULT_TTS_SETTINGS; return { ...DEFAULT_TTS_SETTINGS, ...(JSON.parse(raw) as Partial) }; } catch { return DEFAULT_TTS_SETTINGS; } } function parseSignedNumber(value: string, _suffix: '%' | 'Hz') { const match = value.match(/([+-]?\d+)(?:%|Hz)/); if (!match) return 0; return Number(match[1] || 0); } function formatSignedValue(value: number, suffix: '%' | 'Hz') { return `${value >= 0 ? '+' : ''}${value}${suffix}`; } function loadScriptOnce(selector: string, src: string, errorMessage: string) { return new Promise((resolve, reject) => { 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 }); return; } const script = document.createElement('script'); script.src = src; script.async = true; if (selector.includes('jszip')) script.dataset.jszip = 'true'; if (selector.includes('epubjs')) script.dataset.epubjs = 'true'; script.onload = () => { script.dataset.loaded = 'true'; resolve(); }; script.onerror = () => reject(new Error(errorMessage)); document.body.appendChild(script); }); } 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 加载失败'); } if (!window.ePub) { await loadScriptOnce('script[data-epubjs]', 'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js', 'epub.js 加载失败'); } } function loadReaderSettings(): ReaderSettings { if (typeof window === 'undefined') return DEFAULT_SETTINGS; try { const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); if (!raw) return DEFAULT_SETTINGS; return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial) }; } catch { return DEFAULT_SETTINGS; } } function buildScrolledPositionKey(sourceId: string, bookId: string, href?: string) { return `${sourceId}::${bookId}::${normalizeHrefForMatch(href)}`; } function loadScrolledPositions(): Record { if (typeof window === 'undefined') return {}; try { const raw = localStorage.getItem(SCROLLED_POSITION_STORAGE_KEY); return raw ? (JSON.parse(raw) as Record) : {}; } catch { return {}; } } 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 { const all = loadScrolledPositions(); return all[buildScrolledPositionKey(sourceId, bookId, href)] || null; } function getIframeScrollMetrics(viewer: HTMLDivElement | null) { if (!viewer) return null; const iframe = viewer.querySelector('iframe'); const doc = iframe?.contentDocument; const win = iframe?.contentWindow; const elementCandidates = [ viewer, ...Array.from(viewer.querySelectorAll('div')), ]; let bestElement: HTMLDivElement | null = null; let bestOverflow = 0; for (const candidate of elementCandidates) { const el = candidate as HTMLDivElement; const overflow = el.scrollHeight - el.clientHeight; if (overflow > bestOverflow + 8) { bestOverflow = overflow; bestElement = el; } } 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), clientHeight: root.clientHeight || win?.innerHeight || 0, setScrollTop: (value: number) => { if (typeof root.scrollTo === 'function') { root.scrollTo({ top: value, behavior: 'auto' }); } else { root.scrollTop = value; } }, addScrollListener: (listener: () => void) => win?.addEventListener('scroll', listener, { passive: true }), removeScrollListener: (listener: () => void) => win?.removeEventListener('scroll', listener), interactionTarget: root, }; } if (bestElement) { const scrollElement = bestElement; return { iframe, root: scrollElement, scrollTop: Math.max(0, scrollElement.scrollTop || 0), scrollHeight: scrollElement.scrollHeight || 0, clientHeight: scrollElement.clientHeight || 0, 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), interactionTarget: scrollElement, }; } return null; } 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; if (maxSaved <= 0) return Math.min(position.scrollTop, maxCurrent); const ratio = Math.max(0, Math.min(1, position.scrollTop / maxSaved)); return ratio * maxCurrent; } function flattenToc(items: TocItem[]): TocItem[] { return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]); } function tocItemIsActive(item: TocItem, currentHref: string): boolean { return isSameTocTarget(currentHref, item.href) || (item.subitems || []).some((subitem) => tocItemIsActive(subitem, currentHref)); } function findTocLabelByHref(items: TocItem[], currentHref: string): string { for (const item of items) { if (isSameTocTarget(currentHref, item.href)) return item.label; const nested = findTocLabelByHref(item.subitems || [], currentHref); if (nested) return nested; } return ''; } async function downloadBookWithProgress( manifest: Pick, onProgress: (received: number, total: number | null) => void ): Promise { const response = await fetch('/api/books/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceId: manifest.book.sourceId, bookId: manifest.book.id, format: manifest.format, href: manifest.acquisitionHref || undefined, }), cache: 'force-cache', }); if (!response.ok) throw new Error(`下载电子书失败: ${response.status}`); const total = Number(response.headers.get('content-length') || '') || null; if (!response.body) { const blob = await response.blob(); onProgress(blob.size, total); return blob; } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let received = 0; let done = false; while (!done) { const result = await reader.read(); done = result.done; const value = result.value; if (done) break; if (value) { chunks.push(value); received += value.length; onProgress(received, total); } } return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' }); } function normalizeHrefForMatch(href?: string) { if (!href) return ''; try { const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim(); return normalized.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, ''); } catch { return href.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '').trim(); } } 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); } function formatBytes(size: number): string { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1024 / 1024).toFixed(1)} MB`; } 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')}`; } function sanitizeTtsText(text: string): string { return text .replace(/\u00a0/g, ' ') .replace(/\r/g, '') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]{2,}/g, ' ') .trim(); } 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 chunks: TtsChunk[] = []; let buffer = ''; let start = 0; let cursor = 0; const flush = () => { const value = buffer.trim(); if (!value) return; chunks.push({ index: chunks.length, text: value, start, end: start + value.length, }); cursor = start + value.length; buffer = ''; start = cursor; }; const appendPiece = (piece: string) => { const trimmed = piece.trim(); if (!trimmed) return; const next = buffer ? `${buffer}\n\n${trimmed}` : trimmed; if (next.length <= maxChars) { if (!buffer) start = cursor; buffer = next; cursor += trimmed.length; return; } if (buffer) flush(); if (trimmed.length <= maxChars) { buffer = trimmed; start = cursor; cursor += trimmed.length; return; } const sentences = trimmed.split(/(?<=[。!?!?;;])/).map((item) => item.trim()).filter(Boolean); let local = ''; let localStart = cursor; for (const sentence of sentences) { const maybe = local ? `${local}${sentence}` : sentence; if (maybe.length <= maxChars) { if (!local) localStart = cursor; local = maybe; cursor += sentence.length; } else { if (local) { chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); local = ''; } if (sentence.length <= maxChars) { local = sentence; localStart = cursor; cursor += sentence.length; } 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 }); cursor += part.length; } } } } if (local) { chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); } }; for (const paragraph of paragraphs) { appendPiece(paragraph); } flush(); return chunks; } function getRenditionOptions(mode: ReaderMode) { return mode === 'scrolled' ? { width: '100%', height: '100%', spread: 'none', manager: 'default', flow: 'scrolled-doc', } : { width: '100%', height: '100%', spread: 'none', manager: 'default', flow: 'paginated', }; } function decodeBase64Audio(base64: string, mimeType: string) { const binary = typeof window === 'undefined' ? '' : window.atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return new Blob([bytes], { type: mimeType || 'audio/mpeg' }); } 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 [manifest, setManifest] = useState(null); const [error, setError] = useState(''); const [ready, setReady] = useState(false); 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(DEFAULT_TTS_SETTINGS); const [tocItems, setTocItems] = useState([]); const [currentHref, setCurrentHref] = useState(''); const [currentChapter, setCurrentChapter] = useState(''); const [, setProgressPercent] = useState(0); const [restoredMessage, setRestoredMessage] = useState(''); const [pdfBlobUrl, setPdfBlobUrl] = useState(''); const [ttsVoices, setTtsVoices] = useState([]); const [ttsAvailable, setTtsAvailable] = useState(false); const [ttsStatus, setTtsStatus] = useState('idle'); const [ttsError, setTtsError] = useState(''); const [ttsChunks, setTtsChunks] = useState([]); const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState(null); const [ttsCurrentChapterHref, setTtsCurrentChapterHref] = useState(''); const [ttsCurrentChapterTitle, setTtsCurrentChapterTitle] = useState(''); const [ttsBarVisible, setTtsBarVisible] = useState(false); const [ttsPanelOpen, setTtsPanelOpen] = useState(false); const [ttsCurrentTime, setTtsCurrentTime] = useState(0); const [ttsDuration, setTtsDuration] = useState(0); const [ttsSeekValue, setTtsSeekValue] = useState(0); const [ttsSeeking, setTtsSeeking] = useState(false); const [scrolledBottomReached, setScrolledBottomReached] = useState(false); const viewerRef = useRef(null); const pendingScrolledRestoreRef = useRef(null); const restoreTargetRef = useRef(undefined); const scrollListenerCleanupRef = useRef<(() => void) | null>(null); const scrolledAutoAdvanceLockRef = useRef(false); const scrolledTouchStartYRef = useRef(null); const scrolledBottomReachedRef = useRef(false); const nextChapterHrefRef = useRef(''); const bindScrolledIframeListenerRef = useRef<() => void>(() => undefined); const applyPendingScrolledRestoreRef = useRef<() => void>(() => undefined); const tocScrollRef = useRef(null); const tocItemRefs = useRef>({}); const bookRef = useRef(null); const renditionRef = useRef(null); const pendingRecordRef = useRef(null); const pendingRecordDirtyRef = useRef(false); const saveInFlightRef = useRef(false); const lastLocationRef = useRef(null); const settingsRef = useRef(DEFAULT_SETTINGS); const lastProgressRef = useRef(0); const lastChapterRef = useRef(''); const locationsReadyRef = useRef(false); const tocItemsRef = useRef([]); const currentHrefRef = useRef(''); const audioRef = useRef(null); const ttsChunkAudioUrlRef = useRef>({}); const ttsChunkBlobCacheRef = useRef>({}); const ttsChunksRef = useRef([]); const ttsSettingsRef = useRef(DEFAULT_TTS_SETTINGS); const ttsCurrentChunkIndexRef = useRef(0); const ttsCurrentChapterHrefRef = useRef(''); const ttsCurrentChapterTitleRef = useRef(''); const ttsStatusRef = useRef('idle'); const ttsSeekingRef = useRef(false); const ttsPrefetchedFromChunkRef = useRef(null); const ttsPrefetchFnRef = useRef<(fromIndex: number) => void>(() => undefined); const ttsResumeTimeRef = useRef(0); useEffect(() => { setSettings(loadReaderSettings()); setTtsSettings(loadTtsSettings()); }, []); useEffect(() => { settingsRef.current = 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)); } ttsSettingsRef.current = ttsSettings; }, [ttsSettings]); useEffect(() => { ttsStatusRef.current = ttsStatus; }, [ttsStatus]); useEffect(() => { currentHrefRef.current = currentHref; }, [currentHref]); useEffect(() => { ttsSeekingRef.current = ttsSeeking; if (!ttsSeeking) { setTtsSeekValue(ttsCurrentTime); } }, [ttsCurrentTime, ttsSeeking]); useEffect(() => { scrolledBottomReachedRef.current = scrolledBottomReached; }, [scrolledBottomReached]); useEffect(() => { const handleToggleSettings = () => { setSettingsOpen((prev) => !prev); setTocOpen(false); }; window.addEventListener('books-read-toggle-settings', handleToggleSettings); return () => { window.removeEventListener('books-read-toggle-settings', handleToggleSettings); }; }, []); useEffect(() => { const handleToggleChapters = () => { setTocOpen((prev) => !prev); setSettingsOpen(false); }; window.addEventListener('books-read-toggle-chapters', handleToggleChapters); return () => { window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); }; }, []); useEffect(() => { const handleToggleTts = () => { setTtsBarVisible((prev) => { const next = !prev; if (!next) { setTtsPanelOpen(false); } return next; }); setSettingsOpen(false); setTocOpen(false); }; window.addEventListener('books-read-toggle-tts', handleToggleTts); return () => { window.removeEventListener('books-read-toggle-tts', handleToggleTts); }; }, []); useEffect(() => { if (!sourceId || !bookId) return; fetch('/api/books/read/manifest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceId, bookId, href: cached?.detailHref, acquisitionHref: cached?.acquisitionHref, format: cached?.format || null, title: cached?.title, author: cached?.author, cover: cached?.cover, summary: cached?.summary, }), }) .then(async (res) => { const json = await res.json(); if (!res.ok) throw new Error(json.error || '获取阅读信息失败'); setManifest(json); cacheBookDetail(json.book); }) .catch((err) => setError(err.message || '获取阅读信息失败')); }, [sourceId, bookId, cached]); useEffect(() => { if (!manifest || manifest.format !== 'epub') return; let cancelled = false; fetch('/api/books/tts/voices') .then(async (res) => { const json = await res.json(); if (!res.ok) throw new Error(json.error || '获取朗读配置失败'); if (cancelled) return; setTtsAvailable(true); setTtsVoices(json.voices || []); setTtsSettings((prev) => ({ ...prev, voice: prev.voice || json.defaults?.voice || '', rate: prev.rate || json.defaults?.rate || '+0%', pitch: prev.pitch || json.defaults?.pitch || '+0Hz', volume: prev.volume || json.defaults?.volume || '+0%', })); }) .catch((err) => { if (cancelled) return; setTtsAvailable(false); setTtsVoices([]); setTtsError(err.message || '朗读能力不可用'); }); return () => { cancelled = true; }; }, [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, chapterTitle, }, chapterTitle, 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 flushPendingReadRecord = useCallback(async () => { const record = pendingRecordRef.current; if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) return; saveInFlightRef.current = true; try { await saveBookReadRecord(record.sourceId, record.bookId, { ...record, saveTime: Date.now(), }); pendingRecordRef.current = { ...record, saveTime: Date.now() }; pendingRecordDirtyRef.current = false; } catch { // ignore } finally { saveInFlightRef.current = false; } }, []); 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; const pending = pendingScrolledRestoreRef.current; 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); metrics.setScrollTop(targetScrollTop); pendingScrolledRestoreRef.current = null; }, []); useEffect(() => { applyPendingScrolledRestoreRef.current = applyPendingScrolledRestore; }, [applyPendingScrolledRestore]); const persistCurrentProgress = useCallback(() => { if (lastLocationRef.current) { queueReadRecord(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current); } persistScrolledPosition(); void flushPendingReadRecord(); }, [queueReadRecord, persistScrolledPosition, flushPendingReadRecord]); const applyReaderTheme = useCallback((nextSettings: ReaderSettings) => { const rendition = renditionRef.current; if (!rendition?.themes) return; const palette = THEME_STYLES[nextSettings.theme]; rendition.themes.default?.({ body: { 'background-color': palette.bodyBg, color: palette.bodyColor, 'font-size': `${nextSettings.fontSize}%`, 'line-height': String(nextSettings.lineHeight), 'padding-left': '6px', 'padding-right': '6px', }, p: { color: palette.bodyColor }, a: { color: nextSettings.theme === 'dark' ? '#93c5fd' : '#2563eb' }, }); rendition.themes.fontSize?.(`${nextSettings.fontSize}%`); rendition.themes.override?.('line-height', String(nextSettings.lineHeight)); }, []); useEffect(() => { applyReaderTheme(settings); }, [settings, applyReaderTheme]); const navigateToTarget = useCallback(async (target?: string) => { if (!renditionRef.current) return; 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 cleanupTtsAudioUrls = useCallback(() => { 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 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 || ''; 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({ 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(); } 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]); 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; if (ttsStatus === 'playing') { audioRef.current?.pause(); setTtsStatus('paused'); persistTtsProgress(); return; } if (ttsStatus === 'paused' && audioRef.current) { try { await audioRef.current.play(); setTtsStatus('playing'); } catch (error) { setTtsStatus('error'); setTtsError((error as Error).message || '恢复播放失败'); } return; } await bootstrapTtsForCurrentChapter(true); }, [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; setReady(false); setRestoredMessage(''); locationsReadyRef.current = false; lastLocationRef.current = null; setProgressPercent(manifest.lastRecord?.progressPercent || 0); 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); 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}` ); let fileBuffer: ArrayBuffer; const cached = await getCachedBookFile(cacheKey).catch(() => null); if (cached) { setCacheHit(true); setFileLoadState('opening'); setDownloadedBytes(cached.size); setTotalBytes(cached.size); await touchCachedBookFile(cacheKey).catch(() => undefined); fileBuffer = await cached.blob.arrayBuffer(); } else { setFileLoadState('downloading'); const blob = await downloadBookWithProgress(manifest, (received, total) => { if (!destroyed) { setDownloadedBytes(received); setTotalBytes(total); } }); fileBuffer = await blob.arrayBuffer(); await putCachedBookFile({ key: cacheKey, sourceId: manifest.book.sourceId, bookId: manifest.book.id, title: manifest.book.title, format: manifest.format, acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`, blob, size: blob.size, mimeType: blob.type || 'application/epub+zip', updatedAt: Date.now(), lastOpenTime: Date.now(), }).catch(() => undefined); await enforceBookCacheLimit().catch(() => undefined); if (destroyed) return; setFileLoadState('opening'); } if (destroyed) return; const book = window.ePub(fileBuffer); const readyFallbackTimer = window.setTimeout(() => { if (!destroyed) { setReady(true); setFileLoadState('ready'); } }, 4000); const rendition = book.renderTo(viewerRef.current, getRenditionOptions(settings.mode)); bookRef.current = book; renditionRef.current = rendition; applyReaderTheme(settingsRef.current); const restoreTarget = restoreTargetRef.current; let restoreMessageShown = false; rendition.on('relocated', (location: EpubLocation) => { if (!destroyed) { window.clearTimeout(readyFallbackTimer); setReady(true); setFileLoadState('ready'); } if (restoreTarget && !restoreMessageShown) { restoreMessageShown = true; setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`); window.setTimeout(() => setRestoredMessage(''), 3000); } lastLocationRef.current = location; scrolledAutoAdvanceLockRef.current = false; setScrolledBottomReached(false); window.requestAnimationFrame(() => { 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 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; setProgressPercent(normalizedProgress); setCurrentChapter(chapterTitle); setCurrentHref(location?.start?.href || ''); lastProgressRef.current = normalizedProgress; lastChapterRef.current = chapterTitle; queueReadRecord(location, normalizedProgress, chapterTitle); }); void navigateToTarget(restoreTarget).catch(() => { if (!destroyed) { setReady(true); setFileLoadState('ready'); } }); void (async () => { try { const navigation = (await book.loaded?.navigation) || book.navigation; if (!destroyed) setTocItems(navigation?.toc || []); } catch { if (!destroyed) setTocItems(book.navigation?.toc || []); } })(); void (async () => { try { await book.ready; await book.locations?.generate?.(480); locationsReadyRef.current = true; if (lastLocationRef.current?.start?.cfi) { 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; } } catch { // ignore } })(); }) .catch((err) => { setReady(false); setError(err.message || '初始化 EPUB 阅读器失败'); }); return () => { destroyed = true; scrollListenerCleanupRef.current?.(); scrollListenerCleanupRef.current = null; persistCurrentProgress(); renditionRef.current?.destroy?.(); bookRef.current?.destroy?.(); }; }, [manifest, settings.mode, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]); useEffect(() => { const flushPendingReadRecordOnLeave = () => { if (!pendingRecordDirtyRef.current || saveInFlightRef.current) return; persistCurrentProgress(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { flushPendingReadRecordOnLeave(); } }; const intervalId = window.setInterval(() => { void flushPendingReadRecord(); }, SAVE_INTERVAL_MS); window.addEventListener('pagehide', flushPendingReadRecordOnLeave); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.clearInterval(intervalId); window.removeEventListener('pagehide', flushPendingReadRecordOnLeave); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [flushPendingReadRecord, persistCurrentProgress]); useEffect(() => { if (!audioRef.current) { audioRef.current = new Audio(); } const audio = audioRef.current; const handleEnded = () => { setTtsCurrentTime(0); setTtsSeekValue(0); const nextIndex = ttsCurrentChunkIndexRef.current + 1; if (nextIndex < ttsChunksRef.current.length) { void playTtsChunk(nextIndex); return; } setTtsStatus('idle'); persistTtsProgress(ttsCurrentChunkIndexRef.current); }; const handlePause = () => { if (!audio.ended && ttsStatusRef.current === 'playing') { setTtsStatus('paused'); } }; const handleTimeUpdate = () => { const nextTime = audio.currentTime || 0; const nextDuration = audio.duration || 0; setTtsCurrentTime(nextTime); setTtsDuration(nextDuration); if (nextDuration > 0 && nextTime / nextDuration >= 0.6) { ttsPrefetchFnRef.current(ttsCurrentChunkIndexRef.current); } if (!ttsSeekingRef.current) { setTtsSeekValue(nextTime); } }; 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)); ttsResumeTimeRef.current = 0; } setTtsDuration(nextDuration); if (!ttsSeekingRef.current) { setTtsSeekValue(audio.currentTime || 0); } }; audio.addEventListener('ended', handleEnded); audio.addEventListener('pause', handlePause); audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('loadedmetadata', handleLoadedMetadata); return () => { audio.removeEventListener('ended', handleEnded); audio.removeEventListener('pause', handlePause); audio.removeEventListener('timeupdate', handleTimeUpdate); audio.removeEventListener('loadedmetadata', handleLoadedMetadata); persistTtsProgress(); stopTts(true); }; }, [persistTtsProgress, playTtsChunk, stopTts]); useEffect(() => { ttsChunksRef.current = ttsChunks; }, [ttsChunks]); useEffect(() => { ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; }, [ttsCurrentChunkIndex]); useEffect(() => { ttsCurrentChapterHrefRef.current = ttsCurrentChapterHref; }, [ttsCurrentChapterHref]); useEffect(() => { ttsCurrentChapterTitleRef.current = ttsCurrentChapterTitle; }, [ttsCurrentChapterTitle]); useEffect(() => { if (!ttsCurrentChapterHref || !currentHref) return; if (!isSameTocTarget(ttsCurrentChapterHref, currentHref)) { stopTts(true); } }, [currentHref, stopTts, ttsCurrentChapterHref]); useEffect(() => { if (!manifest || manifest.format !== 'pdf') return; let revokedUrl = ''; let cancelled = false; setFileLoadState('downloading'); setDownloadedBytes(0); setTotalBytes(null); setPdfBlobUrl(''); downloadBookWithProgress(manifest, (received, total) => { if (!cancelled) { setDownloadedBytes(received); setTotalBytes(total); } }) .then(async (blob) => { if (cancelled) return; const objectUrl = URL.createObjectURL(blob); revokedUrl = objectUrl; setPdfBlobUrl(objectUrl); setFileLoadState('ready'); }) .catch((err) => { if (!cancelled) setError(err.message || 'PDF 加载失败'); }); return () => { cancelled = true; if (revokedUrl) URL.revokeObjectURL(revokedUrl); }; }, [manifest]); useEffect(() => { tocItemsRef.current = tocItems; }, [tocItems]); const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]); const activeTocHref = useMemo( () => flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href || '', [flatToc, 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)}`, }, })); }, [manifest, currentChapter, currentTocLabel, settings.mode]); useEffect(() => { if (!tocOpen || !activeTocHref) return; const activeNode = tocItemRefs.current[activeTocHref]; if (!activeNode) return; activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, [tocOpen, activeTocHref]); const nextChapterHref = useMemo(() => { const index = flatToc.findIndex((item) => isSameTocTarget(currentHref, item.href)); if (index < 0) return flatToc[0]?.href || ''; return flatToc[index + 1]?.href || ''; }, [flatToc, currentHref]); useEffect(() => { nextChapterHrefRef.current = nextChapterHref; }, [nextChapterHref]); const goToNextChapter = useCallback(() => { if (!nextChapterHref) return; persistScrolledPosition(); pendingScrolledRestoreRef.current = { href: nextChapterHref, scrollTop: 0, scrollHeight: 1, clientHeight: 1, updatedAt: Date.now(), }; restoreTargetRef.current = nextChapterHref; scrolledAutoAdvanceLockRef.current = true; setScrolledBottomReached(false); void navigateToTarget(nextChapterHref); }, [nextChapterHref, navigateToTarget, persistScrolledPosition]); const bindScrolledIframeListener = useCallback(() => { scrollListenerCleanupRef.current?.(); scrollListenerCleanupRef.current = null; if (settingsRef.current.mode !== 'scrolled') return; let retryTimer = 0; let rafId = 0; const attach = () => { const metrics = getIframeScrollMetrics(viewerRef.current); if (!metrics) { retryTimer = window.setTimeout(attach, 120); return; } const isAtBottom = () => { const latestMetrics = getIframeScrollMetrics(viewerRef.current); if (!latestMetrics) return false; const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; return distanceToBottom <= 36; }; const setBottomReached = (value: boolean) => { if (scrolledBottomReachedRef.current === value) return; scrolledBottomReachedRef.current = value; setScrolledBottomReached(value); }; const handleAdvanceIntent = () => { if (!nextChapterHrefRef.current) return; if (!isAtBottom()) return; if (!scrolledBottomReachedRef.current) { setBottomReached(true); return; } if (scrolledAutoAdvanceLockRef.current) return; goToNextChapter(); }; const handleScroll = () => { if (rafId) window.cancelAnimationFrame(rafId); rafId = window.requestAnimationFrame(() => { const latestMetrics = getIframeScrollMetrics(viewerRef.current); if (!latestMetrics) return; persistScrolledPosition(); const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; if (distanceToBottom <= 36) { setBottomReached(true); scrolledAutoAdvanceLockRef.current = false; return; } setBottomReached(false); scrolledAutoAdvanceLockRef.current = false; }); }; const handleWheel = (event: Event) => { const wheel = event as WheelEvent; if (wheel.deltaY > 24) handleAdvanceIntent(); }; const handleTouchStart = (event: Event) => { const touch = (event as TouchEvent).touches[0]; scrolledTouchStartYRef.current = touch?.clientY ?? null; }; const handleTouchMove = (event: Event) => { const touch = (event as TouchEvent).touches[0]; const startY = scrolledTouchStartYRef.current; if (touch && startY !== null && startY - touch.clientY > 28) { handleAdvanceIntent(); scrolledTouchStartYRef.current = touch.clientY; } }; const handleTouchEnd = () => { scrolledTouchStartYRef.current = null; }; 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 }); 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); viewerRef.current?.removeEventListener('wheel', handleWheel); viewerRef.current?.removeEventListener('touchstart', handleTouchStart); viewerRef.current?.removeEventListener('touchmove', handleTouchMove); viewerRef.current?.removeEventListener('touchend', handleTouchEnd); }; }; 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}
); }), [currentHref, navigateToTarget, persistScrolledPosition]); 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 currentChunk = ttsChunks[ttsCurrentChunkIndex]; const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%'); const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz'); const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%'); const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime; if (error) return
{error}
; if (!manifest) { return (
准备阅读器中...
); } if (manifest.format === 'pdf') { if (!pdfBlobUrl) return
PDF 加载中... {progressLabel}
; return