diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index 286582e..d4b72d6 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -15,6 +15,13 @@ import { 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 { @@ -273,6 +280,13 @@ function formatBytes(size: number): string { 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, ' ') @@ -436,6 +450,7 @@ export default function BookReadPage() { const ttsSeekingRef = useRef(false); const ttsPrefetchedFromChunkRef = useRef(null); const ttsPrefetchFnRef = useRef<(fromIndex: number) => void>(() => undefined); + const ttsResumeTimeRef = useRef(0); useEffect(() => { setSettings(loadReaderSettings()); @@ -715,6 +730,7 @@ export default function BookReadPage() { 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, @@ -735,6 +751,27 @@ export default function BookReadPage() { 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' }, @@ -756,6 +793,26 @@ export default function BookReadPage() { 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]); @@ -778,7 +835,7 @@ export default function BookReadPage() { const chunks = ttsChunksRef.current; const chunk = chunks[index]; const chapterHref = ttsCurrentChapterHrefRef.current; - if (!chunk || !chapterHref) return; + if (!chunk || !chapterHref || !manifest) return; try { setTtsError(''); setTtsLoadingChunkIndex(index); @@ -788,6 +845,11 @@ export default function BookReadPage() { 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); @@ -799,7 +861,7 @@ export default function BookReadPage() { setTtsLoadingChunkIndex(null); setTtsError((error as Error).message || '朗读失败'); } - }, [fetchTtsChunkAudioUrl, persistTtsProgress]); + }, [fetchTtsChunkAudioUrl, manifest, persistTtsProgress]); const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => { if (!manifest || manifest.format !== 'epub') return; @@ -1076,6 +1138,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)); + ttsResumeTimeRef.current = 0; + } setTtsDuration(nextDuration); if (!ttsSeekingRef.current) { setTtsSeekValue(audio.currentTime || 0); @@ -1433,7 +1499,7 @@ export default function BookReadPage() {
{selectedVoice?.displayName || '默认音色'} - {Math.floor(displayedTtsTime)}s / {Math.floor(ttsDuration || 0)}s + {formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}
diff --git a/src/lib/book-tts-cache.client.ts b/src/lib/book-tts-cache.client.ts new file mode 100644 index 0000000..4991f67 --- /dev/null +++ b/src/lib/book-tts-cache.client.ts @@ -0,0 +1,162 @@ +'use client'; + +const DB_NAME = 'moontv_book_tts'; +const STORE_NAME = 'audio_chunks'; +const DB_VERSION = 1; +const DEFAULT_CACHE_LIMIT = 150 * 1024 * 1024; + +export interface CachedBookTtsChunk { + cacheKey: string; + sourceId: string; + bookId: string; + chapterHref: string; + chunkIndex: number; + textHash: string; + voice: string; + rate: string; + pitch: string; + volume: string; + textPreview: string; + mimeType: string; + audioBlob: Blob; + size: number; + duration?: number; + createdAt: number; + lastAccessAt: number; +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'cacheKey' }); + store.createIndex('lastAccessAt', 'lastAccessAt', { unique: false }); + store.createIndex('book', ['sourceId', 'bookId'], { unique: false }); + store.createIndex('chapter', ['sourceId', 'bookId', 'chapterHref'], { unique: false }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('打开听书缓存失败')); + }); +} + +async function sha256(input: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + return Array.from(new Uint8Array(digest)) + .map((item) => item.toString(16).padStart(2, '0')) + .join(''); +} + +export async function buildBookTtsCacheKey(input: { + sourceId: string; + bookId: string; + chapterHref: string; + chunkIndex: number; + text: string; + voice: string; + rate: string; + pitch: string; + volume: string; +}): Promise<{ cacheKey: string; textHash: string }> { + const textHash = await sha256(input.text); + const raw = JSON.stringify({ + sourceId: input.sourceId, + bookId: input.bookId, + chapterHref: input.chapterHref, + chunkIndex: input.chunkIndex, + voice: input.voice, + rate: input.rate, + pitch: input.pitch, + volume: input.volume, + textHash, + }); + return { + cacheKey: `bookTts:${await sha256(raw)}`, + textHash, + }; +} + +export async function getCachedBookTtsChunk(cacheKey: string): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const request = tx.objectStore(STORE_NAME).get(cacheKey); + request.onsuccess = () => { + db.close(); + resolve((request.result as CachedBookTtsChunk | undefined) || null); + }; + request.onerror = () => { + db.close(); + reject(request.error || new Error('读取听书缓存失败')); + }; + }); +} + +export async function putCachedBookTtsChunk(record: CachedBookTtsChunk): Promise { + const db = await openDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(record); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error || new Error('写入听书缓存失败')); + }; + }); +} + +export async function touchCachedBookTtsChunk(cacheKey: string): Promise { + const current = await getCachedBookTtsChunk(cacheKey); + if (!current) return; + await putCachedBookTtsChunk({ ...current, lastAccessAt: Date.now() }); +} + +export async function listCachedBookTtsChunks(): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const request = tx.objectStore(STORE_NAME).getAll(); + request.onsuccess = () => { + db.close(); + resolve((request.result as CachedBookTtsChunk[]) || []); + }; + request.onerror = () => { + db.close(); + reject(request.error || new Error('读取听书缓存列表失败')); + }; + }); +} + +export async function deleteCachedBookTtsChunk(cacheKey: string): Promise { + const db = await openDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).delete(cacheKey); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error || new Error('删除听书缓存失败')); + }; + }); +} + +export async function enforceBookTtsCacheLimit(limit = DEFAULT_CACHE_LIMIT): Promise { + const items = await listCachedBookTtsChunks(); + const total = items.reduce((sum, item) => sum + item.size, 0); + if (total <= limit) return; + let current = total; + const sorted = [...items].sort((a, b) => a.lastAccessAt - b.lastAccessAt); + for (const item of sorted) { + if (current <= limit) break; + await deleteCachedBookTtsChunk(item.cacheKey); + current -= item.size; + } +} diff --git a/src/lib/book.types.ts b/src/lib/book.types.ts index 9ee1837..4c4e157 100644 --- a/src/lib/book.types.ts +++ b/src/lib/book.types.ts @@ -149,6 +149,7 @@ export interface BookTtsProgress { chapterTitle?: string; chunkIndex: number; charOffset: number; + currentTimeSec?: number; voice: string; rate: string; pitch: string;