diff --git a/src/components/FocusDisplay.tsx b/src/components/FocusDisplay.tsx index dc7564e..3e253c2 100644 --- a/src/components/FocusDisplay.tsx +++ b/src/components/FocusDisplay.tsx @@ -126,9 +126,9 @@ export const FocusDisplay: React.FC = ({ {token.prefix} - {/* Pivot (ORP): strictly mathematically centered at 50% */} + {/* Pivot (ORP): strictly mathematically centered at 50% with slot smoothing */} {token.pivot || ' '} diff --git a/src/components/LibraryModal.tsx b/src/components/LibraryModal.tsx index b043d8f..ddc4da8 100644 --- a/src/components/LibraryModal.tsx +++ b/src/components/LibraryModal.tsx @@ -47,7 +47,7 @@ export const LibraryModal: React.FC = ({ title = parsed.title; author = parsed.author; fullText = parsed.fullText; - chapters = parsed.chapters; + chapters = parsed.rawChapters; } else if (lower.endsWith('.txt') || lower.endsWith('.md')) { fullText = await file.text(); } else { diff --git a/src/core/epub.ts b/src/core/epub.ts index 0073bb4..5f68be0 100644 --- a/src/core/epub.ts +++ b/src/core/epub.ts @@ -1,14 +1,23 @@ -import JSZip from 'jszip'; -import type { Chapter } from '../types'; +export interface EpubChapterItem { + id: string; + title: string; + charOffset: number; +} export interface ParsedEpub { title: string; author: string; fullText: string; - chapters: Chapter[]; + rawChapters: EpubChapterItem[]; } +/** + * Client-side EPUB parser with dynamic JSZip import to reduce initial bundle size. + */ export async function parseEpubFile(file: File | Blob): Promise { + // Dynamic import JSZip to split ~100KB off the initial load bundle + const { default: JSZip } = await import('jszip'); + const arrayBuffer = await file.arrayBuffer(); const zip = await JSZip.loadAsync(arrayBuffer); @@ -39,8 +48,11 @@ export async function parseEpubFile(file: File | Blob): Promise { const opfDoc = domParser.parseFromString(opfXml, 'application/xml'); // Metadata - const title = opfDoc.querySelector('title, dc\\:title')?.textContent?.trim() || (file instanceof File ? file.name.replace(/\.epub$/i, '') : '未命名书籍'); - const author = opfDoc.querySelector('creator, dc\\:creator')?.textContent?.trim() || '未知作者'; + const title = + opfDoc.querySelector('title, dc\\:title')?.textContent?.trim() || + (file instanceof File ? file.name.replace(/\.epub$/i, '') : '未命名书籍'); + const author = + opfDoc.querySelector('creator, dc\\:creator')?.textContent?.trim() || '未知作者'; // Manifest: id -> href const manifestMap = new Map(); @@ -48,7 +60,6 @@ export async function parseEpubFile(file: File | Blob): Promise { const id = item.getAttribute('id'); const href = item.getAttribute('href'); if (id && href) { - // Decode URL encoding manifestMap.set(id, decodeURIComponent(href)); } }); @@ -62,7 +73,7 @@ export async function parseEpubFile(file: File | Blob): Promise { } }); - const chapters: Chapter[] = []; + const rawChapters: EpubChapterItem[] = []; let fullText = ''; let chapterIndex = 1; @@ -70,7 +81,6 @@ export async function parseEpubFile(file: File | Blob): Promise { const relativeHref = manifestMap.get(idref); if (!relativeHref) continue; - // Resolve full path inside zip const filePath = opfDir ? resolvePath(opfDir, relativeHref) : relativeHref; const chapterFile = zip.file(filePath); if (!chapterFile) continue; @@ -87,7 +97,6 @@ export async function parseEpubFile(file: File | Blob): Promise { // Extract text content cleanly const body = chapterDoc.body || chapterDoc.documentElement; - // Remove scripts and styles body.querySelectorAll('script, style, svg').forEach((el) => el.remove()); const paragraphs: string[] = []; @@ -105,10 +114,11 @@ export async function parseEpubFile(file: File | Blob): Promise { const chapterText = paragraphs.join('\n\n'); if (chapterText.trim().length > 0) { - chapters.push({ + const startCharOffset = fullText.length; + rawChapters.push({ id: `chap-${chapterIndex}`, title: chapterTitle, - startIndex: fullText.length, + charOffset: startCharOffset, }); fullText += (fullText ? '\n\n' : '') + chapterText; @@ -124,7 +134,7 @@ export async function parseEpubFile(file: File | Blob): Promise { title, author, fullText, - chapters, + rawChapters, }; } diff --git a/src/core/orp.ts b/src/core/orp.ts index 6d5d738..190c0dc 100644 --- a/src/core/orp.ts +++ b/src/core/orp.ts @@ -1,6 +1,8 @@ /** * Optimal Recognition Point (ORP) Calculation for Spritz RSVP Reading. * Fixates the eye at ~30%-35% into the word where human ocular fixation naturally rests. + * Uses unicode-safe code point segmentation (Array.from) to eliminate surrogate pair + * and emoji splitting defects. */ const CJK_REGEX = /[一-龥぀-ヿ가-힯]/; @@ -15,9 +17,11 @@ export function isCJKWord(word: string): boolean { /** * Calculates the 0-based index of the ORP character in cleanWord. + * Based on unicode code-point length (Array.from(cleanWord).length). */ export function calculateOrpIndex(cleanWord: string): number { - const len = cleanWord.length; + const chars = Array.from(cleanWord); + const len = chars.length; if (len <= 1) return 0; if (isCJKWord(cleanWord)) { @@ -45,6 +49,7 @@ export interface SplitWord { /** * Splits a token into prefix, pivot (ORP character), and suffix. * Handles leading punctuation (e.g. "“", "《", "(") and trailing punctuation. + * Operates purely on unicode code points to prevent corrupting surrogate pairs. */ export function splitOrp( coreWord: string, @@ -60,10 +65,11 @@ export function splitOrp( }; } + const chars = Array.from(coreWord); const orpIndex = calculateOrpIndex(coreWord); - const prefix = leadingPunct + coreWord.slice(0, orpIndex); - const pivot = coreWord[orpIndex] || ''; - const suffix = coreWord.slice(orpIndex + 1) + trailingPunct; + const prefix = leadingPunct + chars.slice(0, orpIndex).join(''); + const pivot = chars[orpIndex] || ''; + const suffix = chars.slice(orpIndex + 1).join('') + trailingPunct; return { prefix, diff --git a/src/core/tokenizer.ts b/src/core/tokenizer.ts index 41108b3..f35b7c6 100644 --- a/src/core/tokenizer.ts +++ b/src/core/tokenizer.ts @@ -10,7 +10,10 @@ const SENTENCE_PUNCT_REGEX = /[.!?。!?…\n]/; // Clause/pause punctuation const CLAUSE_PUNCT_REGEX = /[,;:,;:、—–]/; -// Chapter detection regex patterns +// Emoji / Extended pictographic Unicode matcher +const EMOJI_REGEX = /^\p{Extended_Pictographic}+$/u; + +// Chapter detection regex patterns for raw text / markdown const CHAPTER_REGEXES = [ /^(#{1,3}\s+.+)$/m, // # Markdown header /^(第[0-9一二三四五六七八九十百千万]+[章回卷节篇部集].*)$/m, // 第X章 ... @@ -20,6 +23,13 @@ const CHAPTER_REGEXES = [ /^(Act\s+[0-9IVXLCDM]+.*)$/im, // Act X ... ]; +export interface ChapterInputHint { + id?: string; + title: string; + charOffset?: number; + startIndex?: number; +} + export interface TokenizeResult { tokens: Token[]; chapters: Chapter[]; @@ -27,14 +37,28 @@ export interface TokenizeResult { export function tokenizeText( rawText: string, - existingChapters?: Chapter[] + existingChapters?: ChapterInputHint[] ): TokenizeResult { if (!rawText || !rawText.trim()) { return { tokens: [], chapters: [] }; } - const chapters: Chapter[] = existingChapters ? [...existingChapters] : []; - const autoDetectChapters = !existingChapters || existingChapters.length === 0; + // Determine chapter source + const hasExternalHints = existingChapters && existingChapters.length > 0; + const chapterHints: { id: string; title: string; charOffset: number }[] = []; + + if (hasExternalHints) { + existingChapters.forEach((c, idx) => { + chapterHints.push({ + id: c.id || `chap-${idx + 1}`, + title: c.title, + charOffset: c.charOffset ?? c.startIndex ?? 0, + }); + }); + chapterHints.sort((a, b) => a.charOffset - b.charOffset); + } + + const autoDetectChapters = !hasExternalHints; let segmenter: Intl.Segmenter | null = null; try { @@ -70,6 +94,7 @@ export function tokenizeText( } } + const chapters: Chapter[] = []; const tokens: Token[] = []; let pendingLeading = ''; let newlineCount = 0; @@ -82,8 +107,26 @@ export function tokenizeText( for (const seg of segments) { const { segment, isWordLike, index } = seg; - // Check if we reached a detected chapter boundary - if ( + // Align chapters based on character offset -> exact Token index + if (hasExternalHints) { + while ( + nextChapterIdx < chapterHints.length && + index >= chapterHints[nextChapterIdx].charOffset + ) { + const hint = chapterHints[nextChapterIdx]; + currentChapterIndex = chapters.length; + chapters.push({ + id: hint.id, + title: hint.title, + startIndex: tokens.length, // True token index + }); + nextChapterIdx++; + if (tokens.length > 0) { + tokens[tokens.length - 1].isParagraphEnd = true; + tokens[tokens.length - 1].isSentenceEnd = true; + } + } + } else if ( autoDetectChapters && nextChapterIdx < detectedChapterPositions.length && index >= detectedChapterPositions[nextChapterIdx].index @@ -93,7 +136,7 @@ export function tokenizeText( chapters.push({ id: `chap-${chapters.length + 1}`, title: chapInfo.line, - startIndex: tokens.length, + startIndex: tokens.length, // True token index }); nextChapterIdx++; if (tokens.length > 0) { @@ -116,22 +159,49 @@ export function tokenizeText( continue; } + // Emoji compatibility: Extended_Pictographic symbols stand as full tokens + if (EMOJI_REGEX.test(trimmed)) { + const { prefix, pivot, suffix, orpIndex } = splitOrp( + trimmed, + pendingLeading, + '' + ); + tokens.push({ + id: tokens.length, + text: prefix + pivot + suffix, + cleanWord: trimmed, + prefix, + pivot, + suffix, + orpIndex, + isParagraphEnd: newlineCount >= 2, + isSentenceEnd: false, + isClauseEnd: false, + isLongWord: false, + chapterIndex: currentChapterIndex, + }); + pendingLeading = ''; + newlineCount = 0; + continue; + } + if (LEADING_PUNCT_REGEX.test(trimmed)) { // Attaches to the NEXT token's prefix pendingLeading += trimmed; - } else { + } else if (tokens.length > 0) { // Trailing punctuation attached to the PREVIOUS token - if (tokens.length > 0) { - const prev = tokens[tokens.length - 1]; - prev.text += trimmed; - prev.suffix += trimmed; - if (SENTENCE_PUNCT_REGEX.test(trimmed)) { - prev.isSentenceEnd = true; - } - if (CLAUSE_PUNCT_REGEX.test(trimmed)) { - prev.isClauseEnd = true; - } + const prev = tokens[tokens.length - 1]; + prev.text += trimmed; + prev.suffix += trimmed; + if (SENTENCE_PUNCT_REGEX.test(trimmed)) { + prev.isSentenceEnd = true; } + if (CLAUSE_PUNCT_REGEX.test(trimmed)) { + prev.isClauseEnd = true; + } + } else { + // Leading punctuation or symbols at the start of text (don't discard) + pendingLeading += trimmed; } if (newlineCount >= 2 && tokens.length > 0) { @@ -145,8 +215,8 @@ export function tokenizeText( if (!coreWord) continue; const isLong = isCJKWord(coreWord) - ? coreWord.length >= 4 - : coreWord.length > 8; + ? Array.from(coreWord).length >= 4 + : Array.from(coreWord).length > 8; const { prefix, pivot, suffix, orpIndex } = splitOrp( coreWord, @@ -175,13 +245,13 @@ export function tokenizeText( newlineCount = 0; } } else { - // Regex fallback for environments without Intl.Segmenter - const wordPattern = /[一-龥]|[a-zA-Z0-9_À-ɏ]+|[^\s\w]/g; + // Fallback regex loop with Emoji support + const wordPattern = /\p{Extended_Pictographic}+|[一-龥]|[a-zA-Z0-9_À-ɏ]+|[^\s\w]/gu; let match: RegExpExecArray | null; while ((match = wordPattern.exec(rawText)) !== null) { const str = match[0]; - if (/[一-龥a-zA-Z0-9]/.test(str)) { + if (EMOJI_REGEX.test(str) || /[一-龥a-zA-Z0-9]/.test(str)) { const { prefix, pivot, suffix, orpIndex } = splitOrp( str, pendingLeading, @@ -198,26 +268,46 @@ export function tokenizeText( isParagraphEnd: newlineCount >= 2, isSentenceEnd: false, isClauseEnd: false, - isLongWord: str.length > 8, + isLongWord: Array.from(str).length > 8, chapterIndex: currentChapterIndex, }); pendingLeading = ''; newlineCount = 0; } else if (LEADING_PUNCT_REGEX.test(str)) { pendingLeading += str; + } else if (tokens.length > 0) { + const prev = tokens[tokens.length - 1]; + prev.text += str; + prev.suffix += str; + if (SENTENCE_PUNCT_REGEX.test(str)) prev.isSentenceEnd = true; + if (CLAUSE_PUNCT_REGEX.test(str)) prev.isClauseEnd = true; } else { - if (tokens.length > 0) { - const prev = tokens[tokens.length - 1]; - prev.text += str; - prev.suffix += str; - if (SENTENCE_PUNCT_REGEX.test(str)) prev.isSentenceEnd = true; - if (CLAUSE_PUNCT_REGEX.test(str)) prev.isClauseEnd = true; - } + pendingLeading += str; } } } - // If no chapters detected at all, supply default single chapter + // If text only consisted of pendingLeading symbols and no words + if (tokens.length === 0 && pendingLeading.trim().length > 0) { + const trimmed = pendingLeading.trim(); + const { prefix, pivot, suffix, orpIndex } = splitOrp(trimmed, '', ''); + tokens.push({ + id: 0, + text: trimmed, + cleanWord: trimmed, + prefix, + pivot, + suffix, + orpIndex, + isParagraphEnd: false, + isSentenceEnd: false, + isClauseEnd: false, + isLongWord: false, + chapterIndex: 0, + }); + } + + // If no chapters detected, supply default single chapter if (chapters.length === 0 && tokens.length > 0) { chapters.push({ id: 'chap-1', diff --git a/src/db/index.ts b/src/db/index.ts index c042b02..634aca6 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -24,115 +24,235 @@ export const DEFAULT_SETTINGS: ReaderSettings = { const SETTINGS_KEY = 'focus_reader_settings'; const ACTIVE_BOOK_KEY = 'focus_active_book_id'; +// Global In-Memory Fallback Map (for private browsing, restricted sandbox, or quota limits) +const memoryBooks = new Map(); +const memoryMeta = new Map(); +let useMemoryFallback = false; + function openDB(): Promise { return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); + try { + const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(STORE_BOOKS)) { - db.createObjectStore(STORE_BOOKS, { keyPath: 'id' }); - } - if (!db.objectStoreNames.contains(STORE_META)) { - const metaStore = db.createObjectStore(STORE_META, { keyPath: 'id' }); - metaStore.createIndex('lastReadAt', 'lastReadAt', { unique: false }); - } - }; + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_BOOKS)) { + db.createObjectStore(STORE_BOOKS, { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains(STORE_META)) { + const metaStore = db.createObjectStore(STORE_META, { keyPath: 'id' }); + metaStore.createIndex('lastReadAt', 'lastReadAt', { unique: false }); + } + }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + } catch (err) { + reject(err); + } }); } +async function getDB(): Promise { + if (useMemoryFallback) return null; + if (typeof indexedDB === 'undefined') { + useMemoryFallback = true; + return null; + } + + try { + return await openDB(); + } catch (err) { + console.warn('IndexedDB unavailable, engaging in-memory fallback store:', err); + useMemoryFallback = true; + return null; + } +} + export async function saveBook(book: Book): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite'); - const booksStore = tx.objectStore(STORE_BOOKS); - const metaStore = tx.objectStore(STORE_META); + const { tokens: _tokens, ...meta } = book; - const { tokens: _tokens, ...meta } = book; + // Always sync to memory fallback + memoryBooks.set(book.id, book); + memoryMeta.set(book.id, meta); - booksStore.put(book); - metaStore.put(meta); + const db = await getDB(); + if (!db) return; - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); + return new Promise((resolve) => { + try { + const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite'); + const booksStore = tx.objectStore(STORE_BOOKS); + const metaStore = tx.objectStore(STORE_META); + + booksStore.put(book); + metaStore.put(meta); + + tx.oncomplete = () => resolve(); + tx.onerror = (err) => { + console.warn('IndexedDB saveBook transaction error, cached in memory:', err); + resolve(); + }; + } catch (err) { + console.warn('saveBook error:', err); + resolve(); + } }); } export async function getBook(id: string): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_BOOKS, 'readonly'); - const store = tx.objectStore(STORE_BOOKS); - const request = store.get(id); + const db = await getDB(); + if (!db) { + return memoryBooks.get(id); + } - request.onsuccess = () => resolve(request.result as Book | undefined); - request.onerror = () => reject(request.error); + return new Promise((resolve) => { + try { + const tx = db.transaction([STORE_BOOKS, STORE_META], 'readonly'); + const booksStore = tx.objectStore(STORE_BOOKS); + const metaStore = tx.objectStore(STORE_META); + + let bookResult: Book | undefined; + let metaResult: BookMeta | undefined; + + const bookReq = booksStore.get(id); + bookReq.onsuccess = () => { + bookResult = bookReq.result as Book | undefined; + }; + + const metaReq = metaStore.get(id); + metaReq.onsuccess = () => { + metaResult = metaReq.result as BookMeta | undefined; + }; + + tx.oncomplete = () => { + if (bookResult) { + // Overlay latest progress & lastReadAt from STORE_META + if (metaResult) { + bookResult.currentProgress = metaResult.currentProgress; + bookResult.lastReadAt = metaResult.lastReadAt; + } + memoryBooks.set(id, bookResult); + if (metaResult) memoryMeta.set(id, metaResult); + } + resolve(bookResult || memoryBooks.get(id)); + }; + + tx.onerror = () => { + resolve(memoryBooks.get(id)); + }; + } catch { + resolve(memoryBooks.get(id)); + } }); } export async function getAllBooksMeta(): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_META, 'readonly'); - const store = tx.objectStore(STORE_META); - const request = store.getAll(); + const db = await getDB(); + if (!db) { + const list = Array.from(memoryMeta.values()); + list.sort((a, b) => b.lastReadAt - a.lastReadAt); + return list; + } - request.onsuccess = () => { - const list = (request.result as BookMeta[]) || []; - // Sort newest read first - list.sort((a, b) => b.lastReadAt - a.lastReadAt); - resolve(list); - }; - request.onerror = () => reject(request.error); + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_META, 'readonly'); + const store = tx.objectStore(STORE_META); + const request = store.getAll(); + + request.onsuccess = () => { + const list = (request.result as BookMeta[]) || []; + list.sort((a, b) => b.lastReadAt - a.lastReadAt); + // Sync to memory + list.forEach((m) => memoryMeta.set(m.id, m)); + resolve(list); + }; + request.onerror = () => { + const fallback = Array.from(memoryMeta.values()); + fallback.sort((a, b) => b.lastReadAt - a.lastReadAt); + resolve(fallback); + }; + } catch { + const fallback = Array.from(memoryMeta.values()); + fallback.sort((a, b) => b.lastReadAt - a.lastReadAt); + resolve(fallback); + } }); } +/** + * Slim Progress Update: + * Updates ONLY STORE_META (a lightweight record < 1KB), avoiding full serialization + * and deserialization of megabyte-sized token arrays. + */ export async function updateBookProgress(id: string, progress: number): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite'); - const booksStore = tx.objectStore(STORE_BOOKS); - const metaStore = tx.objectStore(STORE_META); + const now = Date.now(); - const now = Date.now(); + // Update in-memory stores immediately + const m = memoryMeta.get(id); + if (m) { + m.currentProgress = progress; + m.lastReadAt = now; + } + const b = memoryBooks.get(id); + if (b) { + b.currentProgress = progress; + b.lastReadAt = now; + } - const getReq = booksStore.get(id); - getReq.onsuccess = () => { - const book = getReq.result as Book | undefined; - if (book) { - book.currentProgress = progress; - book.lastReadAt = now; - booksStore.put(book); - } - }; + const db = await getDB(); + if (!db) return; - const getMetaReq = metaStore.get(id); - getMetaReq.onsuccess = () => { - const meta = getMetaReq.result as BookMeta | undefined; - if (meta) { - meta.currentProgress = progress; - meta.lastReadAt = now; - metaStore.put(meta); - } - }; + return new Promise((resolve) => { + try { + // ONLY readwrite STORE_META! Zero serialization overhead. + const tx = db.transaction(STORE_META, 'readwrite'); + const metaStore = tx.objectStore(STORE_META); + const getMetaReq = metaStore.get(id); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); + getMetaReq.onsuccess = () => { + const meta = getMetaReq.result as BookMeta | undefined; + if (meta) { + meta.currentProgress = progress; + meta.lastReadAt = now; + metaStore.put(meta); + } + }; + + tx.oncomplete = () => resolve(); + tx.onerror = (err) => { + console.warn('Failed to update meta progress in IndexedDB:', err); + resolve(); + }; + } catch (err) { + console.warn('updateBookProgress error:', err); + resolve(); + } }); } export async function deleteBook(id: string): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite'); - tx.objectStore(STORE_BOOKS).delete(id); - tx.objectStore(STORE_META).delete(id); + memoryBooks.delete(id); + memoryMeta.delete(id); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); + const db = await getDB(); + if (!db) return; + + return new Promise((resolve) => { + try { + const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite'); + tx.objectStore(STORE_BOOKS).delete(id); + tx.objectStore(STORE_META).delete(id); + + tx.oncomplete = () => resolve(); + tx.onerror = (err) => { + console.warn('Failed to delete book in IndexedDB:', err); + resolve(); + }; + } catch { + resolve(); + } }); } diff --git a/src/hooks/useReaderEngine.ts b/src/hooks/useReaderEngine.ts index 9699997..a6ebfcd 100644 --- a/src/hooks/useReaderEngine.ts +++ b/src/hooks/useReaderEngine.ts @@ -43,13 +43,15 @@ export function useReaderEngine({ const nextToken = currentIndex < tokens.length - 1 ? tokens[currentIndex + 1] || null : null; - // Refs for timer and async execution + // Refs for timer and clock synchronization const settingsRef = useRef(settings); const isPlayingRef = useRef(isPlaying); const currentIndexRef = useRef(currentIndex); const tokensRef = useRef(tokens); const timerRef = useRef(null); + const expectedTickTimeRef = useRef(null); const lastSavedIndexRef = useRef(currentIndex); + const lastSaveTimestampRef = useRef(0); const scheduleNextRef = useRef<((fromIdx: number) => void) | null>(null); // Synchronize refs @@ -69,18 +71,24 @@ export function useReaderEngine({ tokensRef.current = tokens; }, [tokens]); - // Periodic throttled persistence to IndexedDB + // Periodic throttled persistence to IndexedDB (every 4s or immediately on pause) useEffect(() => { if (!book) return; - if (Math.abs(currentIndex - lastSavedIndexRef.current) >= 5 || !isPlaying) { + const now = Date.now(); + const isPaused = !isPlaying; + const timeSinceLastSave = now - lastSaveTimestampRef.current; + const shouldSave = isPaused || timeSinceLastSave >= 4000; + + if (shouldSave && currentIndex !== lastSavedIndexRef.current) { lastSavedIndexRef.current = currentIndex; + lastSaveTimestampRef.current = now; updateBookProgress(book.id, currentIndex); onBookProgressUpdate?.(book.id, currentIndex); } }, [currentIndex, isPlaying, book, onBookProgressUpdate]); - // Setup recursion handler in ref + // Setup recursion handler with clock drift compensation useEffect(() => { scheduleNextRef.current = (fromIndex: number) => { if (timerRef.current) { @@ -99,11 +107,26 @@ export function useReaderEngine({ if (fromIndex >= curTokens.length - 1) { setIsPlaying(false); isPlayingRef.current = false; + expectedTickTimeRef.current = null; return; } const tok = curTokens[fromIndex]; - const delay = calculateTokenDelay(tok, settingsRef.current); + const nominalDelay = calculateTokenDelay(tok, settingsRef.current); + const now = performance.now(); + + // Adaptive clock drift compensation + if ( + expectedTickTimeRef.current === null || + Math.abs(now - expectedTickTimeRef.current) > 500 + ) { + expectedTickTimeRef.current = now + nominalDelay; + } else { + expectedTickTimeRef.current += nominalDelay; + } + + const drift = now - (expectedTickTimeRef.current - nominalDelay); + const compensatedDelay = Math.max(10, Math.round(nominalDelay - drift)); timerRef.current = window.setTimeout(() => { if (!isPlayingRef.current) return; @@ -113,7 +136,7 @@ export function useReaderEngine({ currentIndexRef.current = nextIndex; scheduleNextRef.current?.(nextIndex); - }, delay); + }, compensatedDelay); }; }); @@ -128,6 +151,7 @@ export function useReaderEngine({ setIsPlaying(true); isPlayingRef.current = true; + expectedTickTimeRef.current = null; scheduleNextRef.current?.(currentIndexRef.current); }, []); @@ -136,10 +160,24 @@ export function useReaderEngine({ window.clearTimeout(timerRef.current); timerRef.current = null; } + expectedTickTimeRef.current = null; setIsPlaying(false); isPlayingRef.current = false; }, []); + // Background visibility change auto-pause + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden && isPlayingRef.current) { + pause(); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [pause]); + const togglePlay = useCallback(() => { if (isPlayingRef.current) { pause(); @@ -153,6 +191,7 @@ export function useReaderEngine({ const target = Math.max(0, Math.min(curTokens.length - 1, index)); setCurrentIndex(target); currentIndexRef.current = target; + expectedTickTimeRef.current = null; if (isPlayingRef.current) { if (timerRef.current) {