diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index 7f39aef..a4d00da 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -102,6 +102,12 @@ interface TtsSettings { autoPlayNext: boolean; } +interface TtsVoicesCache { + voices: BookTtsVoice[]; + defaults?: Partial; + savedAt: number; +} + interface ScrolledReadingPosition { href: string; scrollTop: number; @@ -114,7 +120,8 @@ 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 TTS_SETTINGS_STORAGE_KEY = 'books_tts_settings'; +const TTS_VOICES_STORAGE_KEY = 'books_tts_voices_cache'; const DEFAULT_SETTINGS: ReaderSettings = { fontSize: 100, lineHeight: 1.7, @@ -151,6 +158,38 @@ function loadTtsSettings(): TtsSettings { } } +function loadCachedTtsVoices(): TtsVoicesCache | null { + if (typeof window === 'undefined') return null; + try { + const raw = localStorage.getItem(TTS_VOICES_STORAGE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw) as Partial; + if (!Array.isArray(cache.voices) || cache.voices.length === 0) return null; + return { + voices: cache.voices, + defaults: cache.defaults || {}, + savedAt: typeof cache.savedAt === 'number' ? cache.savedAt : 0, + }; + } catch { + return 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() })); +} + +function applyTtsDefaults(settings: TtsSettings, defaults?: Partial): TtsSettings { + return { + ...settings, + voice: settings.voice || defaults?.voice || '', + rate: settings.rate || defaults?.rate || '+0%', + pitch: settings.pitch || defaults?.pitch || '+0Hz', + volume: settings.volume || defaults?.volume || '+0%', + }; +} + function parseSignedNumber(value: string, _suffix: '%' | 'Hz') { const match = value.match(/([+-]?\d+)(?:%|Hz)/); if (!match) return 0; @@ -418,7 +457,7 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const [error, setError] = useState(''); const [ttsVoices, setTtsVoices] = useState([]); const [ttsAvailable, setTtsAvailable] = useState(false); - const [ttsSettings, setTtsSettings] = useState(DEFAULT_TTS_SETTINGS); + const [ttsSettings, setTtsSettings] = useState(() => loadTtsSettings()); const [ttsStatus, setTtsStatus] = useState('idle'); const [ttsError, setTtsError] = useState(''); const [ttsChunks, setTtsChunks] = useState([]); @@ -451,7 +490,6 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { useEffect(() => { setSettings(loadReaderSettings()); - setTtsSettings(loadTtsSettings()); }, []); useEffect(() => { @@ -504,6 +542,13 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { }, []); useEffect(() => { + const cachedVoices = loadCachedTtsVoices(); + if (cachedVoices) { + setTtsAvailable(true); + setTtsVoices(cachedVoices.voices); + setTtsSettings((prev) => applyTtsDefaults(prev, cachedVoices.defaults)); + return; + } let cancelled = false; fetch('/api/books/tts/voices') .then(async (res) => { @@ -512,7 +557,8 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { if (cancelled) return; setTtsAvailable(true); setTtsVoices(json.voices || []); - setTtsSettings((prev) => ({ ...prev, voice: prev.voice || json.defaults?.voice || '' })); + setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults)); + saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} }); }) .catch((err) => { if (!cancelled) { @@ -935,7 +981,7 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
{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} + {ttsPanelOpen ?
听书控制
{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}{Math.round(ttsChunkPercent)}%
{ttsError ?
{ttsError}
: null}
: null} : null} ); @@ -1106,7 +1152,7 @@ export default function BookReadPage() { const [settingsOpen, setSettingsOpen] = useState(false); const [tocOpen, setTocOpen] = useState(false); const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [ttsSettings, setTtsSettings] = useState(DEFAULT_TTS_SETTINGS); + const [ttsSettings, setTtsSettings] = useState(() => loadTtsSettings()); const [tocItems, setTocItems] = useState([]); const [currentHref, setCurrentHref] = useState(''); const [currentChapter, setCurrentChapter] = useState(''); @@ -1169,7 +1215,6 @@ export default function BookReadPage() { useEffect(() => { setSettings(loadReaderSettings()); - setTtsSettings(loadTtsSettings()); }, []); useEffect(() => { @@ -1281,6 +1326,13 @@ export default function BookReadPage() { useEffect(() => { if (!manifest || manifest.format !== 'epub') return; + const cachedVoices = loadCachedTtsVoices(); + if (cachedVoices) { + setTtsAvailable(true); + setTtsVoices(cachedVoices.voices); + setTtsSettings((prev) => applyTtsDefaults(prev, cachedVoices.defaults)); + return; + } let cancelled = false; fetch('/api/books/tts/voices') .then(async (res) => { @@ -1289,13 +1341,8 @@ export default function BookReadPage() { 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%', - })); + setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults)); + saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} }); }) .catch((err) => { if (cancelled) return;