diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index 91e2b72..10b8b4d 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -377,19 +377,116 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const [currentIndex, setCurrentIndex] = useState(0); const [chapter, setChapter] = useState(null); const [tocOpen, setTocOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [loading, setLoading] = useState(true); + const [chaptersLoaded, setChaptersLoaded] = useState(false); const [error, setError] = useState(''); + const [ttsVoices, setTtsVoices] = useState([]); + const [ttsAvailable, setTtsAvailable] = useState(false); + const [ttsSettings, setTtsSettings] = useState(DEFAULT_TTS_SETTINGS); + const [ttsStatus, setTtsStatus] = useState('idle'); + const [ttsError, setTtsError] = useState(''); + const [ttsChunks, setTtsChunks] = useState([]); + const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); + const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState(null); + 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 scrollRef = useRef(null); + const audioRef = useRef(null); + const ttsChunksRef = useRef([]); + const ttsCurrentChunkIndexRef = useRef(0); + const ttsStatusRef = useRef('idle'); + const ttsSettingsRef = useRef(DEFAULT_TTS_SETTINGS); + const ttsSeekingRef = useRef(false); + const ttsResumeTimeRef = useRef(0); + const currentChapterHref = chapters[currentIndex]?.href || ''; + const currentChapterTitle = chapters[currentIndex]?.title || chapter?.title || ''; useEffect(() => { - const handleToggleChapters = () => setTocOpen((prev) => !prev); - window.addEventListener('books-read-toggle-chapters', handleToggleChapters); - return () => window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); + setSettings(loadReaderSettings()); + setTtsSettings(loadTtsSettings()); }, []); useEffect(() => { - if (!manifest.chaptersUrl && !manifest.acquisitionHref) return; + 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(() => { 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; + if (audio) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + setTtsCurrentTime(0); + setTtsDuration(0); + setTtsSeekValue(0); + setTtsSeeking(false); + setTtsLoadingChunkIndex(null); + setTtsStatus('idle'); + if (clearQueue) { + setTtsChunks([]); + ttsChunksRef.current = []; + setTtsCurrentChunkIndex(0); + ttsCurrentChunkIndexRef.current = 0; + } + }, []); + + 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); }; + 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-tts', handleToggleTts); + }; + }, []); + + useEffect(() => { + 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 || '' })); + }) + .catch((err) => { + if (!cancelled) { + setTtsAvailable(false); + setTtsVoices([]); + setTtsError(err.message || '朗读能力不可用'); + } + }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (!manifest.chaptersUrl && !manifest.book.id) return; let cancelled = false; setChapters([]); + setChaptersLoaded(false); setChapter(null); setCurrentIndex(0); setLoading(true); @@ -402,30 +499,26 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { 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 || ''; const savedIndex = list.findIndex((item) => item.href === savedHref); setCurrentIndex(savedIndex >= 0 ? savedIndex : 0); }) - .catch((err) => { - if (!cancelled) setError(err.message || '获取目录失败'); - }); - return () => { - cancelled = true; - }; + .catch((err) => { if (!cancelled) { setError(err.message || '获取目录失败'); setChaptersLoaded(true); } }); + return () => { cancelled = true; }; }, [initialChapterHref, manifest]); useEffect(() => { const item = chapters[currentIndex]; if (!item) { - if (chapters.length === 0) setLoading(false); + if (chaptersLoaded && chapters.length === 0) setLoading(false); return; } + stopTts(true); setLoading(true); setError(''); - const params = new URLSearchParams({ - sourceId: manifest.book.sourceId, - href: item.href, - }); + scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' }); + const params = new URLSearchParams({ sourceId: manifest.book.sourceId, href: item.href }); if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref); fetch(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' }) .then(async (res) => { @@ -453,10 +546,185 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { }) .catch((err) => setError(err.message || '获取章节失败')) .finally(() => setLoading(false)); - }, [chapters, currentIndex, manifest]); + }, [chapters, chaptersLoaded, currentIndex, manifest, 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)}`, + }, + })); + }, [manifest, currentChapterTitle, settings.mode]); + + const goPrevChapter = useCallback(() => setCurrentIndex((prev) => Math.max(0, prev - 1)), []); + const goNextChapter = useCallback(() => setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1)), [chapters.length]); + + const 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, ' ')); + 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 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; + if (!currentChapterHref) { + setTtsError('当前章节尚未定位,稍后再试'); + setTtsStatus('error'); + return; + } + const text = getChapterPlainText(); + if (!text) { + setTtsError('当前章节没有可朗读文本'); + setTtsStatus('error'); + return; + } + const chunks = chunkTtsText(text, 1200); + setTtsChunks(chunks); + ttsChunksRef.current = chunks; + setTtsCurrentChunkIndex(0); + ttsCurrentChunkIndexRef.current = 0; + await playTtsChunk(0); + }, [currentChapterHref, getChapterPlainText, playTtsChunk, ttsAvailable]); + + const toggleTtsPlayback = useCallback(async () => { + if (!ttsAvailable) return; + if (ttsStatus === 'playing') { + audioRef.current?.pause(); + setTtsStatus('paused'); + return; + } + if (ttsStatus === 'paused' && audioRef.current) { + await audioRef.current.play(); + setTtsStatus('playing'); + return; + } + await bootstrapTts(); + }, [bootstrapTts, ttsAvailable, ttsStatus]); + + useEffect(() => { + if (!audioRef.current) audioRef.current = new Audio(); + const audio = audioRef.current; + const handleEnded = () => { + setTtsCurrentTime(0); + setTtsSeekValue(0); + const next = ttsCurrentChunkIndexRef.current + 1; + if (next < ttsChunksRef.current.length) void playTtsChunk(next); + else setTtsStatus('idle'); + }; + const handleTimeUpdate = () => { + setTtsCurrentTime(audio.currentTime || 0); + setTtsDuration(audio.duration || 0); + if (!ttsSeekingRef.current) setTtsSeekValue(audio.currentTime || 0); + }; + 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)); + ttsResumeTimeRef.current = 0; + setTtsDuration(audio.duration || 0); + }; + audio.addEventListener('ended', handleEnded); + audio.addEventListener('timeupdate', handleTimeUpdate); + audio.addEventListener('pause', handlePause); + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + return () => { + audio.removeEventListener('ended', handleEnded); + audio.removeEventListener('timeupdate', handleTimeUpdate); + audio.removeEventListener('pause', handlePause); + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + stopTts(true); + }; + }, [playTtsChunk, stopTts]); + + 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 palette = THEME_STYLES[settings.theme]; if (error) return
{error}
; - if (loading && !chapter) return
章节加载中...
; + if (!chaptersLoaded || (loading && !chapter)) { + return ( +
+
+
+ +
+
章节加载中...
+
+
+ ); + } if (!chapters.length) { return (
@@ -468,56 +736,59 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { } return ( -
+
{tocOpen && typeof document !== 'undefined' ? createPortal(
setTocOpen(false)}> -
event.stopPropagation()} - > -
-
章节目录
-
{manifest.book.title} · {chapters.length} 章
-
+
event.stopPropagation()}>
{chapters.map((item, index) => { const active = index === currentIndex; - return ( - - ); + return ; })}
-
, - document.body +
, 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 ? <> - -
+ {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)}
+
+
+ {ttsPanelOpen ?
听书控制
{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}{Math.round(ttsChunkPercent)}%
{ttsError ?
{ttsError}
: null}
: null} + : null}
); } - function normalizeHrefForMatch(href?: string) { if (!href) return ''; try { @@ -785,6 +1056,7 @@ export default function BookReadPage() { useEffect(() => { const handleToggleSettings = () => { + if (manifest?.format === 'chapters') return; setSettingsOpen((prev) => !prev); setTocOpen(false); }; @@ -793,10 +1065,11 @@ export default function BookReadPage() { return () => { window.removeEventListener('books-read-toggle-settings', handleToggleSettings); }; - }, []); + }, [manifest?.format]); useEffect(() => { const handleToggleChapters = () => { + if (manifest?.format === 'chapters') return; setTocOpen((prev) => !prev); setSettingsOpen(false); }; @@ -805,10 +1078,11 @@ export default function BookReadPage() { return () => { window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); }; - }, []); + }, [manifest?.format]); useEffect(() => { const handleToggleTts = () => { + if (manifest?.format === 'chapters') return; setTtsBarVisible((prev) => { const next = !prev; if (!next) { @@ -824,7 +1098,7 @@ export default function BookReadPage() { return () => { window.removeEventListener('books-read-toggle-tts', handleToggleTts); }; - }, []); + }, [manifest?.format]); useEffect(() => {