diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 2463249aa..a1bcb6711 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -197,6 +197,7 @@ let lastRendererStyles = ''; let currentViewMode = 'paginated'; let currentSectionUsesVerticalWriting = false; + let lastRelocateRange = null; let currentPaginatedLayout = 'double'; let currentFontSize = 16; let currentLineHeight = 1.6; @@ -272,7 +273,7 @@ } if (ttsHighlightState.cfi) { - setTTSHighlight(ttsHighlightState.cfi, ttsHighlightState.color); + applyTTSHighlight(ttsHighlightState.cfi, ttsHighlightState.color); } }, delay == null ? 100 : delay); } @@ -644,7 +645,7 @@ if (!view || !current || !current.doc) return null; try { - await view.initTTS('sentence', (range) => { + await view.initTTS('sentence', ttsNodeFilter, (range) => { const sourceDoc = range && range.startContainer ? (range.startContainer.nodeType === Node.DOCUMENT_NODE ? range.startContainer @@ -686,6 +687,16 @@ } return cfi; + }, (range) => { + const sourceDoc = range && range.startContainer + ? (range.startContainer.nodeType === Node.DOCUMENT_NODE + ? range.startContainer + : range.startContainer.ownerDocument) + : null; + const source = getRendererContentForDoc(sourceDoc) || getCurrentTTSOverlayerContext(); + return source && source.index != null && view.getCFI + ? view.getCFI(source.index, range.cloneRange()) + : ''; }); return view.tts || null; } catch (e) { @@ -694,48 +705,6 @@ } } - function drawTTSHighlight(rects, options) { - const { color = 'rgba(96, 165, 250, 0.35)', padding = 1, radius = 4, radiusPadding = 2 } = options || {}; - const svgNS = 'http://www.w3.org/2000/svg'; - const g = document.createElementNS(svgNS, 'g'); - g.setAttribute('fill', color); - g.style.opacity = 'var(--overlayer-highlight-opacity, 1)'; - g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)'; - - for (let index = 0; index < rects.length; index++) { - const { left, top, width, height } = rects[index]; - const isFirst = index === 0; - const isLast = index === rects.length - 1; - const x = left - padding - (isFirst ? radiusPadding : 0); - const y = top - padding; - const w = width + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0); - const h = height + padding * 2; - const rtl = isLast ? radius : 0; - const rtr = isFirst ? radius : 0; - const rbr = isFirst ? radius : 0; - const rbl = isLast ? radius : 0; - const path = document.createElementNS(svgNS, 'path'); - path.setAttribute( - 'd', - [ - `M ${x + rtl} ${y}`, - `H ${x + w - rtr}`, - rtr ? `Q ${x + w} ${y} ${x + w} ${y + rtr}` : '', - `V ${y + h - rbr}`, - rbr ? `Q ${x + w} ${y + h} ${x + w - rbr} ${y + h}` : '', - `H ${x + rbl}`, - rbl ? `Q ${x} ${y + h} ${x} ${y + h - rbl}` : '', - `V ${y + rtl}`, - rtl ? `Q ${x} ${y} ${x + rtl} ${y}` : '', - 'Z', - ].filter(Boolean).join(' ') - ); - g.append(path); - } - - return g; - } - function resolveTTSHighlightColor(color) { if (!color) return 'rgba(96, 165, 250, 0.35)'; // If it's already an rgba/rgb value, use as-is @@ -812,6 +781,48 @@ }, 900); } + function drawTTSHighlight(rects, options) { + const { color = 'rgba(96, 165, 250, 0.35)', padding = 1, radius = 4, radiusPadding = 2 } = options || {}; + const svgNS = 'http://www.w3.org/2000/svg'; + const g = document.createElementNS(svgNS, 'g'); + g.setAttribute('fill', color); + g.style.opacity = 'var(--overlayer-highlight-opacity, 1)'; + g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)'; + + for (let index = 0; index < rects.length; index++) { + const { left, top, width, height } = rects[index]; + const isFirst = index === 0; + const isLast = index === rects.length - 1; + const x = left - padding - (isFirst ? radiusPadding : 0); + const y = top - padding; + const w = width + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0); + const h = height + padding * 2; + const rtl = isLast ? radius : 0; + const rtr = isFirst ? radius : 0; + const rbr = isFirst ? radius : 0; + const rbl = isLast ? radius : 0; + const path = document.createElementNS(svgNS, 'path'); + path.setAttribute( + 'd', + [ + `M ${x + rtl} ${y}`, + `H ${x + w - rtr}`, + rtr ? `Q ${x + w} ${y} ${x + w} ${y + rtr}` : '', + `V ${y + h - rbr}`, + rbr ? `Q ${x + w} ${y + h} ${x + w - rbr} ${y + h}` : '', + `H ${x + rbl}`, + rbl ? `Q ${x} ${y + h} ${x} ${y + h - rbl}` : '', + `V ${y + rtl}`, + rtl ? `Q ${x} ${y} ${x + rtl} ${y}` : '', + 'Z', + ].filter(Boolean).join(' ') + ); + g.append(path); + } + + return g; + } + async function followTTSLocation(cfi) { if (!view || !view.renderer || !cfi) return; const resolved = resolveRangeForCfi(cfi); @@ -835,20 +846,16 @@ } const renderer = view.renderer; - const win = doc.defaultView; - if (!win) return; - if (!isRangeVisibleInReader(range, renderer, win)) { + if (renderer.scrolled) { try { await renderer.scrollToAnchor(range, true); } catch (e) { - try { - await view.goTo(cfi); - } catch (_) {} + try { await view.goTo(cfi); } catch (_) {} } } } - async function setTTSHighlight(cfi, color) { + async function applyTTSHighlight(cfi, color) { postToRN('debug', { message: '[ttsHighlight] setTTSHighlight called: cfi=' + (cfi || 'null') + ' color=' + (color || 'null') }); const previousValue = ttsHighlightState.annotationValue; const annotationValue = cfi ? `${TTS_ANNOTATION_PREFIX}${cfi}` : null; @@ -1354,7 +1361,7 @@ if (view && msg.settings) applySettings(msg.settings); break; case 'setTTSHighlight': - await setTTSHighlight(msg.cfi, msg.color); + await applyTTSHighlight(msg.cfi, msg.color); break; case 'clearTTSHighlight': await clearTTSHighlight(); @@ -1550,6 +1557,9 @@ markLoaded(); hideFootnoteTip(); const detail = e.detail; + lastRelocateRange = detail && detail.range && detail.range.cloneRange + ? detail.range.cloneRange() + : null; const previousSectionIndex = currentSectionIndex; if (tapNavigationInFlight) { setTimeout(() => { @@ -3073,18 +3083,17 @@ } } - function getCurrentRendererContent() { - const contents = getRendererContents(); - return contents[0] || null; - } - function anchorToRange(doc, anchor) { if (!doc || !anchor) return null; - if (anchor instanceof Range) return anchor; - if (!(anchor instanceof Node)) return null; + if ( + typeof anchor.cloneRange === 'function' && + anchor.startContainer && + anchor.endContainer + ) return anchor; + if (typeof anchor.nodeType !== 'number') return null; try { const range = doc.createRange(); - if (anchor.nodeType === Node.TEXT_NODE) { + if (anchor.nodeType === 3) { range.selectNodeContents(anchor); } else { range.selectNode(anchor); @@ -3131,274 +3140,6 @@ return null; } - function getPaginatedVisibleRangeCandidates(renderer) { - const start = Number(renderer && renderer.start || 0); - const end = Number(renderer && renderer.end || 0); - const size = Number(renderer && renderer.size || 0); - const candidates = []; - - if (Number.isFinite(start) && Number.isFinite(size) && size > 0) { - candidates.push({ left: start - size, right: start, source: 'legacy-offset' }); - candidates.push({ left: start, right: start + size, source: 'size-fallback' }); - } - - if (Number.isFinite(start) && Number.isFinite(end) && end > start) { - candidates.push({ left: start, right: end, source: 'renderer' }); - } - - return candidates.filter((candidate, index, list) => { - return candidate.right > candidate.left && - list.findIndex((item) => item.left === candidate.left && item.right === candidate.right) === index; - }); - } - - function rectIntersectsPaginatedRange(rect, range) { - return rect.right > range.left && rect.left < range.right; - } - - function scorePaginatedVisibleRange(doc, range) { - if (!doc || !doc.body) return 0; - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { - acceptNode: function (node) { - if (isInsideRubyAnnotation(node)) return NodeFilter.FILTER_REJECT; - if (isTTSFootnoteMarker(node.nodeValue || '')) return NodeFilter.FILTER_REJECT; - if (shouldSkipTTSNode(node.parentElement)) return NodeFilter.FILTER_REJECT; - return node.nodeValue && normalizeTTSText(node.nodeValue) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_SKIP; - }, - }); - let score = 0; - let visited = 0; - let textNode = walker.nextNode(); - while (textNode && visited < 500) { - visited += 1; - const text = normalizeTTSText(textNode.nodeValue || ''); - if (text) { - try { - const textRange = doc.createRange(); - textRange.selectNodeContents(textNode); - const rects = Array.from(textRange.getClientRects ? textRange.getClientRects() : []); - if (rects.some((rect) => rect.width > 0 && rect.height > 0 && rectIntersectsPaginatedRange(rect, range))) { - score += Math.min(text.length, 120); - } - } catch {} - } - textNode = walker.nextNode(); - } - return score; - } - - function pickPaginatedVisibleRange(doc, renderer) { - const candidates = getPaginatedVisibleRangeCandidates(renderer); - if (candidates.length <= 1) return candidates[0] || null; - - const legacyRange = candidates.find((range) => range.source === 'legacy-offset') || null; - if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) return legacyRange; - - const rendererRange = candidates.find((range) => range.source === 'renderer') || null; - if (rendererRange && scorePaginatedVisibleRange(doc, rendererRange) > 0) return rendererRange; - - const fallbackRange = candidates.find((range) => range.source === 'size-fallback') || null; - if (fallbackRange && scorePaginatedVisibleRange(doc, fallbackRange) > 0) return fallbackRange; - - return legacyRange || rendererRange || fallbackRange || candidates[0] || null; - } - - let ttsVisibleRangeByDoc = new WeakMap(); - - function getVisibleRangeForTTSDoc(doc, renderer) { - if (!doc) return null; - if (ttsVisibleRangeByDoc.has(doc)) return ttsVisibleRangeByDoc.get(doc) || null; - const range = pickPaginatedVisibleRange(doc, renderer); - ttsVisibleRangeByDoc.set(doc, range); - return range; - } - - function getRangeDocument(range) { - const container = range && range.commonAncestorContainer; - if (!container) return null; - return container.nodeType === Node.DOCUMENT_NODE ? container : container.ownerDocument; - } - - function getReaderViewportRect() { - const viewport = { - left: 0, - top: 0, - right: window.innerWidth || document.documentElement.clientWidth || 0, - bottom: window.innerHeight || document.documentElement.clientHeight || 0, - }; - try { - const viewRect = view && view.getBoundingClientRect ? view.getBoundingClientRect() : null; - if (!viewRect || viewRect.width <= 0 || viewRect.height <= 0) return viewport; - return { - left: Math.max(viewRect.left, viewport.left), - top: Math.max(viewRect.top, viewport.top), - right: Math.min(viewRect.right, viewport.right), - bottom: Math.min(viewRect.bottom, viewport.bottom), - }; - } catch (e) { - return viewport; - } - } - - function mapIframeRectToHost(rect, doc) { - try { - const iframe = doc && doc.defaultView && doc.defaultView.frameElement; - if (!iframe || !iframe.getBoundingClientRect) return rect; - const iframeRect = iframe.getBoundingClientRect(); - const scaleX = iframe.clientWidth > 0 ? iframeRect.width / iframe.clientWidth : 1; - const scaleY = iframe.clientHeight > 0 ? iframeRect.height / iframe.clientHeight : 1; - return { - left: iframeRect.left + rect.left * scaleX, - top: iframeRect.top + rect.top * scaleY, - right: iframeRect.left + rect.right * scaleX, - bottom: iframeRect.top + rect.bottom * scaleY, - width: rect.width * scaleX, - height: rect.height * scaleY, - }; - } catch (e) { - return rect; - } - } - - function isHostRectVisible(rect) { - if (!rect || rect.width <= 0 || rect.height <= 0) return false; - const viewport = getReaderViewportRect(); - return rect.right > viewport.left && - rect.left < viewport.right && - rect.bottom > viewport.top && - rect.top < viewport.bottom; - } - - function getContentHostRect(content) { - const doc = content && content.doc; - const iframe = doc && doc.defaultView && doc.defaultView.frameElement; - if (iframe && iframe.getBoundingClientRect) { - const rect = iframe.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }; - } - if (doc && doc.body) { - return mapIframeRectToHost(doc.body.getBoundingClientRect(), doc); - } - return null; - } - - function getVisibleContentsForTTS(contents, renderer) { - if (!renderer || !renderer.scrolled) return contents; - return (contents || []) - .filter(function(content) { return !!(content && content.doc); }) - .map(function(content) { - return { content: content, rect: getContentHostRect(content) }; - }) - .filter(function(item) { - return item.rect && isHostRectVisible(item.rect); - }) - .sort(function(a, b) { - const topDelta = (a.rect ? a.rect.top : 0) - (b.rect ? b.rect.top : 0); - return Math.abs(topDelta) > 1 - ? topDelta - : (a.rect ? a.rect.left : 0) - (b.rect ? b.rect.left : 0); - }) - .map(function(item) { return item.content; }); - } - - function isRectVisibleInReader(rect, renderer, win, doc) { - if (!rect || rect.width <= 0 || rect.height <= 0) return false; - const isPaginated = !renderer.scrolled; - if (isPaginated && renderer.size > 0) { - const visibleRange = getVisibleRangeForTTSDoc(doc, renderer); - return visibleRange ? rectIntersectsPaginatedRange(rect, visibleRange) : false; - } - return isHostRectVisible(mapIframeRectToHost(rect, doc)); - } - - function isRangeVisibleInReader(range, renderer, win) { - if (!range || !renderer || !win) return false; - try { - const rects = Array.from(range.getClientRects ? range.getClientRects() : []); - const doc = getRangeDocument(range); - if (!rects.length) { - return isRectVisibleInReader(range.getBoundingClientRect(), renderer, win, doc); - } - return rects.some((rect) => isRectVisibleInReader(rect, renderer, win, doc)); - } catch (e) { - console.log('[visibleTTSSegments] failed to inspect range rects:', e); - return false; - } - } - - function isRangeStartVisibleInReader(range, renderer, win) { - if (!range || !renderer || !win) return false; - try { - const rects = Array.from(range.getClientRects ? range.getClientRects() : []); - const doc = getRangeDocument(range); - if (!rects.length) { - return isRectVisibleInReader(range.getBoundingClientRect(), renderer, win, doc); - } - return isRectVisibleInReader(rects[0], renderer, win, doc); - } catch (e) { - console.log('[visibleTTSSegments] failed to inspect range start rect:', e); - return false; - } - } - - function getVisibleBlocksForTTS(doc, renderer) { - const blockSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; - const blocks = doc.querySelectorAll(blockSelector); - const win = doc.defaultView; - if (!win) return []; - return Array.from(blocks).filter((block) => { - if (!block || !block.textContent || !block.textContent.trim()) return false; - if (shouldSkipTTSNode(block)) return false; - try { - const range = doc.createRange(); - range.selectNodeContents(block); - return isRangeVisibleInReader(range, renderer, win); - } catch (e) { - return isRectVisibleInReader(block.getBoundingClientRect(), renderer, win, doc); - } - }); - } - - function getReadableBlocksForTTS(doc) { - const blockSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; - return Array.from(doc.querySelectorAll(blockSelector)).filter((block) => { - if (!block || !block.textContent || !block.textContent.trim()) return false; - if (shouldSkipTTSNode(block)) return false; - return true; - }); - } - - function resolveTextPosition(textNodes, absoluteOffset, isEnd) { - if (!textNodes.length) return null; - for (var i = 0; i < textNodes.length; i++) { - var item = textNodes[i]; - if (absoluteOffset < item.end || (isEnd && absoluteOffset <= item.end)) { - return { - node: item.node, - offset: Math.max(0, Math.min(item.node.nodeValue.length, absoluteOffset - item.start)), - }; - } - } - const last = textNodes[textNodes.length - 1]; - return { node: last.node, offset: last.node.nodeValue.length }; - } - - function fallbackSentenceSegments(text) { - const normalized = normalizeTTSText(text); - if (!normalized) return []; - const matches = normalized.match(/[^。!?!?;;\n]+[。!?!?;;…]?/gu) || []; - return (matches.length ? matches : [normalized]).map((segment) => segment.trim()).filter(Boolean); - } - const TTS_FOOTNOTE_MARKER_PATTERN = /(?:\s*(?:\[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\]|[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})]|【(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})】|〔(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})〕|[?((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10}))]?|\((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\)))+/gu; const TTS_FOOTNOTE_MARKER_ONLY_PATTERN = /^(?:\s*(?:\[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\]|[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})]|【(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})】|〔(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})〕|[?((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10}))]?|\((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\)))+\s*$/u; const TTS_SKIPPED_ELEMENT_SELECTOR = [ @@ -3440,6 +3181,11 @@ return !!(element && element.closest && element.closest(TTS_SKIPPED_ELEMENT_SELECTOR)); } + function ttsNodeFilter(node) { + if (node.nodeType !== Node.ELEMENT_NODE) return NodeFilter.FILTER_ACCEPT; + return shouldSkipTTSNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_SKIP; + } + function stripCfiAssertions(value) { return String(value || '').replace(/\[[^\]]*\]/g, ''); } @@ -3469,18 +3215,6 @@ return `${getTTSCfiStartIdentity(cfi)}::${normalizeTTSText(text)}`; } - function summarizeTTSSegmentsForLog(segments, limit) { - return (segments || []).slice(0, limit || 8).map(function(segment, index) { - var text = normalizeTTSText(segment && segment.text); - return { - index: index, - cfi: segment && segment.cfi || null, - length: text.length, - text: text, - }; - }); - } - function collectTTSSegmentsFromEngine(count, alignCfi, alignRange) { const tts = view && view.tts; if (!tts) return []; @@ -3516,17 +3250,18 @@ } const total = Math.max(1, Number.isFinite(count) ? count : 1); - const current = - typeof tts.currentDetail === 'function' - ? tts.currentDetail() - : null; - const following = - typeof tts.collectDetails === 'function' + const currentBlock = typeof tts.currentBlockDetails === 'function' + ? tts.currentBlockDetails(total) + : null; + const current = currentBlock ? null : + (typeof tts.currentDetail === 'function' ? tts.currentDetail() : null); + const following = currentBlock || + (typeof tts.collectDetails === 'function' ? tts.collectDetails(Math.max(0, total - (current ? 1 : 0)), { includeCurrent: false, offset: 1, }) - : []; + : []); const result = []; const seen = new Set(); @@ -3545,484 +3280,40 @@ try { const tts = view && view.tts; if (!tts || !cfi) return { before: [], after: [] }; - - const resolved = resolveRangeForCfi(cfi); - try { - if (resolved?.range && typeof tts.from === 'function') { - const caretRange = resolved.range.cloneRange ? resolved.range.cloneRange() : resolved.range; - if (caretRange.collapse) caretRange.collapse(true); - tts.from(caretRange); - } else if (typeof tts.alignCfi === 'function') { - tts.alignCfi(cfi); - } else if (typeof tts.highlightCfi === 'function') { - tts.highlightCfi(cfi); - } - } catch (e) { - console.log('[ttsSegmentContext] failed to align cfi:', e); - return { before: [], after: [] }; - } - - const current = - typeof tts.currentDetail === 'function' - ? tts.currentDetail() - : null; - const currentText = - current && current.text - ? normalizeTTSText(current.text) - : ''; - const currentIdentity = currentText ? getTTSSegmentIdentity(current && current.cfi, currentText) : null; - - const normalize = function(details) { - const result = []; - const seen = new Set(); - for (const detail of details || []) { - if (!detail || !detail.text || !detail.cfi) continue; - const text = normalizeTTSText(detail.text); - const identity = getTTSSegmentIdentity(detail.cfi, text); - if (!text || (currentIdentity && identity === currentIdentity) || seen.has(identity)) continue; - seen.add(identity); - result.push({ text, cfi: detail.cfi }); - } - return result; - }; - - const before = - typeof tts.collectDetails === 'function' - ? tts.collectDetails(Math.max(0, beforeCount || 0), { - includeCurrent: false, - offset: -Math.max(0, beforeCount || 0), - }) - : []; - const after = - typeof tts.collectDetails === 'function' - ? tts.collectDetails(Math.max(0, afterCount || 0), { - includeCurrent: false, - offset: 1, - }) - : []; - - return { - before: normalize(before), - after: normalize(after), - }; + if (typeof tts.contextDetailsByCfi !== 'function') return { before: [], after: [] }; + return tts.contextDetailsByCfi( + cfi, + Math.max(0, beforeCount || 0), + Math.max(0, afterCount || 0), + ); } catch (e) { console.log('[ttsSegmentContext] error:', e); return { before: [], after: [] }; } } - function extractTTSSegmentsFromDocument(doc, sectionIndex, maxCount, anchorRange) { - try { - if (!doc || !view || typeof view.getCFI !== 'function') return []; - const blocks = getReadableBlocksForTTS(doc); - const lang = - doc.documentElement.lang || - doc.documentElement.getAttribute('xml:lang') || - (doc.body && doc.body.lang) || - navigator.language || - 'en'; - const segmenter = - typeof Intl !== 'undefined' && Intl.Segmenter - ? new Intl.Segmenter(lang, { granularity: 'sentence' }) - : null; - const limit = Math.max(1, maxCount || 24); - const segments = []; - const seen = new Set(); - const docNode = doc.defaultView && doc.defaultView.Node ? doc.defaultView.Node : Node; - const anchorNode = - anchorRange && anchorRange.startContainer - ? (anchorRange.startContainer.nodeType === docNode.TEXT_NODE - ? anchorRange.startContainer.parentElement - : anchorRange.startContainer) - : null; - let started = !anchorNode; - - for (const block of blocks) { - if (!started && anchorNode) { - if (block === anchorNode || block.contains(anchorNode)) { - started = true; - } else if ( - anchorNode.compareDocumentPosition && - (anchorNode.compareDocumentPosition(block) & docNode.DOCUMENT_POSITION_FOLLOWING) - ) { - started = true; - } - } - if (!started) continue; - - const textNodes = getTTSTextNodes(block).map((node) => { - const text = node.nodeValue || ''; - return { node, text }; - }).filter((item) => item.text.trim().length > 0); - if (!textNodes.length) continue; - - let absoluteText = ''; - const positionedNodes = textNodes.map((item) => { - const start = absoluteText.length; - absoluteText += item.text; - return { - node: item.node, - start, - end: absoluteText.length, - }; - }); - - const rawSegments = segmenter - ? Array.from(segmenter.segment(absoluteText)).map((item) => ({ - start: item.index, - end: item.index + item.segment.length, - })) - : fallbackSentenceSegments(absoluteText).reduce((acc, sentence) => { - const start = absoluteText.indexOf(sentence, acc.lastIndex || 0); - if (start >= 0) { - acc.items.push({ start, end: start + sentence.length }); - acc.lastIndex = start + sentence.length; - } - return acc; - }, { items: [], lastIndex: 0 }).items; - const normalizedSegments = rawSegments.length - ? rawSegments - : [{ start: 0, end: absoluteText.length }]; - - for (const rawSegment of normalizedSegments) { - let start = rawSegment.start; - let end = rawSegment.end; - while (start < end && /\s/u.test(absoluteText[start])) start++; - while (end > start && /\s/u.test(absoluteText[end - 1])) end--; - if (end - start < 2) continue; - - const startPos = resolveTextPosition(positionedNodes, start, false); - const endPos = resolveTextPosition(positionedNodes, end, true); - if (!startPos || !endPos) continue; - - const range = doc.createRange(); - range.setStart(startPos.node, startPos.offset); - range.setEnd(endPos.node, endPos.offset); - - const text = normalizeTTSText(absoluteText.slice(start, end)); - if (!text) continue; - - try { - const cfi = view.getCFI(sectionIndex, range); - const identity = getTTSSegmentIdentity(cfi, text); - if (!cfi || seen.has(identity)) continue; - seen.add(identity); - segments.push({ text, cfi }); - if (segments.length >= limit) return segments; - } catch (e) { - console.log('[hrefTTSSegments] failed to convert range to CFI:', e); - } - } - } - - return segments; - } catch (e) { - console.log('[hrefTTSSegments] extract error:', e); - return []; - } - } - - async function getHrefTTSSegmentsFromBook(href, maxCount) { - try { - if (!href || !currentBook || !currentBook.sections || !view) return []; - let resolved = null; - try { - if (typeof view.resolveNavigation === 'function') { - resolved = await Promise.resolve(view.resolveNavigation(href)); - } else if (typeof currentBook.resolveHref === 'function') { - resolved = await Promise.resolve(currentBook.resolveHref(href)); - } - } catch (e) { - console.log('[hrefTTSSegments] failed to resolve href:', href, e); - } - - let sectionIndex = Number.isInteger(resolved && resolved.index) ? resolved.index : -1; - if (sectionIndex < 0) { - const hrefWithoutHash = String(href).split('#')[0]; - sectionIndex = Array.from(currentBook.sections).findIndex(function(section) { - const sectionHref = String(section && section.href || '').split('#')[0]; - return sectionHref === href || sectionHref === hrefWithoutHash || sectionHref.endsWith('/' + hrefWithoutHash); - }); - } - if (sectionIndex < 0 || sectionIndex >= currentBook.sections.length) return []; - const section = currentBook.sections[sectionIndex]; - if (!section || typeof section.createDocument !== 'function') return []; - - const doc = await section.createDocument(); - let anchorRange = null; - if (resolved && typeof resolved.anchor === 'function') { - try { - anchorRange = anchorToRange(doc, resolved.anchor(doc)); - } catch (e) { - anchorRange = null; - } - } + function extractVisibleTTSSegments(alignCfi) { + const tts = view && view.tts; + if (!tts) return []; - return extractTTSSegmentsFromDocument(doc, sectionIndex, maxCount, anchorRange); - } catch (e) { - console.log('[hrefTTSSegments] error:', e); - return []; + let range = null; + if (alignCfi) { + range = resolveRangeForCfi(alignCfi)?.range || null; } - } - - async function getSectionTTSSegmentsFromBook(sectionIndex, maxCount) { - try { - if (!currentBook || !currentBook.sections || !view) return []; - const index = Number(sectionIndex); - if (!Number.isInteger(index) || index < 0 || index >= currentBook.sections.length) { - return []; - } - - const section = currentBook.sections[index]; - if (!section || typeof section.createDocument !== 'function') return []; - - const doc = await section.createDocument(); - return extractTTSSegmentsFromDocument(doc, index, maxCount, null); - } catch (e) { - console.log('[sectionTTSSegments] error:', e); - return []; + if (!alignCfi && !range && lastRelocateRange) { + range = lastRelocateRange.cloneRange ? lastRelocateRange.cloneRange() : lastRelocateRange; } - } - function extractVisibleTTSSegments(alignCfi) { - try { - const renderer = view && view.renderer; - const contents = getRendererContents(); - if (!renderer || !contents.length) return []; - ttsVisibleRangeByDoc = new WeakMap(); - const scanContents = getVisibleContentsForTTS(contents, renderer); - const segments = []; - const stats = { - contentsCount: contents.length, - scannedContentsCount: scanContents.length, - visibleBlockCount: 0, - rawSentenceCount: 0, - skippedTooShort: 0, - skippedNoBoundary: 0, - skippedNotStartVisible: 0, - skippedNotVisible: 0, - skippedEmptyText: 0, - skippedCFI: 0, - skippedDuplicate: 0, - startVisibilityMode: 'range-intersection', - }; - const seenVisibleIdentities = new Set(); - // Track the first visible range for range-based TTS cursor alignment - // Must be declared here (outer scope) so it's accessible after both loops close. - let firstVisibleRange = null; - - for (var contentIndex = 0; contentIndex < scanContents.length; contentIndex++) { - const current = scanContents[contentIndex]; - if (!current || !current.doc) continue; - const doc = current.doc; - const win = doc.defaultView; - if (!win) continue; - const sectionIndex = typeof current.index === 'number' ? current.index : currentSectionIndex; - const visibleBlocks = getVisibleBlocksForTTS(doc, renderer); - stats.visibleBlockCount += visibleBlocks.length; - const lang = - doc.documentElement.lang || - doc.documentElement.getAttribute('xml:lang') || - doc.body.lang || - navigator.language || - 'en'; - const segmenter = - typeof Intl !== 'undefined' && Intl.Segmenter - ? new Intl.Segmenter(lang, { granularity: 'sentence' }) - : null; - - for (var blockIndex = 0; blockIndex < visibleBlocks.length; blockIndex++) { - const block = visibleBlocks[blockIndex]; - const textNodes = getTTSTextNodes(block).map((node) => { - const text = node.nodeValue || ''; - return { node, text }; - }).filter((item) => item.text.trim().length > 0); - if (!textNodes.length) continue; - - var absoluteText = ''; - const positionedNodes = textNodes.map((item) => { - const start = absoluteText.length; - absoluteText += item.text; - return { - node: item.node, - start, - end: absoluteText.length, - }; - }); - - const rawSegments = segmenter - ? Array.from(segmenter.segment(absoluteText)).map((item) => ({ - start: item.index, - end: item.index + item.segment.length, - })) - : fallbackSentenceSegments(absoluteText).reduce((acc, sentence) => { - const start = absoluteText.indexOf(sentence, acc.lastIndex || 0); - if (start >= 0) { - acc.items.push({ start, end: start + sentence.length }); - acc.lastIndex = start + sentence.length; - } - return acc; - }, { items: [], lastIndex: 0 }).items; - - const normalizedSegments = rawSegments.length - ? rawSegments - : [{ start: 0, end: absoluteText.length }]; - stats.rawSentenceCount += normalizedSegments.length; - - for (var si = 0; si < normalizedSegments.length; si++) { - let start = normalizedSegments[si].start; - let end = normalizedSegments[si].end; - - while (start < end && /\s/u.test(absoluteText[start])) start++; - while (end > start && /\s/u.test(absoluteText[end - 1])) end--; - if (end - start < 2) { - stats.skippedTooShort++; - continue; - } - - const startPos = resolveTextPosition(positionedNodes, start, false); - const endPos = resolveTextPosition(positionedNodes, end, true); - if (!startPos || !endPos) { - stats.skippedNoBoundary++; - continue; - } - - const range = doc.createRange(); - range.setStart(startPos.node, startPos.offset); - range.setEnd(endPos.node, endPos.offset); - if (!isRangeStartVisibleInReader(range, renderer, win)) { - stats.skippedNotVisible++; - stats.skippedNotStartVisible++; - continue; - } - - const text = normalizeTTSText(absoluteText.slice(start, end)); - if (!text) { - stats.skippedEmptyText++; - continue; - } - - // Capture the first visible range for reliable TTS cursor alignment - if (!firstVisibleRange) { - firstVisibleRange = range; - } - - try { - const cfi = view && view.getCFI ? view.getCFI(sectionIndex, range) : ''; - if (cfi) { - const identity = getTTSSegmentIdentity(cfi, text); - if (seenVisibleIdentities.has(identity)) { - stats.skippedDuplicate++; - continue; - } - seenVisibleIdentities.add(identity); - segments.push({ text, cfi }); - } else { - stats.skippedCFI++; - } - } catch (e) { - stats.skippedCFI++; - console.log('[visibleTTSSegments] failed to convert range to CFI:', e); - } - } - } - } - - // Use range-based alignment (preferred: uses boundary comparison, not CFI string equality) - // Fall back to CFI string alignment for older tts.js builds without tts.from() - const firstVisibleCfi = segments[0]?.cfi || null; - const resolvedAlignRange = resolveRangeForCfi(alignCfi); - const alignedSegments = segments.length - ? collectTTSSegmentsFromEngine( - alignCfi ? 500 : segments.length, - alignCfi || firstVisibleCfi, - resolvedAlignRange?.range || firstVisibleRange - ) - : []; - let returnSource = segments.length ? 'direct-visible' : 'no-visible-segments'; - let filteredAlignedCount = 0; - let filteredAlignedPreview = []; - let returnedSegments = segments; - if (alignedSegments.length && segments.length) { - const visibleIdentities = new Set( - segments.map((segment) => getTTSSegmentIdentity(segment.cfi, segment.text)) - ); - const filtered = alignedSegments.filter((segment) => - visibleIdentities.has(getTTSSegmentIdentity(segment.cfi, segment.text)) - ); - filteredAlignedCount = filtered.length; - filteredAlignedPreview = summarizeTTSSegmentsForLog(filtered, 8); - if (filtered.length === segments.length) { - returnSource = 'aligned-filtered'; - returnedSegments = filtered; - } else if (filtered.length > 0) { - returnSource = 'direct-partial-filtered-fallback'; - returnedSegments = segments; - } else if (alignCfi) { - const alignedStart = alignedSegments[0] || null; - const alignedStartIdentity = alignedStart - ? getTTSSegmentIdentity(alignedStart.cfi, alignedStart.text) - : null; - const visibleStartIndex = alignedStartIdentity - ? segments.findIndex( - (segment) => - getTTSSegmentIdentity(segment.cfi, segment.text) === alignedStartIdentity - ) - : -1; - if (visibleStartIndex >= 0) { - returnSource = 'direct-aligned-slice'; - returnedSegments = segments.slice(visibleStartIndex); - } else { - returnSource = 'direct-fallback'; - returnedSegments = segments; - } - } else { - returnSource = 'direct-visible'; - returnedSegments = segments; - } - } - - window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, - firstVisibleCfi: firstVisibleCfi, - firstVisibleText: segments[0] && segments[0].text || null, - directCount: segments.length, - alignedCount: alignedSegments.length, - filteredAlignedCount: filteredAlignedCount, - returnedCount: returnedSegments.length, - returnSource: returnSource, - stats: stats, - directPreview: summarizeTTSSegmentsForLog(segments, 8), - alignedPreview: summarizeTTSSegmentsForLog(alignedSegments, 8), - filteredAlignedPreview: filteredAlignedPreview, - returnedPreview: summarizeTTSSegmentsForLog(returnedSegments, 8), - }; - console.log('[visibleTTSSegments] diagnostics', JSON.stringify(window.__lastVisibleTTSDiagnostics)); - return returnedSegments; - } catch (e) { - console.log('[visibleTTSSegments] extraction error:', e); - const fallbackSegments = alignCfi - ? collectTTSSegmentsFromEngine(500, alignCfi, resolveRangeForCfi(alignCfi)?.range || null) - : []; - window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, - extractionError: String(e), - returnSource: alignCfi ? 'engine-align-fallback' : 'no-visible-segments', - directCount: 0, - alignedCount: fallbackSegments.length, - filteredAlignedCount: 0, - returnedCount: fallbackSegments.length, - stats: { - startVisibilityMode: 'range-intersection', - }, - directPreview: [], - alignedPreview: summarizeTTSSegmentsForLog(fallbackSegments, 8), - filteredAlignedPreview: [], - returnedPreview: summarizeTTSSegmentsForLog(fallbackSegments, 8), - }; - console.log('[visibleTTSSegments] diagnostics', JSON.stringify(window.__lastVisibleTTSDiagnostics)); - return fallbackSegments; - } + const segments = collectTTSSegmentsFromEngine(128, alignCfi || null, range); + window.__lastVisibleTTSDiagnostics = { + alignCfi: alignCfi || null, + returnedCount: segments.length, + scannedCount: segments.length, + returnSource: alignCfi + ? (range ? 'foliate-cfi-range' : 'foliate-cfi') + : (range ? 'foliate-relocate-range' : 'foliate-current'), + }; + return segments; } // ─── Expose global functions for injectJavaScript ─── @@ -4058,7 +3349,7 @@ window.setPaginatedLayout = function (m) { applySettings({ paginatedLayout: m }); }; window.addAnnotation = function (a) { if (view) view.addAnnotation(a); }; window.removeAnnotation = function (a) { if (view) view.deleteAnnotation(a); }; - window.setTTSHighlight = function (cfi, color) { return setTTSHighlight(cfi, color); }; + window.setTTSHighlight = function (cfi, color) { return applyTTSHighlight(cfi, color); }; window.clearTTSHighlight = function () { return clearTTSHighlight(); }; // Flash highlight for AI citation - temporarily hide existing annotation, show flash, then restore @@ -4165,14 +3456,6 @@ const context = getTTSSegmentContextFromEngine(cfi, before, after); postToRN('ttsSegmentContext', { requestId: requestId || null, ...context }); }; - window.doGetHrefTTSSegments = async function (href, count, requestId) { - const segments = await getHrefTTSSegmentsFromBook(href, count); - postToRN('hrefTTSSegments', { requestId: requestId || null, segments }); - }; - window.doGetSectionTTSSegments = async function (sectionIndex, count, requestId) { - const segments = await getSectionTTSSegmentsFromBook(sectionIndex, count); - postToRN('sectionTTSSegments', { requestId: requestId || null, segments }); - }; window.doGetChapterParagraphs = function () { handleGetChapterParagraphs(); }; @@ -4928,8 +4211,8 @@ diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 63f3098ac..e2e3142b8 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -197,6 +197,7 @@ let lastRendererStyles = ''; let currentViewMode = 'paginated'; let currentSectionUsesVerticalWriting = false; + let lastRelocateRange = null; let currentPaginatedLayout = 'double'; let currentFontSize = 16; let currentLineHeight = 1.6; @@ -272,7 +273,7 @@ } if (ttsHighlightState.cfi) { - setTTSHighlight(ttsHighlightState.cfi, ttsHighlightState.color); + applyTTSHighlight(ttsHighlightState.cfi, ttsHighlightState.color); } }, delay == null ? 100 : delay); } @@ -644,7 +645,7 @@ if (!view || !current || !current.doc) return null; try { - await view.initTTS('sentence', (range) => { + await view.initTTS('sentence', ttsNodeFilter, (range) => { const sourceDoc = range && range.startContainer ? (range.startContainer.nodeType === Node.DOCUMENT_NODE ? range.startContainer @@ -686,6 +687,16 @@ } return cfi; + }, (range) => { + const sourceDoc = range && range.startContainer + ? (range.startContainer.nodeType === Node.DOCUMENT_NODE + ? range.startContainer + : range.startContainer.ownerDocument) + : null; + const source = getRendererContentForDoc(sourceDoc) || getCurrentTTSOverlayerContext(); + return source && source.index != null && view.getCFI + ? view.getCFI(source.index, range.cloneRange()) + : ''; }); return view.tts || null; } catch (e) { @@ -694,48 +705,6 @@ } } - function drawTTSHighlight(rects, options) { - const { color = 'rgba(96, 165, 250, 0.35)', padding = 1, radius = 4, radiusPadding = 2 } = options || {}; - const svgNS = 'http://www.w3.org/2000/svg'; - const g = document.createElementNS(svgNS, 'g'); - g.setAttribute('fill', color); - g.style.opacity = 'var(--overlayer-highlight-opacity, 1)'; - g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)'; - - for (let index = 0; index < rects.length; index++) { - const { left, top, width, height } = rects[index]; - const isFirst = index === 0; - const isLast = index === rects.length - 1; - const x = left - padding - (isFirst ? radiusPadding : 0); - const y = top - padding; - const w = width + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0); - const h = height + padding * 2; - const rtl = isLast ? radius : 0; - const rtr = isFirst ? radius : 0; - const rbr = isFirst ? radius : 0; - const rbl = isLast ? radius : 0; - const path = document.createElementNS(svgNS, 'path'); - path.setAttribute( - 'd', - [ - `M ${x + rtl} ${y}`, - `H ${x + w - rtr}`, - rtr ? `Q ${x + w} ${y} ${x + w} ${y + rtr}` : '', - `V ${y + h - rbr}`, - rbr ? `Q ${x + w} ${y + h} ${x + w - rbr} ${y + h}` : '', - `H ${x + rbl}`, - rbl ? `Q ${x} ${y + h} ${x} ${y + h - rbl}` : '', - `V ${y + rtl}`, - rtl ? `Q ${x} ${y} ${x + rtl} ${y}` : '', - 'Z', - ].filter(Boolean).join(' ') - ); - g.append(path); - } - - return g; - } - function resolveTTSHighlightColor(color) { if (!color) return 'rgba(96, 165, 250, 0.35)'; // If it's already an rgba/rgb value, use as-is @@ -812,6 +781,48 @@ }, 900); } + function drawTTSHighlight(rects, options) { + const { color = 'rgba(96, 165, 250, 0.35)', padding = 1, radius = 4, radiusPadding = 2 } = options || {}; + const svgNS = 'http://www.w3.org/2000/svg'; + const g = document.createElementNS(svgNS, 'g'); + g.setAttribute('fill', color); + g.style.opacity = 'var(--overlayer-highlight-opacity, 1)'; + g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)'; + + for (let index = 0; index < rects.length; index++) { + const { left, top, width, height } = rects[index]; + const isFirst = index === 0; + const isLast = index === rects.length - 1; + const x = left - padding - (isFirst ? radiusPadding : 0); + const y = top - padding; + const w = width + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0); + const h = height + padding * 2; + const rtl = isLast ? radius : 0; + const rtr = isFirst ? radius : 0; + const rbr = isFirst ? radius : 0; + const rbl = isLast ? radius : 0; + const path = document.createElementNS(svgNS, 'path'); + path.setAttribute( + 'd', + [ + `M ${x + rtl} ${y}`, + `H ${x + w - rtr}`, + rtr ? `Q ${x + w} ${y} ${x + w} ${y + rtr}` : '', + `V ${y + h - rbr}`, + rbr ? `Q ${x + w} ${y + h} ${x + w - rbr} ${y + h}` : '', + `H ${x + rbl}`, + rbl ? `Q ${x} ${y + h} ${x} ${y + h - rbl}` : '', + `V ${y + rtl}`, + rtl ? `Q ${x} ${y} ${x + rtl} ${y}` : '', + 'Z', + ].filter(Boolean).join(' ') + ); + g.append(path); + } + + return g; + } + async function followTTSLocation(cfi) { if (!view || !view.renderer || !cfi) return; const resolved = resolveRangeForCfi(cfi); @@ -835,20 +846,16 @@ } const renderer = view.renderer; - const win = doc.defaultView; - if (!win) return; - if (!isRangeVisibleInReader(range, renderer, win)) { + if (renderer.scrolled) { try { await renderer.scrollToAnchor(range, true); } catch (e) { - try { - await view.goTo(cfi); - } catch (_) {} + try { await view.goTo(cfi); } catch (_) {} } } } - async function setTTSHighlight(cfi, color) { + async function applyTTSHighlight(cfi, color) { postToRN('debug', { message: '[ttsHighlight] setTTSHighlight called: cfi=' + (cfi || 'null') + ' color=' + (color || 'null') }); const previousValue = ttsHighlightState.annotationValue; const annotationValue = cfi ? `${TTS_ANNOTATION_PREFIX}${cfi}` : null; @@ -1354,7 +1361,7 @@ if (view && msg.settings) applySettings(msg.settings); break; case 'setTTSHighlight': - await setTTSHighlight(msg.cfi, msg.color); + await applyTTSHighlight(msg.cfi, msg.color); break; case 'clearTTSHighlight': await clearTTSHighlight(); @@ -1550,6 +1557,9 @@ markLoaded(); hideFootnoteTip(); const detail = e.detail; + lastRelocateRange = detail && detail.range && detail.range.cloneRange + ? detail.range.cloneRange() + : null; const previousSectionIndex = currentSectionIndex; if (tapNavigationInFlight) { setTimeout(() => { @@ -3073,18 +3083,17 @@ } } - function getCurrentRendererContent() { - const contents = getRendererContents(); - return contents[0] || null; - } - function anchorToRange(doc, anchor) { if (!doc || !anchor) return null; - if (anchor instanceof Range) return anchor; - if (!(anchor instanceof Node)) return null; + if ( + typeof anchor.cloneRange === 'function' && + anchor.startContainer && + anchor.endContainer + ) return anchor; + if (typeof anchor.nodeType !== 'number') return null; try { const range = doc.createRange(); - if (anchor.nodeType === Node.TEXT_NODE) { + if (anchor.nodeType === 3) { range.selectNodeContents(anchor); } else { range.selectNode(anchor); @@ -3131,274 +3140,6 @@ return null; } - function getPaginatedVisibleRangeCandidates(renderer) { - const start = Number(renderer && renderer.start || 0); - const end = Number(renderer && renderer.end || 0); - const size = Number(renderer && renderer.size || 0); - const candidates = []; - - if (Number.isFinite(start) && Number.isFinite(size) && size > 0) { - candidates.push({ left: start - size, right: start, source: 'legacy-offset' }); - candidates.push({ left: start, right: start + size, source: 'size-fallback' }); - } - - if (Number.isFinite(start) && Number.isFinite(end) && end > start) { - candidates.push({ left: start, right: end, source: 'renderer' }); - } - - return candidates.filter((candidate, index, list) => { - return candidate.right > candidate.left && - list.findIndex((item) => item.left === candidate.left && item.right === candidate.right) === index; - }); - } - - function rectIntersectsPaginatedRange(rect, range) { - return rect.right > range.left && rect.left < range.right; - } - - function scorePaginatedVisibleRange(doc, range) { - if (!doc || !doc.body) return 0; - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { - acceptNode: function (node) { - if (isInsideRubyAnnotation(node)) return NodeFilter.FILTER_REJECT; - if (isTTSFootnoteMarker(node.nodeValue || '')) return NodeFilter.FILTER_REJECT; - if (shouldSkipTTSNode(node.parentElement)) return NodeFilter.FILTER_REJECT; - return node.nodeValue && normalizeTTSText(node.nodeValue) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_SKIP; - }, - }); - let score = 0; - let visited = 0; - let textNode = walker.nextNode(); - while (textNode && visited < 500) { - visited += 1; - const text = normalizeTTSText(textNode.nodeValue || ''); - if (text) { - try { - const textRange = doc.createRange(); - textRange.selectNodeContents(textNode); - const rects = Array.from(textRange.getClientRects ? textRange.getClientRects() : []); - if (rects.some((rect) => rect.width > 0 && rect.height > 0 && rectIntersectsPaginatedRange(rect, range))) { - score += Math.min(text.length, 120); - } - } catch {} - } - textNode = walker.nextNode(); - } - return score; - } - - function pickPaginatedVisibleRange(doc, renderer) { - const candidates = getPaginatedVisibleRangeCandidates(renderer); - if (candidates.length <= 1) return candidates[0] || null; - - const legacyRange = candidates.find((range) => range.source === 'legacy-offset') || null; - if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) return legacyRange; - - const rendererRange = candidates.find((range) => range.source === 'renderer') || null; - if (rendererRange && scorePaginatedVisibleRange(doc, rendererRange) > 0) return rendererRange; - - const fallbackRange = candidates.find((range) => range.source === 'size-fallback') || null; - if (fallbackRange && scorePaginatedVisibleRange(doc, fallbackRange) > 0) return fallbackRange; - - return legacyRange || rendererRange || fallbackRange || candidates[0] || null; - } - - let ttsVisibleRangeByDoc = new WeakMap(); - - function getVisibleRangeForTTSDoc(doc, renderer) { - if (!doc) return null; - if (ttsVisibleRangeByDoc.has(doc)) return ttsVisibleRangeByDoc.get(doc) || null; - const range = pickPaginatedVisibleRange(doc, renderer); - ttsVisibleRangeByDoc.set(doc, range); - return range; - } - - function getRangeDocument(range) { - const container = range && range.commonAncestorContainer; - if (!container) return null; - return container.nodeType === Node.DOCUMENT_NODE ? container : container.ownerDocument; - } - - function getReaderViewportRect() { - const viewport = { - left: 0, - top: 0, - right: window.innerWidth || document.documentElement.clientWidth || 0, - bottom: window.innerHeight || document.documentElement.clientHeight || 0, - }; - try { - const viewRect = view && view.getBoundingClientRect ? view.getBoundingClientRect() : null; - if (!viewRect || viewRect.width <= 0 || viewRect.height <= 0) return viewport; - return { - left: Math.max(viewRect.left, viewport.left), - top: Math.max(viewRect.top, viewport.top), - right: Math.min(viewRect.right, viewport.right), - bottom: Math.min(viewRect.bottom, viewport.bottom), - }; - } catch (e) { - return viewport; - } - } - - function mapIframeRectToHost(rect, doc) { - try { - const iframe = doc && doc.defaultView && doc.defaultView.frameElement; - if (!iframe || !iframe.getBoundingClientRect) return rect; - const iframeRect = iframe.getBoundingClientRect(); - const scaleX = iframe.clientWidth > 0 ? iframeRect.width / iframe.clientWidth : 1; - const scaleY = iframe.clientHeight > 0 ? iframeRect.height / iframe.clientHeight : 1; - return { - left: iframeRect.left + rect.left * scaleX, - top: iframeRect.top + rect.top * scaleY, - right: iframeRect.left + rect.right * scaleX, - bottom: iframeRect.top + rect.bottom * scaleY, - width: rect.width * scaleX, - height: rect.height * scaleY, - }; - } catch (e) { - return rect; - } - } - - function isHostRectVisible(rect) { - if (!rect || rect.width <= 0 || rect.height <= 0) return false; - const viewport = getReaderViewportRect(); - return rect.right > viewport.left && - rect.left < viewport.right && - rect.bottom > viewport.top && - rect.top < viewport.bottom; - } - - function getContentHostRect(content) { - const doc = content && content.doc; - const iframe = doc && doc.defaultView && doc.defaultView.frameElement; - if (iframe && iframe.getBoundingClientRect) { - const rect = iframe.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }; - } - if (doc && doc.body) { - return mapIframeRectToHost(doc.body.getBoundingClientRect(), doc); - } - return null; - } - - function getVisibleContentsForTTS(contents, renderer) { - if (!renderer || !renderer.scrolled) return contents; - return (contents || []) - .filter(function(content) { return !!(content && content.doc); }) - .map(function(content) { - return { content: content, rect: getContentHostRect(content) }; - }) - .filter(function(item) { - return item.rect && isHostRectVisible(item.rect); - }) - .sort(function(a, b) { - const topDelta = (a.rect ? a.rect.top : 0) - (b.rect ? b.rect.top : 0); - return Math.abs(topDelta) > 1 - ? topDelta - : (a.rect ? a.rect.left : 0) - (b.rect ? b.rect.left : 0); - }) - .map(function(item) { return item.content; }); - } - - function isRectVisibleInReader(rect, renderer, win, doc) { - if (!rect || rect.width <= 0 || rect.height <= 0) return false; - const isPaginated = !renderer.scrolled; - if (isPaginated && renderer.size > 0) { - const visibleRange = getVisibleRangeForTTSDoc(doc, renderer); - return visibleRange ? rectIntersectsPaginatedRange(rect, visibleRange) : false; - } - return isHostRectVisible(mapIframeRectToHost(rect, doc)); - } - - function isRangeVisibleInReader(range, renderer, win) { - if (!range || !renderer || !win) return false; - try { - const rects = Array.from(range.getClientRects ? range.getClientRects() : []); - const doc = getRangeDocument(range); - if (!rects.length) { - return isRectVisibleInReader(range.getBoundingClientRect(), renderer, win, doc); - } - return rects.some((rect) => isRectVisibleInReader(rect, renderer, win, doc)); - } catch (e) { - console.log('[visibleTTSSegments] failed to inspect range rects:', e); - return false; - } - } - - function isRangeStartVisibleInReader(range, renderer, win) { - if (!range || !renderer || !win) return false; - try { - const rects = Array.from(range.getClientRects ? range.getClientRects() : []); - const doc = getRangeDocument(range); - if (!rects.length) { - return isRectVisibleInReader(range.getBoundingClientRect(), renderer, win, doc); - } - return isRectVisibleInReader(rects[0], renderer, win, doc); - } catch (e) { - console.log('[visibleTTSSegments] failed to inspect range start rect:', e); - return false; - } - } - - function getVisibleBlocksForTTS(doc, renderer) { - const blockSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; - const blocks = doc.querySelectorAll(blockSelector); - const win = doc.defaultView; - if (!win) return []; - return Array.from(blocks).filter((block) => { - if (!block || !block.textContent || !block.textContent.trim()) return false; - if (shouldSkipTTSNode(block)) return false; - try { - const range = doc.createRange(); - range.selectNodeContents(block); - return isRangeVisibleInReader(range, renderer, win); - } catch (e) { - return isRectVisibleInReader(block.getBoundingClientRect(), renderer, win, doc); - } - }); - } - - function getReadableBlocksForTTS(doc) { - const blockSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; - return Array.from(doc.querySelectorAll(blockSelector)).filter((block) => { - if (!block || !block.textContent || !block.textContent.trim()) return false; - if (shouldSkipTTSNode(block)) return false; - return true; - }); - } - - function resolveTextPosition(textNodes, absoluteOffset, isEnd) { - if (!textNodes.length) return null; - for (var i = 0; i < textNodes.length; i++) { - var item = textNodes[i]; - if (absoluteOffset < item.end || (isEnd && absoluteOffset <= item.end)) { - return { - node: item.node, - offset: Math.max(0, Math.min(item.node.nodeValue.length, absoluteOffset - item.start)), - }; - } - } - const last = textNodes[textNodes.length - 1]; - return { node: last.node, offset: last.node.nodeValue.length }; - } - - function fallbackSentenceSegments(text) { - const normalized = normalizeTTSText(text); - if (!normalized) return []; - const matches = normalized.match(/[^。!?!?;;\n]+[。!?!?;;…]?/gu) || []; - return (matches.length ? matches : [normalized]).map((segment) => segment.trim()).filter(Boolean); - } - const TTS_FOOTNOTE_MARKER_PATTERN = /(?:\s*(?:\[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\]|[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})]|【(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})】|〔(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})〕|[?((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10}))]?|\((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\)))+/gu; const TTS_FOOTNOTE_MARKER_ONLY_PATTERN = /^(?:\s*(?:\[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\]|[(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})]|【(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})】|〔(?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})〕|[?((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10}))]?|\((?:\d{1,4}|[一二三四五六七八九十百千万零〇两]{1,8}|[ivxlcdmIVXLCDM]{1,10})\)))+\s*$/u; const TTS_SKIPPED_ELEMENT_SELECTOR = [ @@ -3440,6 +3181,11 @@ return !!(element && element.closest && element.closest(TTS_SKIPPED_ELEMENT_SELECTOR)); } + function ttsNodeFilter(node) { + if (node.nodeType !== Node.ELEMENT_NODE) return NodeFilter.FILTER_ACCEPT; + return shouldSkipTTSNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_SKIP; + } + function stripCfiAssertions(value) { return String(value || '').replace(/\[[^\]]*\]/g, ''); } @@ -3469,18 +3215,6 @@ return `${getTTSCfiStartIdentity(cfi)}::${normalizeTTSText(text)}`; } - function summarizeTTSSegmentsForLog(segments, limit) { - return (segments || []).slice(0, limit || 8).map(function(segment, index) { - var text = normalizeTTSText(segment && segment.text); - return { - index: index, - cfi: segment && segment.cfi || null, - length: text.length, - text: text, - }; - }); - } - function collectTTSSegmentsFromEngine(count, alignCfi, alignRange) { const tts = view && view.tts; if (!tts) return []; @@ -3516,17 +3250,18 @@ } const total = Math.max(1, Number.isFinite(count) ? count : 1); - const current = - typeof tts.currentDetail === 'function' - ? tts.currentDetail() - : null; - const following = - typeof tts.collectDetails === 'function' + const currentBlock = typeof tts.currentBlockDetails === 'function' + ? tts.currentBlockDetails(total) + : null; + const current = currentBlock ? null : + (typeof tts.currentDetail === 'function' ? tts.currentDetail() : null); + const following = currentBlock || + (typeof tts.collectDetails === 'function' ? tts.collectDetails(Math.max(0, total - (current ? 1 : 0)), { includeCurrent: false, offset: 1, }) - : []; + : []); const result = []; const seen = new Set(); @@ -3545,484 +3280,40 @@ try { const tts = view && view.tts; if (!tts || !cfi) return { before: [], after: [] }; - - const resolved = resolveRangeForCfi(cfi); - try { - if (resolved?.range && typeof tts.from === 'function') { - const caretRange = resolved.range.cloneRange ? resolved.range.cloneRange() : resolved.range; - if (caretRange.collapse) caretRange.collapse(true); - tts.from(caretRange); - } else if (typeof tts.alignCfi === 'function') { - tts.alignCfi(cfi); - } else if (typeof tts.highlightCfi === 'function') { - tts.highlightCfi(cfi); - } - } catch (e) { - console.log('[ttsSegmentContext] failed to align cfi:', e); - return { before: [], after: [] }; - } - - const current = - typeof tts.currentDetail === 'function' - ? tts.currentDetail() - : null; - const currentText = - current && current.text - ? normalizeTTSText(current.text) - : ''; - const currentIdentity = currentText ? getTTSSegmentIdentity(current && current.cfi, currentText) : null; - - const normalize = function(details) { - const result = []; - const seen = new Set(); - for (const detail of details || []) { - if (!detail || !detail.text || !detail.cfi) continue; - const text = normalizeTTSText(detail.text); - const identity = getTTSSegmentIdentity(detail.cfi, text); - if (!text || (currentIdentity && identity === currentIdentity) || seen.has(identity)) continue; - seen.add(identity); - result.push({ text, cfi: detail.cfi }); - } - return result; - }; - - const before = - typeof tts.collectDetails === 'function' - ? tts.collectDetails(Math.max(0, beforeCount || 0), { - includeCurrent: false, - offset: -Math.max(0, beforeCount || 0), - }) - : []; - const after = - typeof tts.collectDetails === 'function' - ? tts.collectDetails(Math.max(0, afterCount || 0), { - includeCurrent: false, - offset: 1, - }) - : []; - - return { - before: normalize(before), - after: normalize(after), - }; + if (typeof tts.contextDetailsByCfi !== 'function') return { before: [], after: [] }; + return tts.contextDetailsByCfi( + cfi, + Math.max(0, beforeCount || 0), + Math.max(0, afterCount || 0), + ); } catch (e) { console.log('[ttsSegmentContext] error:', e); return { before: [], after: [] }; } } - function extractTTSSegmentsFromDocument(doc, sectionIndex, maxCount, anchorRange) { - try { - if (!doc || !view || typeof view.getCFI !== 'function') return []; - const blocks = getReadableBlocksForTTS(doc); - const lang = - doc.documentElement.lang || - doc.documentElement.getAttribute('xml:lang') || - (doc.body && doc.body.lang) || - navigator.language || - 'en'; - const segmenter = - typeof Intl !== 'undefined' && Intl.Segmenter - ? new Intl.Segmenter(lang, { granularity: 'sentence' }) - : null; - const limit = Math.max(1, maxCount || 24); - const segments = []; - const seen = new Set(); - const docNode = doc.defaultView && doc.defaultView.Node ? doc.defaultView.Node : Node; - const anchorNode = - anchorRange && anchorRange.startContainer - ? (anchorRange.startContainer.nodeType === docNode.TEXT_NODE - ? anchorRange.startContainer.parentElement - : anchorRange.startContainer) - : null; - let started = !anchorNode; - - for (const block of blocks) { - if (!started && anchorNode) { - if (block === anchorNode || block.contains(anchorNode)) { - started = true; - } else if ( - anchorNode.compareDocumentPosition && - (anchorNode.compareDocumentPosition(block) & docNode.DOCUMENT_POSITION_FOLLOWING) - ) { - started = true; - } - } - if (!started) continue; - - const textNodes = getTTSTextNodes(block).map((node) => { - const text = node.nodeValue || ''; - return { node, text }; - }).filter((item) => item.text.trim().length > 0); - if (!textNodes.length) continue; - - let absoluteText = ''; - const positionedNodes = textNodes.map((item) => { - const start = absoluteText.length; - absoluteText += item.text; - return { - node: item.node, - start, - end: absoluteText.length, - }; - }); - - const rawSegments = segmenter - ? Array.from(segmenter.segment(absoluteText)).map((item) => ({ - start: item.index, - end: item.index + item.segment.length, - })) - : fallbackSentenceSegments(absoluteText).reduce((acc, sentence) => { - const start = absoluteText.indexOf(sentence, acc.lastIndex || 0); - if (start >= 0) { - acc.items.push({ start, end: start + sentence.length }); - acc.lastIndex = start + sentence.length; - } - return acc; - }, { items: [], lastIndex: 0 }).items; - const normalizedSegments = rawSegments.length - ? rawSegments - : [{ start: 0, end: absoluteText.length }]; - - for (const rawSegment of normalizedSegments) { - let start = rawSegment.start; - let end = rawSegment.end; - while (start < end && /\s/u.test(absoluteText[start])) start++; - while (end > start && /\s/u.test(absoluteText[end - 1])) end--; - if (end - start < 2) continue; - - const startPos = resolveTextPosition(positionedNodes, start, false); - const endPos = resolveTextPosition(positionedNodes, end, true); - if (!startPos || !endPos) continue; - - const range = doc.createRange(); - range.setStart(startPos.node, startPos.offset); - range.setEnd(endPos.node, endPos.offset); - - const text = normalizeTTSText(absoluteText.slice(start, end)); - if (!text) continue; - - try { - const cfi = view.getCFI(sectionIndex, range); - const identity = getTTSSegmentIdentity(cfi, text); - if (!cfi || seen.has(identity)) continue; - seen.add(identity); - segments.push({ text, cfi }); - if (segments.length >= limit) return segments; - } catch (e) { - console.log('[hrefTTSSegments] failed to convert range to CFI:', e); - } - } - } - - return segments; - } catch (e) { - console.log('[hrefTTSSegments] extract error:', e); - return []; - } - } - - async function getHrefTTSSegmentsFromBook(href, maxCount) { - try { - if (!href || !currentBook || !currentBook.sections || !view) return []; - let resolved = null; - try { - if (typeof view.resolveNavigation === 'function') { - resolved = await Promise.resolve(view.resolveNavigation(href)); - } else if (typeof currentBook.resolveHref === 'function') { - resolved = await Promise.resolve(currentBook.resolveHref(href)); - } - } catch (e) { - console.log('[hrefTTSSegments] failed to resolve href:', href, e); - } - - let sectionIndex = Number.isInteger(resolved && resolved.index) ? resolved.index : -1; - if (sectionIndex < 0) { - const hrefWithoutHash = String(href).split('#')[0]; - sectionIndex = Array.from(currentBook.sections).findIndex(function(section) { - const sectionHref = String(section && section.href || '').split('#')[0]; - return sectionHref === href || sectionHref === hrefWithoutHash || sectionHref.endsWith('/' + hrefWithoutHash); - }); - } - if (sectionIndex < 0 || sectionIndex >= currentBook.sections.length) return []; - const section = currentBook.sections[sectionIndex]; - if (!section || typeof section.createDocument !== 'function') return []; - - const doc = await section.createDocument(); - let anchorRange = null; - if (resolved && typeof resolved.anchor === 'function') { - try { - anchorRange = anchorToRange(doc, resolved.anchor(doc)); - } catch (e) { - anchorRange = null; - } - } + function extractVisibleTTSSegments(alignCfi) { + const tts = view && view.tts; + if (!tts) return []; - return extractTTSSegmentsFromDocument(doc, sectionIndex, maxCount, anchorRange); - } catch (e) { - console.log('[hrefTTSSegments] error:', e); - return []; + let range = null; + if (alignCfi) { + range = resolveRangeForCfi(alignCfi)?.range || null; } - } - - async function getSectionTTSSegmentsFromBook(sectionIndex, maxCount) { - try { - if (!currentBook || !currentBook.sections || !view) return []; - const index = Number(sectionIndex); - if (!Number.isInteger(index) || index < 0 || index >= currentBook.sections.length) { - return []; - } - - const section = currentBook.sections[index]; - if (!section || typeof section.createDocument !== 'function') return []; - - const doc = await section.createDocument(); - return extractTTSSegmentsFromDocument(doc, index, maxCount, null); - } catch (e) { - console.log('[sectionTTSSegments] error:', e); - return []; + if (!alignCfi && !range && lastRelocateRange) { + range = lastRelocateRange.cloneRange ? lastRelocateRange.cloneRange() : lastRelocateRange; } - } - function extractVisibleTTSSegments(alignCfi) { - try { - const renderer = view && view.renderer; - const contents = getRendererContents(); - if (!renderer || !contents.length) return []; - ttsVisibleRangeByDoc = new WeakMap(); - const scanContents = getVisibleContentsForTTS(contents, renderer); - const segments = []; - const stats = { - contentsCount: contents.length, - scannedContentsCount: scanContents.length, - visibleBlockCount: 0, - rawSentenceCount: 0, - skippedTooShort: 0, - skippedNoBoundary: 0, - skippedNotStartVisible: 0, - skippedNotVisible: 0, - skippedEmptyText: 0, - skippedCFI: 0, - skippedDuplicate: 0, - startVisibilityMode: 'range-intersection', - }; - const seenVisibleIdentities = new Set(); - // Track the first visible range for range-based TTS cursor alignment - // Must be declared here (outer scope) so it's accessible after both loops close. - let firstVisibleRange = null; - - for (var contentIndex = 0; contentIndex < scanContents.length; contentIndex++) { - const current = scanContents[contentIndex]; - if (!current || !current.doc) continue; - const doc = current.doc; - const win = doc.defaultView; - if (!win) continue; - const sectionIndex = typeof current.index === 'number' ? current.index : currentSectionIndex; - const visibleBlocks = getVisibleBlocksForTTS(doc, renderer); - stats.visibleBlockCount += visibleBlocks.length; - const lang = - doc.documentElement.lang || - doc.documentElement.getAttribute('xml:lang') || - doc.body.lang || - navigator.language || - 'en'; - const segmenter = - typeof Intl !== 'undefined' && Intl.Segmenter - ? new Intl.Segmenter(lang, { granularity: 'sentence' }) - : null; - - for (var blockIndex = 0; blockIndex < visibleBlocks.length; blockIndex++) { - const block = visibleBlocks[blockIndex]; - const textNodes = getTTSTextNodes(block).map((node) => { - const text = node.nodeValue || ''; - return { node, text }; - }).filter((item) => item.text.trim().length > 0); - if (!textNodes.length) continue; - - var absoluteText = ''; - const positionedNodes = textNodes.map((item) => { - const start = absoluteText.length; - absoluteText += item.text; - return { - node: item.node, - start, - end: absoluteText.length, - }; - }); - - const rawSegments = segmenter - ? Array.from(segmenter.segment(absoluteText)).map((item) => ({ - start: item.index, - end: item.index + item.segment.length, - })) - : fallbackSentenceSegments(absoluteText).reduce((acc, sentence) => { - const start = absoluteText.indexOf(sentence, acc.lastIndex || 0); - if (start >= 0) { - acc.items.push({ start, end: start + sentence.length }); - acc.lastIndex = start + sentence.length; - } - return acc; - }, { items: [], lastIndex: 0 }).items; - - const normalizedSegments = rawSegments.length - ? rawSegments - : [{ start: 0, end: absoluteText.length }]; - stats.rawSentenceCount += normalizedSegments.length; - - for (var si = 0; si < normalizedSegments.length; si++) { - let start = normalizedSegments[si].start; - let end = normalizedSegments[si].end; - - while (start < end && /\s/u.test(absoluteText[start])) start++; - while (end > start && /\s/u.test(absoluteText[end - 1])) end--; - if (end - start < 2) { - stats.skippedTooShort++; - continue; - } - - const startPos = resolveTextPosition(positionedNodes, start, false); - const endPos = resolveTextPosition(positionedNodes, end, true); - if (!startPos || !endPos) { - stats.skippedNoBoundary++; - continue; - } - - const range = doc.createRange(); - range.setStart(startPos.node, startPos.offset); - range.setEnd(endPos.node, endPos.offset); - if (!isRangeStartVisibleInReader(range, renderer, win)) { - stats.skippedNotVisible++; - stats.skippedNotStartVisible++; - continue; - } - - const text = normalizeTTSText(absoluteText.slice(start, end)); - if (!text) { - stats.skippedEmptyText++; - continue; - } - - // Capture the first visible range for reliable TTS cursor alignment - if (!firstVisibleRange) { - firstVisibleRange = range; - } - - try { - const cfi = view && view.getCFI ? view.getCFI(sectionIndex, range) : ''; - if (cfi) { - const identity = getTTSSegmentIdentity(cfi, text); - if (seenVisibleIdentities.has(identity)) { - stats.skippedDuplicate++; - continue; - } - seenVisibleIdentities.add(identity); - segments.push({ text, cfi }); - } else { - stats.skippedCFI++; - } - } catch (e) { - stats.skippedCFI++; - console.log('[visibleTTSSegments] failed to convert range to CFI:', e); - } - } - } - } - - // Use range-based alignment (preferred: uses boundary comparison, not CFI string equality) - // Fall back to CFI string alignment for older tts.js builds without tts.from() - const firstVisibleCfi = segments[0]?.cfi || null; - const resolvedAlignRange = resolveRangeForCfi(alignCfi); - const alignedSegments = segments.length - ? collectTTSSegmentsFromEngine( - alignCfi ? 500 : segments.length, - alignCfi || firstVisibleCfi, - resolvedAlignRange?.range || firstVisibleRange - ) - : []; - let returnSource = segments.length ? 'direct-visible' : 'no-visible-segments'; - let filteredAlignedCount = 0; - let filteredAlignedPreview = []; - let returnedSegments = segments; - if (alignedSegments.length && segments.length) { - const visibleIdentities = new Set( - segments.map((segment) => getTTSSegmentIdentity(segment.cfi, segment.text)) - ); - const filtered = alignedSegments.filter((segment) => - visibleIdentities.has(getTTSSegmentIdentity(segment.cfi, segment.text)) - ); - filteredAlignedCount = filtered.length; - filteredAlignedPreview = summarizeTTSSegmentsForLog(filtered, 8); - if (filtered.length === segments.length) { - returnSource = 'aligned-filtered'; - returnedSegments = filtered; - } else if (filtered.length > 0) { - returnSource = 'direct-partial-filtered-fallback'; - returnedSegments = segments; - } else if (alignCfi) { - const alignedStart = alignedSegments[0] || null; - const alignedStartIdentity = alignedStart - ? getTTSSegmentIdentity(alignedStart.cfi, alignedStart.text) - : null; - const visibleStartIndex = alignedStartIdentity - ? segments.findIndex( - (segment) => - getTTSSegmentIdentity(segment.cfi, segment.text) === alignedStartIdentity - ) - : -1; - if (visibleStartIndex >= 0) { - returnSource = 'direct-aligned-slice'; - returnedSegments = segments.slice(visibleStartIndex); - } else { - returnSource = 'direct-fallback'; - returnedSegments = segments; - } - } else { - returnSource = 'direct-visible'; - returnedSegments = segments; - } - } - - window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, - firstVisibleCfi: firstVisibleCfi, - firstVisibleText: segments[0] && segments[0].text || null, - directCount: segments.length, - alignedCount: alignedSegments.length, - filteredAlignedCount: filteredAlignedCount, - returnedCount: returnedSegments.length, - returnSource: returnSource, - stats: stats, - directPreview: summarizeTTSSegmentsForLog(segments, 8), - alignedPreview: summarizeTTSSegmentsForLog(alignedSegments, 8), - filteredAlignedPreview: filteredAlignedPreview, - returnedPreview: summarizeTTSSegmentsForLog(returnedSegments, 8), - }; - console.log('[visibleTTSSegments] diagnostics', JSON.stringify(window.__lastVisibleTTSDiagnostics)); - return returnedSegments; - } catch (e) { - console.log('[visibleTTSSegments] extraction error:', e); - const fallbackSegments = alignCfi - ? collectTTSSegmentsFromEngine(500, alignCfi, resolveRangeForCfi(alignCfi)?.range || null) - : []; - window.__lastVisibleTTSDiagnostics = { - alignCfi: alignCfi || null, - extractionError: String(e), - returnSource: alignCfi ? 'engine-align-fallback' : 'no-visible-segments', - directCount: 0, - alignedCount: fallbackSegments.length, - filteredAlignedCount: 0, - returnedCount: fallbackSegments.length, - stats: { - startVisibilityMode: 'range-intersection', - }, - directPreview: [], - alignedPreview: summarizeTTSSegmentsForLog(fallbackSegments, 8), - filteredAlignedPreview: [], - returnedPreview: summarizeTTSSegmentsForLog(fallbackSegments, 8), - }; - console.log('[visibleTTSSegments] diagnostics', JSON.stringify(window.__lastVisibleTTSDiagnostics)); - return fallbackSegments; - } + const segments = collectTTSSegmentsFromEngine(128, alignCfi || null, range); + window.__lastVisibleTTSDiagnostics = { + alignCfi: alignCfi || null, + returnedCount: segments.length, + scannedCount: segments.length, + returnSource: alignCfi + ? (range ? 'foliate-cfi-range' : 'foliate-cfi') + : (range ? 'foliate-relocate-range' : 'foliate-current'), + }; + return segments; } // ─── Expose global functions for injectJavaScript ─── @@ -4058,7 +3349,7 @@ window.setPaginatedLayout = function (m) { applySettings({ paginatedLayout: m }); }; window.addAnnotation = function (a) { if (view) view.addAnnotation(a); }; window.removeAnnotation = function (a) { if (view) view.deleteAnnotation(a); }; - window.setTTSHighlight = function (cfi, color) { return setTTSHighlight(cfi, color); }; + window.setTTSHighlight = function (cfi, color) { return applyTTSHighlight(cfi, color); }; window.clearTTSHighlight = function () { return clearTTSHighlight(); }; // Flash highlight for AI citation - temporarily hide existing annotation, show flash, then restore @@ -4165,14 +3456,6 @@ const context = getTTSSegmentContextFromEngine(cfi, before, after); postToRN('ttsSegmentContext', { requestId: requestId || null, ...context }); }; - window.doGetHrefTTSSegments = async function (href, count, requestId) { - const segments = await getHrefTTSSegmentsFromBook(href, count); - postToRN('hrefTTSSegments', { requestId: requestId || null, segments }); - }; - window.doGetSectionTTSSegments = async function (sectionIndex, count, requestId) { - const segments = await getSectionTTSSegmentsFromBook(sectionIndex, count); - postToRN('sectionTTSSegments', { requestId: requestId || null, segments }); - }; window.doGetChapterParagraphs = function () { handleGetChapterParagraphs(); }; diff --git a/packages/app-expo/src/App.tsx b/packages/app-expo/src/App.tsx index c59779770..4a039667a 100644 --- a/packages/app-expo/src/App.tsx +++ b/packages/app-expo/src/App.tsx @@ -45,7 +45,6 @@ import { Audio } from "expo-av"; import { I18nextProvider } from "react-i18next"; import TrackPlayer, { AppKilledPlaybackBehavior, - Event as TrackEvent, Capability, } from "react-native-track-player"; @@ -165,32 +164,6 @@ export default function App() { ], }); - // Remote event → TTS store bridge - const { useTTSStore: ttsStore } = await import("@/stores/tts-store"); - TrackPlayer.addEventListener(TrackEvent.RemotePlay, () => { - ttsStore.getState().resume(); - }); - TrackPlayer.addEventListener(TrackEvent.RemotePause, () => { - ttsStore.getState().pause(); - }); - TrackPlayer.addEventListener(TrackEvent.RemoteStop, () => { - ttsStore.getState().stop(); - }); - TrackPlayer.addEventListener(TrackEvent.RemoteNext, () => { - const { jumpToChunk, currentChunkIndex, totalChunks } = ttsStore.getState(); - const nextIndex = currentChunkIndex + 1; - if (nextIndex < totalChunks) { - jumpToChunk(nextIndex); - } - }); - TrackPlayer.addEventListener(TrackEvent.RemotePrevious, () => { - const { jumpToChunk, currentChunkIndex } = ttsStore.getState(); - const prevIndex = currentChunkIndex - 1; - if (prevIndex >= 0) { - jumpToChunk(prevIndex); - } - }); - console.log("[App] bootstrap: done"); setReady(true); // Hide native splash now — our animated splash takes over diff --git a/packages/app-expo/src/components/reader/SelectionPopover.tsx b/packages/app-expo/src/components/reader/SelectionPopover.tsx index daa748117..b393c77b0 100644 --- a/packages/app-expo/src/components/reader/SelectionPopover.tsx +++ b/packages/app-expo/src/components/reader/SelectionPopover.tsx @@ -53,7 +53,7 @@ interface Props { onDismiss: () => void; onCopy: () => void; onAIChat: () => void; - onSpeak?: (text: string, cfi: string) => void; + onSpeak?: (cfi: string) => void; onNote?: (text: string, cfi: string) => void; onTranslate?: (text: string) => void; onRemoveHighlight?: () => void; @@ -98,11 +98,7 @@ export function SelectionPopover({ } }, [selection.cfi, hasExistingHighlight]); - const buttonCount = - 4 + - (onNote ? 1 : 0) + - (onTranslate ? 1 : 0) + - (onSpeak ? 1 : 0); + const buttonCount = 4 + (onNote ? 1 : 0) + (onTranslate ? 1 : 0) + (onSpeak ? 1 : 0); const colorRowItemCount = HIGHLIGHT_COLORS.length + (canRemoveHighlight ? 2 : 0); const colorRowWidth = showColors ? HIGHLIGHT_COLORS.length * COLOR_DOT_SIZE + @@ -157,12 +153,9 @@ export function SelectionPopover({ }, [selection.text, onCopy]); const handleSpeak = useCallback(() => { - const text = selection.text.trim(); - if (text && onSpeak) { - onSpeak(text, selection.cfi); - } + onSpeak?.(selection.cfi); onDismiss(); - }, [selection.text, selection.cfi, onSpeak, onDismiss]); + }, [selection.cfi, onSpeak, onDismiss]); const handleNote = useCallback(() => { setShowNoteModal(true); @@ -270,7 +263,6 @@ export function SelectionPopover({ )} - diff --git a/packages/app-expo/src/components/reader/TTSPage.tsx b/packages/app-expo/src/components/reader/TTSPage.tsx index 095e245db..7cc029837 100644 --- a/packages/app-expo/src/components/reader/TTSPage.tsx +++ b/packages/app-expo/src/components/reader/TTSPage.tsx @@ -14,6 +14,7 @@ import { import { useColors, radius } from "@/styles/theme"; import { buildNarrationPreview, + findTTSLyricIndex, type TTSConfig, type TTSPlayState, getTTSVoiceLabel, @@ -59,6 +60,8 @@ interface TTSPageProps { narrationSegments?: Array<{ text: string; cfi?: string | null }>; /** Sentences from the previously-read page, shown above current page sentences */ prevNarrationSegments?: Array<{ text: string; cfi?: string | null }>; + /** Persisted lyric list, independent from the audio synthesis cache. */ + lyricSegments?: Array<{ text: string; cfi?: string | null }>; currentSegmentCfi?: string | null; currentSegmentText?: string | null; currentChunkIndex?: number; @@ -88,6 +91,12 @@ interface TTSPageProps { onNextChapter?: () => void | Promise; } +interface TTSLyricItem { + id: string; + text: string; + cfi?: string | null; +} + function clampPct(p: number) { return Math.max(0, Math.min(100, Math.round(p * 100))); } @@ -108,6 +117,7 @@ export function TTSPage({ continuousEnabled, narrationSegments = [], prevNarrationSegments = [], + lyricSegments: persistedLyricSegments, currentSegmentCfi, currentSegmentText, currentChunkIndex = 0, @@ -162,7 +172,7 @@ export function TTSPage({ // Number of prev-page sentences prepended to the list const prevCount = prevNarrationSegments?.filter((segment) => segment.text.trim().length > 0).length ?? 0; - const lyricSegments = useMemo(() => { + const fallbackLyricSegments = useMemo(() => { const keyCounts = new Map(); const toLyricItem = ( prefix: "prev" | "curr", @@ -193,6 +203,12 @@ export function TTSPage({ } return currentText ? [{ id: "fallback:current-text", text: currentText, cfi: null }] : []; }, [currentText, narrationSegments, prevNarrationSegments]); + const lyricSegments: TTSLyricItem[] = persistedLyricSegments?.length + ? persistedLyricSegments.map((segment, index) => ({ + ...segment, + id: `persistent:${segment.cfi || `${segment.text}:${index}`}`, + })) + : fallbackLyricSegments; const lyricSegmentIdsKey = useMemo( () => lyricSegments.map((segment) => segment.id).join("|"), [lyricSegments], @@ -213,6 +229,11 @@ export function TTSPage({ return currentIndex; } } + const persistedIndex = findTTSLyricIndex(lyricSegments, { + cfi: currentSegmentCfi, + text: normalizedCurrentText, + }); + if (persistedLyricSegments?.length && persistedIndex >= 0) return persistedIndex; const actualPrevCount = lyricSegments.filter((s) => s.id.startsWith("prev:")).length; const prevCountStale = prevCount !== actualPrevCount; if (prevCountStale) { @@ -249,6 +270,7 @@ export function TTSPage({ currentSegmentText, lyricSegments, narrationSegments, + persistedLyricSegments, prevCount, ]); const lyricCenterPadding = useMemo( @@ -487,14 +509,14 @@ export function TTSPage({ const handleLyricPress = useCallback( (segment: { text: string; cfi?: string | null }, index: number) => { - const offsetFromCurrent = index - prevCount; + const offsetFromCurrent = persistedLyricSegments?.length ? index : index - prevCount; if (onJumpToLyricSegment) { onJumpToLyricSegment(segment, offsetFromCurrent); return; } onJumpToSegment?.(offsetFromCurrent); }, - [onJumpToLyricSegment, onJumpToSegment, prevCount], + [onJumpToLyricSegment, onJumpToSegment, persistedLyricSegments, prevCount], ); const triggerLoadMoreAbove = useCallback(() => { diff --git a/packages/app-expo/src/hooks/use-reader-bridge.ts b/packages/app-expo/src/hooks/use-reader-bridge.ts index 45e5a6a47..3393600e4 100644 --- a/packages/app-expo/src/hooks/use-reader-bridge.ts +++ b/packages/app-expo/src/hooks/use-reader-bridge.ts @@ -101,12 +101,6 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { const pendingTTSContextResolveRef = useRef( new Map void>(), ); - const pendingHrefTTSSegmentsResolveRef = useRef( - new Map void>(), - ); - const pendingSectionTTSSegmentsResolveRef = useRef( - new Map void>(), - ); const pendingChapterParagraphsResolveRef = useRef< ((paragraphs: Array<{ id: string; text: string; tagName: string }>) => void) | null >(null); @@ -419,72 +413,6 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { [createRequestId], ); - const getHrefTTSSegments = useCallback( - (href: string, count = 24) => { - return new Promise((resolve) => { - const requestId = createRequestId("href-tts"); - pendingHrefTTSSegmentsResolveRef.current.set(requestId, resolve); - - webViewRef.current?.injectJavaScript(` - (function() { - try { - if (window.doGetHrefTTSSegments) { - window.doGetHrefTTSSegments(${JSON.stringify(href)}, ${Math.max(1, count)}, ${JSON.stringify(requestId)}); - } else { - window.ReactNativeWebView.postMessage(JSON.stringify({type:'hrefTTSSegments',requestId:${JSON.stringify(requestId)},segments:[],error:'doGetHrefTTSSegments not defined'})); - } - } catch(e) { - window.ReactNativeWebView.postMessage(JSON.stringify({type:'hrefTTSSegments',requestId:${JSON.stringify(requestId)},segments:[],error:String(e)})); - } - })(); - true; - `); - - setTimeout(() => { - const pendingResolve = pendingHrefTTSSegmentsResolveRef.current.get(requestId); - if (pendingResolve === resolve) { - pendingHrefTTSSegmentsResolveRef.current.delete(requestId); - resolve([]); - } - }, 5000); - }); - }, - [createRequestId], - ); - - const getSectionTTSSegments = useCallback( - (sectionIndex: number, count = 24) => { - return new Promise((resolve) => { - const requestId = createRequestId("section-tts"); - pendingSectionTTSSegmentsResolveRef.current.set(requestId, resolve); - - webViewRef.current?.injectJavaScript(` - (function() { - try { - if (window.doGetSectionTTSSegments) { - window.doGetSectionTTSSegments(${sectionIndex}, ${Math.max(1, count)}, ${JSON.stringify(requestId)}); - } else { - window.ReactNativeWebView.postMessage(JSON.stringify({type:'sectionTTSSegments',requestId:${JSON.stringify(requestId)},segments:[],error:'doGetSectionTTSSegments not defined'})); - } - } catch(e) { - window.ReactNativeWebView.postMessage(JSON.stringify({type:'sectionTTSSegments',requestId:${JSON.stringify(requestId)},segments:[],error:String(e)})); - } - })(); - true; - `); - - setTimeout(() => { - const pendingResolve = pendingSectionTTSSegmentsResolveRef.current.get(requestId); - if (pendingResolve === resolve) { - pendingSectionTTSSegmentsResolveRef.current.delete(requestId); - resolve([]); - } - }, 6000); - }); - }, - [createRequestId], - ); - const setTTSHighlight = useCallback((cfi: string | null, color?: string, force = false) => { const previousCfi = lastTTSHighlightRef.current.cfi; const previousColor = lastTTSHighlightRef.current.color; @@ -692,232 +620,191 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { // ─── Handle messages from WebView ─── - const handleMessage = useCallback( - (event: { nativeEvent: { data: string } }) => { - try { - const msg = JSON.parse(event.nativeEvent.data); - const cb = callbacksRef.current; - - switch (msg.type) { - case "ready": - cb.onReady?.(); - break; - case "loaded": - cb.onLoaded?.(); - break; - case "relocate": - cb.onRelocate?.(msg); - break; - case "bookTextMetrics": - cb.onBookTextMetrics?.({ - totalCharacters: Number(msg.totalCharacters) || 0, + const handleMessage = useCallback((event: { nativeEvent: { data: string } }) => { + try { + const msg = JSON.parse(event.nativeEvent.data); + const cb = callbacksRef.current; + + switch (msg.type) { + case "ready": + cb.onReady?.(); + break; + case "loaded": + cb.onLoaded?.(); + break; + case "relocate": + cb.onRelocate?.(msg); + break; + case "bookTextMetrics": + cb.onBookTextMetrics?.({ + totalCharacters: Number(msg.totalCharacters) || 0, + }); + break; + case "toc": + cb.onTocReady?.(msg.items || []); + break; + case "selection": + cb.onSelection?.(msg); + break; + case "selectionCleared": + cb.onSelectionCleared?.(); + break; + case "tap": + cb.onTap?.(); + break; + case "searchResult": + cb.onSearchResult?.(msg.index || 0, msg.count || 0); + break; + case "searchComplete": + cb.onSearchComplete?.(msg.count || 0); + break; + case "error": + console.error("[ReaderBridge] Error from WebView:", msg.message); + cb.onError?.(msg.message || "Unknown error"); + break; + case "foliate-loaded": + break; + case "show-annotation": + if (msg.value && msg.position) { + cb.onShowAnnotation?.({ + value: msg.value, + range: msg.range, + position: msg.position, }); - break; - case "toc": - cb.onTocReady?.(msg.items || []); - break; - case "selection": - cb.onSelection?.(msg); - break; - case "selectionCleared": - cb.onSelectionCleared?.(); - break; - case "tap": - cb.onTap?.(); - break; - case "searchResult": - cb.onSearchResult?.(msg.index || 0, msg.count || 0); - break; - case "searchComplete": - cb.onSearchComplete?.(msg.count || 0); - break; - case "error": - console.error("[ReaderBridge] Error from WebView:", msg.message); - cb.onError?.(msg.message || "Unknown error"); - break; - case "foliate-loaded": - break; - case "show-annotation": - if (msg.value && msg.position) { - cb.onShowAnnotation?.({ - value: msg.value, - range: msg.range, - position: msg.position, - }); - } - break; - case "note-tooltip": - if (msg.cfi && msg.note && msg.position) { - cb.onNoteTooltip?.({ - cfi: msg.cfi, - note: msg.note, - position: msg.position, - }); - } - break; - case "pageSnippet": - cb.onPageSnippet?.(msg.textSnippet || ""); - break; - case "bookmarkSnippet": - cb.onBookmarkSnippet?.(msg.textSnippet || ""); - break; - case "toggleBookmark": - cb.onToggleBookmark?.(); - break; - case "bookmarkPull": - cb.onBookmarkPull?.({ - offset: typeof msg.offset === "number" ? msg.offset : 0, - armed: !!msg.armed, - active: !!msg.active, + } + break; + case "note-tooltip": + if (msg.cfi && msg.note && msg.position) { + cb.onNoteTooltip?.({ + cfi: msg.cfi, + note: msg.note, + position: msg.position, }); - break; - case "visibleText": - console.log( - "[ReaderBridge] received visibleText:", - JSON.stringify({ - textLength: msg.text?.length || 0, - error: msg.error || "none", - debug: msg.debug || null, - }), - ); - if (pendingVisibleTextResolveRef.current) { - pendingVisibleTextResolveRef.current(msg.text || ""); - pendingVisibleTextResolveRef.current = null; + } + break; + case "pageSnippet": + cb.onPageSnippet?.(msg.textSnippet || ""); + break; + case "bookmarkSnippet": + cb.onBookmarkSnippet?.(msg.textSnippet || ""); + break; + case "toggleBookmark": + cb.onToggleBookmark?.(); + break; + case "bookmarkPull": + cb.onBookmarkPull?.({ + offset: typeof msg.offset === "number" ? msg.offset : 0, + armed: !!msg.armed, + active: !!msg.active, + }); + break; + case "visibleText": + console.log( + "[ReaderBridge] received visibleText:", + JSON.stringify({ + textLength: msg.text?.length || 0, + error: msg.error || "none", + debug: msg.debug || null, + }), + ); + if (pendingVisibleTextResolveRef.current) { + pendingVisibleTextResolveRef.current(msg.text || ""); + pendingVisibleTextResolveRef.current = null; + } + break; + case "visibleTTSSegments": + { + if (msg.debug) { + console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug)); } - break; - case "visibleTTSSegments": - { - if (msg.debug) { - console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug)); + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId) + : pendingVisibleTTSSegmentsResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error); } - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId) - : pendingVisibleTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingVisibleTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingVisibleTTSSegmentsResolveRef.current.clear(); - } + pendingResolve(msg.segments || []); + if (requestId) { + pendingVisibleTTSSegmentsResolveRef.current.delete(requestId); + } else { + pendingVisibleTTSSegmentsResolveRef.current.clear(); } } - break; - case "ttsSegmentContext": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingTTSContextResolveRef.current.get(requestId) - : pendingTTSContextResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error); - } - pendingResolve({ - before: msg.before || [], - after: msg.after || [], - }); - if (requestId) { - pendingTTSContextResolveRef.current.delete(requestId); - } else { - pendingTTSContextResolveRef.current.clear(); - } + } + break; + case "ttsSegmentContext": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingTTSContextResolveRef.current.get(requestId) + : pendingTTSContextResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error); } - } - break; - case "hrefTTSSegments": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingHrefTTSSegmentsResolveRef.current.get(requestId) - : pendingHrefTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] hrefTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingHrefTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingHrefTTSSegmentsResolveRef.current.clear(); - } + pendingResolve({ + before: msg.before || [], + after: msg.after || [], + }); + if (requestId) { + pendingTTSContextResolveRef.current.delete(requestId); + } else { + pendingTTSContextResolveRef.current.clear(); } } - break; - case "sectionTTSSegments": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingSectionTTSSegmentsResolveRef.current.get(requestId) - : pendingSectionTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] sectionTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingSectionTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingSectionTTSSegmentsResolveRef.current.clear(); - } - } + } + break; + case "chapterParagraphs": + console.log( + "[ChapterTranslation] Received chapterParagraphs:", + JSON.stringify({ + count: msg.paragraphs?.length || 0, + error: msg.error || "none", + }), + ); + if (pendingChapterParagraphsResolveRef.current) { + if (msg.error) { + console.warn("[ChapterTranslation] WebView error:", msg.error); } - break; - case "chapterParagraphs": - console.log( - "[ChapterTranslation] Received chapterParagraphs:", - JSON.stringify({ - count: msg.paragraphs?.length || 0, - error: msg.error || "none", - }), + pendingChapterParagraphsResolveRef.current(msg.paragraphs || []); + pendingChapterParagraphsResolveRef.current = null; + } else { + console.warn( + "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)", ); - if (pendingChapterParagraphsResolveRef.current) { + } + break; + case "chapterTranslationsInjected": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingChapterTranslationInjectionResolveRef.current.get(requestId) + : pendingChapterTranslationInjectionResolveRef.current.values().next().value; + if (pendingResolve) { if (msg.error) { - console.warn("[ChapterTranslation] WebView error:", msg.error); + console.warn("[ChapterTranslation] WebView injection error:", msg.error); } - pendingChapterParagraphsResolveRef.current(msg.paragraphs || []); - pendingChapterParagraphsResolveRef.current = null; - } else { - console.warn( - "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)", - ); - } - break; - case "chapterTranslationsInjected": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingChapterTranslationInjectionResolveRef.current.get(requestId) - : pendingChapterTranslationInjectionResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ChapterTranslation] WebView injection error:", msg.error); - } - pendingResolve(); - if (requestId) { - pendingChapterTranslationInjectionResolveRef.current.delete(requestId); - } else { - pendingChapterTranslationInjectionResolveRef.current.clear(); - } + pendingResolve(); + if (requestId) { + pendingChapterTranslationInjectionResolveRef.current.delete(requestId); + } else { + pendingChapterTranslationInjectionResolveRef.current.clear(); } } - break; - case "debug": - console.log("[WebView]", msg.message); - break; - default: - break; - } - } catch (err) { - console.error("[ReaderBridge] Parse error:", err); + } + break; + case "debug": + console.log("[WebView]", msg.message); + break; + default: + break; } - }, - [], - ); + } catch (err) { + console.error("[ReaderBridge] Parse error:", err); + } + }, []); return useMemo( () => ({ @@ -947,8 +834,6 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { getVisibleText, getVisibleTTSSegments, getTTSSegmentContext, - getHrefTTSSegments, - getSectionTTSSegments, setTTSHighlight, flashHighlight, getChapterParagraphs, @@ -983,8 +868,6 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { getVisibleText, getVisibleTTSSegments, getTTSSegmentContext, - getHrefTTSSegments, - getSectionTTSSegments, setTTSHighlight, flashHighlight, getChapterParagraphs, diff --git a/packages/app-expo/src/lib/platform/expo-av-edge-player.ts b/packages/app-expo/src/lib/platform/expo-av-edge-player.ts deleted file mode 100644 index ffb1e44c0..000000000 --- a/packages/app-expo/src/lib/platform/expo-av-edge-player.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * ExpoAVEdgeTTSPlayer — ITTSPlayer backed by expo-av + Edge TTS WebSocket API. - * - * Fetch MP3 chunks from Microsoft Edge TTS → write to temp files → play - * sequentially via expo-av Audio.Sound with background audio enabled. - * - * Pipeline: while chunk N is playing, chunk N+1's Sound is pre-created - * in background so there is zero gap between chunks. - * - * Background audio is enabled via Audio.setAudioModeAsync called at app - * startup (see App.tsx). This player just manages playback. - */ -import { Audio } from "expo-av"; -import { File, Paths } from "expo-file-system"; -import type { ITTSPlayer, TTSConfig } from "@readany/core/tts"; -import { fetchEdgeTTSAudio } from "@readany/core/tts"; -import { splitIntoChunks } from "@readany/core/tts"; - -const CHUNK_MAX_CHARS = 500; - -interface PrefetchedSound { - sound: Audio.Sound; - uri: string; -} - -export class ExpoAVEdgeTTSPlayer implements ITTSPlayer { - private static readonly BUFFER_SIZE = 4; - - onStateChange?: (state: "playing" | "paused" | "stopped") => void; - onChunkChange?: (index: number, total: number) => void; - onEnd?: () => void; - - private _stopped = false; - private _paused = false; - private _currentSound: Audio.Sound | null = null; - private _chunks: string[] = []; - private _currentIndex = 0; - private _config: TTSConfig | null = null; - private _tempFiles: string[] = []; - private _prefetchBuffer = new Map>(); - private _producerIndex = 0; - private _producerWake: (() => void) | null = null; - private _nextSoundPromise: Promise | null = null; - private _nextSoundIndex = -1; - /** Incremented on every speak() call; lets _playChunk detect preemption. */ - private _speakGen = 0; - - async speak(text: string | string[], config: TTSConfig): Promise { - // Bump generation BEFORE cleanup so any in-flight _playChunk sees the new gen - // immediately after their current await returns, and bails out. - const gen = ++this._speakGen; - await this._cleanup(); - // If another speak() arrived while we were cleaning up, abort this one. - if (gen !== this._speakGen) return; - this._stopped = false; - this._paused = false; - this._config = config; - this._chunks = Array.isArray(text) ? text.filter(Boolean) : splitIntoChunks(text, CHUNK_MAX_CHARS); - this._currentIndex = 0; - this._tempFiles = []; - this._prefetchBuffer.clear(); - this._producerIndex = 0; - this._nextSoundPromise = null; - this._nextSoundIndex = -1; - this._runProducer(); - - const prewarmCount = Math.min(ExpoAVEdgeTTSPlayer.BUFFER_SIZE, this._chunks.length); - for (let p = 0; p < prewarmCount; p++) { - if (this._prefetchBuffer.has(p)) continue; - if (this._stopped) return; - const promise = this._fetchChunkFile(p); - promise.catch(() => {}); - this._prefetchBuffer.set(p, promise); - this._producerIndex = p + 1; - } - - this.onStateChange?.("playing"); - await this._playChunk(gen); - } - - private async _playChunk(gen: number): Promise { - if (gen !== this._speakGen || this._stopped || this._currentIndex >= this._chunks.length) { - if (gen === this._speakGen && !this._stopped) { - this._stopped = true; - console.log("[ExpoAVEdgeTTSPlayer] all chunks done, firing onEnd"); - this.onStateChange?.("stopped"); - this.onEnd?.(); - } - return; - } - - const idx = this._currentIndex; - console.log(`[ExpoAVEdgeTTSPlayer] _playChunk start idx=${idx}/${this._chunks.length}`); - this.onChunkChange?.(idx, this._chunks.length); - - let audioUri: string | null = null; - let usedPrefetched = false; - try { - const hasNextPrefetched = - this._nextSoundPromise !== null && - this._nextSoundIndex === idx && - idx < this._chunks.length; - - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} hasNextPrefetched=${hasNextPrefetched}`); - - if (hasNextPrefetched) { - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} awaiting prefetched sound`); - this._prefetchBuffer.delete(idx); - this._producerWake?.(); - const prefetched = await this._nextSoundPromise!; - this._nextSoundPromise = null; - this._nextSoundIndex = -1; - if (gen !== this._speakGen || this._stopped) { - await prefetched.sound.unloadAsync().catch(() => {}); - return; - } - this._currentSound = prefetched.sound; - audioUri = prefetched.uri; - usedPrefetched = true; - } else { - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} fetching file`); - audioUri = await this._getChunkFile(idx); - if (gen !== this._speakGen || this._stopped) return; - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} creating Sound`); - const { sound } = await Audio.Sound.createAsync( - { uri: audioUri }, - { shouldPlay: false, rate: 1.0, shouldCorrectPitch: false }, - ); - if (gen !== this._speakGen || this._stopped) { - await sound.unloadAsync().catch(() => {}); - return; - } - this._currentSound = sound; - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} Sound created`); - } - - const sound = this._currentSound!; - - this._prefetchNextSound(idx + 1); - - if (this._paused) { - await new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (gen !== this._speakGen || this._stopped) { - clearInterval(checkInterval); - resolve(); - } else if (!this._paused) { - clearInterval(checkInterval); - sound.playAsync().then(() => resolve()); - } - }, 100); - }); - if (gen !== this._speakGen || this._stopped) { - await sound.unloadAsync().catch(() => {}); - this._currentSound = null; - return; - } - } - - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} registering status listener`); - await new Promise((resolve, reject) => { - let settled = false; - const settle = (fn: () => void) => { - if (settled) return; - settled = true; - fn(); - }; - - const timeoutId = setTimeout(() => { - console.warn(`[ExpoAVEdgeTTSPlayer] idx=${idx} playback timeout, advancing`); - settle(resolve); - }, 120_000); - - sound.setOnPlaybackStatusUpdate((status) => { - // If preempted by a new speak(), resolve immediately so we can exit cleanly. - if (gen !== this._speakGen) { - clearTimeout(timeoutId); - settle(resolve); - return; - } - if (!status.isLoaded) { - clearTimeout(timeoutId); - if ((status as any).error) { - console.error(`[ExpoAVEdgeTTSPlayer] idx=${idx} playback error:`, (status as any).error); - settle(() => reject(new Error((status as any).error))); - } else { - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} isLoaded=false (natural end)`); - settle(resolve); - } - return; - } - if (status.didJustFinish) { - clearTimeout(timeoutId); - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} didJustFinish`); - settle(resolve); - } - }); - - if (!this._paused && !this._stopped && gen === this._speakGen) { - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} calling playAsync`); - sound.playAsync().catch((err) => { - console.error(`[ExpoAVEdgeTTSPlayer] idx=${idx} playAsync error:`, err); - settle(() => reject(err)); - }); - } - }); - - console.log(`[ExpoAVEdgeTTSPlayer] idx=${idx} playback promise resolved, unloading`); - await sound.unloadAsync().catch(() => {}); - this._currentSound = null; - - if (gen !== this._speakGen || this._stopped) return; - this._currentIndex++; - await this._playChunk(gen); - } catch (err) { - if (!this._stopped && (err as Error)?.message !== "aborted") { - console.error(`[ExpoAVEdgeTTSPlayer] chunk ${idx} error:`, err); - } - if (this._currentSound) { - await this._currentSound.unloadAsync().catch(() => {}); - this._currentSound = null; - } - if (gen !== this._speakGen || this._stopped) return; - this._currentIndex++; - await this._playChunk(gen); - } finally { - this._prefetchBuffer.delete(idx); - this._producerWake?.(); - if (audioUri && !usedPrefetched) { - try { - const f = new File(audioUri); - if (f.exists) f.delete(); - } catch {} - } - } - } - - private _prefetchNextSound(idx: number): void { - if (this._stopped || idx >= this._chunks.length) return; - if (this._nextSoundPromise !== null) return; - this._nextSoundIndex = idx; - this._nextSoundPromise = (async () => { - const uri = await this._getChunkFile(idx); - if (this._stopped) throw new Error("aborted"); - const { sound } = await Audio.Sound.createAsync( - { uri }, - { shouldPlay: false, rate: 1.0, shouldCorrectPitch: false }, - ); - return { sound, uri }; - })().catch((err) => { - if ((err as Error)?.message !== "aborted") { - console.error("[ExpoAVEdgeTTSPlayer] prefetch error:", err); - } - throw err; - }); - } - - pause(): void { - if (this._stopped || this._paused) return; - this._paused = true; - this._currentSound?.pauseAsync().catch(() => {}); - this.onStateChange?.("paused"); - } - - resume(): void { - if (this._stopped || !this._paused) return; - this._paused = false; - this._currentSound?.playAsync().catch(() => {}); - this.onStateChange?.("playing"); - } - - stop(): void { - this._stopped = true; - this._currentSound?.stopAsync().catch(() => {}); - this._currentSound?.unloadAsync().catch(() => {}); - this._currentSound = null; - this._nextSoundPromise = null; - this._nextSoundIndex = -1; - this._prefetchBuffer.clear(); - this._producerWake?.(); - this._cleanupTempFiles(); - this.onStateChange?.("stopped"); - } - - private async _cleanup(): Promise { - this._stopped = true; - if (this._currentSound) { - await this._currentSound.stopAsync().catch(() => {}); - await this._currentSound.unloadAsync().catch(() => {}); - this._currentSound = null; - } - if (this._nextSoundPromise) { - try { - const prefetched = await this._nextSoundPromise; - await prefetched.sound.unloadAsync().catch(() => {}); - } catch {} - this._nextSoundPromise = null; - } - this._prefetchBuffer.clear(); - this._producerWake?.(); - this._cleanupTempFiles(); - } - - private async _runProducer(): Promise { - while (this._producerIndex < this._chunks.length) { - if (this._stopped) return; - - while (this._prefetchBuffer.size >= ExpoAVEdgeTTSPlayer.BUFFER_SIZE) { - if (this._stopped) return; - await new Promise((resolve) => { - this._producerWake = resolve; - }); - this._producerWake = null; - } - - if (this._stopped) return; - - const index = this._producerIndex++; - const promise = this._fetchChunkFile(index); - promise.catch(() => {}); - this._prefetchBuffer.set(index, promise); - } - } - - private async _getChunkFile(index: number): Promise { - while (!this._prefetchBuffer.has(index)) { - if (this._stopped) { - throw new Error("aborted"); - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - const promise = this._prefetchBuffer.get(index); - if (!promise) { - return Promise.reject(new Error("aborted")); - } - return promise; - } - - private async _fetchChunkFile(index: number): Promise { - if (this._stopped) throw new Error("aborted"); - const config = this._config!; - const voice = config.edgeVoice || "zh-CN-XiaoxiaoNeural"; - const lang = voice.split("-").slice(0, 2).join("-"); - - const mp3Data = await fetchEdgeTTSAudio({ - text: this._chunks[index], - voice, - lang, - rate: config.rate, - pitch: config.pitch, - }); - - if (this._stopped) throw new Error("aborted"); - - const tmpName = `tts_chunk_${index}_${Date.now()}.mp3`; - const tmpFile = new File(Paths.cache, tmpName); - const audioUri = tmpFile.uri; - this._tempFiles.push(audioUri); - tmpFile.write(new Uint8Array(mp3Data)); - return audioUri; - } - - private _cleanupTempFiles(): void { - for (const f of this._tempFiles) { - try { - const file = new File(f); - if (file.exists) file.delete(); - } catch {} - } - this._tempFiles = []; - } -} diff --git a/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts b/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts index 82f4b6648..dc496e550 100644 --- a/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts +++ b/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts @@ -1,6 +1,9 @@ -import { getPlatformService } from "@readany/core/services"; -import type { ITTSPlayer, TTSConfig } from "@readany/core/tts"; -import { splitIntoChunks } from "@readany/core/tts"; +import { + type ITTSPlayer, + type TTSConfig, + fetchDashScopeTTSAudio, + splitIntoChunks, +} from "@readany/core/tts"; import { File, Paths } from "expo-file-system"; import { AppState, type AppStateStatus, Image, Platform } from "react-native"; import TrackPlayer, { Event, State } from "react-native-track-player"; @@ -642,49 +645,10 @@ export class TrackPlayerDashScopeTTSPlayer implements ITTSPlayer { if (this._stopped || gen !== this._speakGen || !this._config) throw new Error("aborted"); const config = this._config; - const platform = getPlatformService(); - - const response = await platform.fetch( - "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.dashscopeApiKey}`, - }, - body: JSON.stringify({ - model: "qwen3-tts-flash", - input: { - text: this._chunks[index], - voice: config.dashscopeVoice, - }, - parameters: { - response_format: "mp3", - }, - }), - }, - ); - - if (!response.ok) { - throw new Error(`DashScope TTS failed: ${response.status}`); - } - - const result = (await response.json()) as { - output?: { audio?: { data?: string } }; - }; - const audioData = result?.output?.audio?.data; - if (!audioData) { - throw new Error("No audio data in DashScope response"); - } + const bytes = await fetchDashScopeTTSAudio(this._chunks[index], config); if (this._stopped || gen !== this._speakGen) throw new Error("aborted"); - const binary = atob(audioData); - const bytes = new Uint8Array(binary.length); - for (let j = 0; j < binary.length; j++) { - bytes[j] = binary.charCodeAt(j); - } - const tmpName = `tts_dashscope_${index}_${Date.now()}.mp3`; const tmpFile = new File(Paths.cache, tmpName); const audioUri = tmpFile.uri; diff --git a/packages/app-expo/src/lib/platform/tts-preview.ts b/packages/app-expo/src/lib/platform/tts-preview.ts index dd3a5e167..5cc990bf3 100644 --- a/packages/app-expo/src/lib/platform/tts-preview.ts +++ b/packages/app-expo/src/lib/platform/tts-preview.ts @@ -1,10 +1,10 @@ import type { TTSConfig } from "@readany/core/tts"; -import { ExpoAVEdgeTTSPlayer } from "./expo-av-edge-player"; import { ExpoSpeechTTSPlayer } from "./expo-speech-player"; import { TrackPlayerCloudTTSPlayer } from "./track-player-cloud-tts-player"; +import { TrackPlayerEdgeTTSPlayer } from "./track-player-edge-player"; const systemPreviewPlayer = new ExpoSpeechTTSPlayer(); -const edgePreviewPlayer = new ExpoAVEdgeTTSPlayer(); +const edgePreviewPlayer = new TrackPlayerEdgeTTSPlayer(); const dashscopePreviewPlayer = new ExpoSpeechTTSPlayer(); const cloudPreviewPlayer = new TrackPlayerCloudTTSPlayer(); diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index 3eb75832a..2bd3cbbee 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -263,6 +263,7 @@ export function ReaderScreen({ route, navigation }: Props) { // Mediator ref so onRelocate can fire TTS continuation without direct hook dependency const ttsPendingContinueRef = useRef<{ pendingTTSContinueCallbackRef: React.RefObject<(() => void) | null>; + pendingTTSContinueSectionRef: React.RefObject; pendingTTSContinueSafetyTimerRef: React.RefObject | null>; } | null>(null); @@ -280,8 +281,6 @@ export function ReaderScreen({ route, navigation }: Props) { before?: number, after?: number, ) => Promise<{ before: TTSSegment[]; after: TTSSegment[] }>; - getHrefTTSSegments?: (href: string, count?: number) => Promise; - getSectionTTSSegments?: (sectionIndex: number, count?: number) => Promise; goToFraction: (fraction: number) => void; goToSection: (sectionIndex: number) => void; goToCFI: (cfi: string) => void; @@ -754,17 +753,28 @@ export function ReaderScreen({ route, navigation }: Props) { // If TTS is waiting for a page turn to complete, fire the continuation callback now // that the renderer has fully updated its position (renderer.start reflects new page). - if (ttsPendingContinueRef.current?.pendingTTSContinueCallbackRef.current) { + const pendingTTS = ttsPendingContinueRef.current; + const pendingSection = pendingTTS?.pendingTTSContinueSectionRef.current ?? null; + if ( + pendingTTS?.pendingTTSContinueCallbackRef.current && + (pendingSection === null || pendingSection === newSection) + ) { console.log("[ReaderScreen][TTS] onRelocate triggered pending TTS continuation"); - const cb = ttsPendingContinueRef.current.pendingTTSContinueCallbackRef.current; - ttsPendingContinueRef.current.pendingTTSContinueCallbackRef.current = null; + const cb = pendingTTS.pendingTTSContinueCallbackRef.current; + pendingTTS.pendingTTSContinueCallbackRef.current = null; + pendingTTS.pendingTTSContinueSectionRef.current = null; // Cancel the safety timer since onRelocate fired successfully - const safetyTimerRef = ttsPendingContinueRef.current.pendingTTSContinueSafetyTimerRef; + const safetyTimerRef = pendingTTS.pendingTTSContinueSafetyTimerRef; if (safetyTimerRef.current) { clearTimeout(safetyTimerRef.current); safetyTimerRef.current = null; } void cb(); + } else if (pendingTTS?.pendingTTSContinueCallbackRef.current && pendingSection !== null) { + console.log("[ReaderScreen][TTS] ignoring relocate for wrong section", { + pendingSection, + actualSection: newSection, + }); } // Sync reading context for AI tools @@ -944,6 +954,7 @@ export function ReaderScreen({ route, navigation }: Props) { // Bind mediator ref so onRelocate can fire the TTS continuation callback ttsPendingContinueRef.current = { pendingTTSContinueCallbackRef: tts.pendingTTSContinueCallbackRef, + pendingTTSContinueSectionRef: tts.pendingTTSContinueSectionRef, pendingTTSContinueSafetyTimerRef: tts.pendingTTSContinueSafetyTimerRef, }; @@ -1605,10 +1616,7 @@ export function ReaderScreen({ route, navigation }: Props) { onCopy={() => { setSelection(null); }} - onSpeak={(text, cfi) => { - tts.startSelectionTTS(text, cfi); - setSelection(null); - }} + onSpeak={(cfi) => void tts.startTTSFromSelection(cfi)} onAIChat={() => { const selectedText = selectionPopoverSelection.text; const chapter = currentChapter; @@ -2160,6 +2168,7 @@ export function ReaderScreen({ route, navigation }: Props) { continuousEnabled={tts.ttsContinuousEnabled} narrationSegments={tts.ttsDisplaySegments} prevNarrationSegments={tts.ttsPrevPageSegments} + lyricSegments={tts.lyricSegments} currentSegmentCfi={tts.resolvedTTSSegmentCfi} currentSegmentText={tts.currentTTSSegment?.text || null} currentChunkIndex={tts.localTTSChunkIndex} diff --git a/packages/app-expo/src/screens/reader/tts-debug-utils.ts b/packages/app-expo/src/screens/reader/tts-debug-utils.ts deleted file mode 100644 index dab48eb6f..000000000 --- a/packages/app-expo/src/screens/reader/tts-debug-utils.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * TTS debug utility functions. - * These are only active in __DEV__ mode and help diagnose - * text-to-speech segment extraction issues. - */ - -export type TTSDebuggableSegment = { text: string; cfi?: string | null }; - -export function normalizeTTSDebugText(text: string | null | undefined) { - return String(text || "") - .replace(/\s+/g, " ") - .trim(); -} - -export function logTTSDebugText(label: string, text: string | null | undefined, chunkSize = 220) { - if (!__DEV__) return; - const normalized = normalizeTTSDebugText(text); - const chunkCount = normalized ? Math.ceil(normalized.length / chunkSize) : 0; - console.log(`${label} summary`, { - length: normalized.length, - chunkCount, - }); - if (!normalized) return; - for (let index = 0; index < chunkCount; index += 1) { - const start = index * chunkSize; - const end = start + chunkSize; - console.log(`${label}#${index + 1}/${chunkCount}`, normalized.slice(start, end)); - } -} - -export function logTTSDebugSentenceList( - label: string, - sentences: Array, - limit = Number.POSITIVE_INFINITY, -) { - if (!__DEV__) return; - const normalized = sentences.map(normalizeTTSDebugText).filter(Boolean).slice(0, limit); - console.log(`${label} summary`, { - count: normalized.length, - }); - normalized.forEach((sentence, index) => { - console.log(`${label}[${index}]`, { - length: sentence.length, - text: sentence, - }); - }); -} - -export function normalizeTTSDebugSegments(segments: TTSDebuggableSegment[]) { - return segments - .map((segment) => ({ - cfi: segment.cfi || null, - text: normalizeTTSDebugText(segment.text), - })) - .filter((segment) => segment.text.length > 0); -} - -export function logTTSDebugSegments(label: string, segments: TTSDebuggableSegment[]) { - if (!__DEV__) return; - const normalized = normalizeTTSDebugSegments(segments); - console.log(`${label} summary`, { - count: normalized.length, - firstCfi: normalized[0]?.cfi || null, - lastCfi: normalized[normalized.length - 1]?.cfi || null, - }); - normalized.forEach((segment, index) => { - console.log(`${label}[${index}]`, { - cfi: segment.cfi || null, - length: segment.text.length, - text: segment.text, - }); - }); -} - -export function collectMissingTTSDebugSentences( - sentences: string[], - segments: TTSDebuggableSegment[], -) { - const segmentTexts = new Set( - normalizeTTSDebugSegments(segments).map((segment) => segment.text), - ); - return sentences - .map(normalizeTTSDebugText) - .filter((sentence) => sentence.length > 0 && !segmentTexts.has(sentence)); -} - -export function findTTSDebugSentenceIndex( - sentence: string | null | undefined, - segments: TTSDebuggableSegment[], -) { - const normalizedSentence = normalizeTTSDebugText(sentence); - if (!normalizedSentence) return -1; - return normalizeTTSDebugSegments(segments).findIndex( - (segment) => segment.text === normalizedSentence, - ); -} diff --git a/packages/app-expo/src/screens/reader/useReaderTTS.ts b/packages/app-expo/src/screens/reader/useReaderTTS.ts index d67fce9a1..2b6b646a2 100644 --- a/packages/app-expo/src/screens/reader/useReaderTTS.ts +++ b/packages/app-expo/src/screens/reader/useReaderTTS.ts @@ -1,34 +1,20 @@ -/** - * useReaderTTS — encapsulates all TTS (text-to-speech) logic for ReaderScreen. - * - * Responsibilities: - * - TTS state: segments, prev/future pages, chunk offset, source kind, continuous mode - * - Cover URI resolution - * - All TTS callbacks: start page/selection/from-cfi, play/pause/replay, jump, load-more - * - All TTS-related useEffects: highlight sync, location tracking, lyric recovery, etc. - * - Cleanup on unmount (clear continuous callback, clear highlight) - */ - import type { VisibleTTSSegment } from "@/hooks/use-reader-bridge"; import { useTTSStore } from "@/stores"; import { getPlatformService } from "@readany/core/services"; -import { type TTSConfig, normalizeTTSConfig, splitNarrationText } from "@readany/core/tts"; -import { eventBus } from "@readany/core/utils/event-bus"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AppState, type AppStateStatus } from "react-native"; import { - collectMissingTTSDebugSentences, - findTTSDebugSentenceIndex, - logTTSDebugSegments, - logTTSDebugSentenceList, - logTTSDebugText, - normalizeTTSDebugSegments, - normalizeTTSDebugText, -} from "./tts-debug-utils"; + TTSReadingSession, + mergeTTSConfigUpdates, + mergeTTSLyricContext, + mergeTTSLyrics, + normalizeTTSConfig, + splitNarrationText, +} from "@readany/core/tts"; +import type { TTSConfig } from "@readany/core/tts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; export type TTSSegment = VisibleTTSSegment; +const TTS_READER_HIGHLIGHT_COLOR = "rgba(96, 165, 250, 0.35)"; -// ─── Types for the bridge ref the hook needs ─────────────────────────────── export type TTSBridgeRef = { getVisibleText: () => Promise; getVisibleTTSSegments: (alignCfi?: string | null) => Promise; @@ -38,15 +24,12 @@ export type TTSBridgeRef = { before?: number, after?: number, ) => Promise<{ before: TTSSegment[]; after: TTSSegment[] }>; - getHrefTTSSegments?: (href: string, count?: number) => Promise; - getSectionTTSSegments?: (sectionIndex: number, count?: number) => Promise; goToSection?: (sectionIndex: number) => void; goToCFI: (cfi: string) => void; setTTSHighlight: (cfi: string | null, color?: string, force?: boolean) => void; flashHighlight: (cfi: string, color?: string, duration?: number) => void; }; -// ─── Hook inputs ──────────────────────────────────────────────────────────── export interface UseReaderTTSOptions { bookId: string; bookTitle: string; @@ -64,92 +47,60 @@ export interface UseReaderTTSOptions { goToHref: (href: string) => void; } -// ─── Hook outputs ─────────────────────────────────────────────────────────── export interface UseReaderTTSResult { - // State ttsCoverUri: string | undefined; ttsLastText: string; ttsSegments: TTSSegment[]; ttsPrevPageSegments: TTSSegment[]; ttsFutureSegments: TTSSegment[]; + lyricSegments: TTSSegment[]; ttsChunkOffset: number; ttsSourceKind: "page" | "selection"; ttsContinuousEnabled: boolean; ttsSourceLabel: string; - - // Derived allLyricSegments: TTSSegment[]; ttsDisplaySegments: TTSSegment[]; currentTTSSegment: TTSSegment | null; resolvedTTSSegmentCfi: string | null; ttsHighlightColor: string; localTTSChunkIndex: number; - - // Handlers handleToggleTTS: () => Promise; handleTTSReplay: () => Promise; handleTTSPlayPause: () => Promise; handleAdjustTTSRate: (delta: number) => void; handleAdjustTTSPitch: (delta: number) => void; - handleUpdateTTSConfig: (updates: Partial) => void; + handleUpdateTTSConfig: (u: Partial) => void; handleToggleTTSContinuous: () => void; - handleJumpToTTSSegment: (offsetFromCurrent: number) => void; - handleJumpToTTSLyricSegment: ( - segment: { text: string; cfi?: string | null }, - offsetFromCurrent: number, - ) => void; + handleJumpToTTSSegment: (offset: number) => void; + handleJumpToTTSLyricSegment: (s: { text: string; cfi?: string | null }, offset: number) => void; handleLoadMoreAboveTTSLyrics: () => Promise; handleLoadMoreBelowTTSLyrics: () => Promise; handleTTSPrevChapter: () => void; handleTTSNextChapter: () => void; - startSelectionTTS: (text: string, selectionCfi?: string | null) => void; + startTTSFromSelection: (selectionCfi: string) => Promise; handleTTSStop: () => void; handleTTSReturnToReading: () => void; - // Exposed refs for onRelocate integration pendingTTSContinueCallbackRef: React.RefObject<(() => void) | null>; + pendingTTSContinueSectionRef: React.RefObject; pendingTTSContinueSafetyTimerRef: React.RefObject | null>; } -const TTS_CONTEXT_CACHE_LIMIT = 24; -const TTS_CONTEXT_WINDOW = 12; -const TTS_DYNAMIC_APPEND_THRESHOLD = 18; -const TTS_DYNAMIC_APPEND_SEGMENTS = 72; -const TTS_NEXT_CHAPTER_PREFETCH_SEGMENTS = 500; - -function normalizeTTSSegmentIdentityText(text?: string | null) { - return (text || "").replace(/\s+/g, " ").trim(); -} - -function stripCfiAssertions(value: string) { - return value.replace(/\[[^\]]*\]/g, ""); -} - -function getTTSCfiStartIdentity(cfi?: string | null) { - const raw = (cfi || "").trim(); - if (!raw) return ""; - const inner = raw.startsWith("epubcfi(") && raw.endsWith(")") ? raw.slice(8, -1) : raw; - const bangIndex = inner.indexOf("!"); - if (bangIndex < 0) return stripCfiAssertions(inner); - - const sectionPath = stripCfiAssertions(inner.slice(0, bangIndex)); - const localPath = stripCfiAssertions(inner.slice(bangIndex + 1)); - const commaIndex = localPath.indexOf(","); - if (commaIndex < 0) return `${sectionPath}!${localPath}`; - - const commonPath = localPath.slice(0, commaIndex); - const rangePath = localPath.slice(commaIndex + 1); - const rangeEndIndex = rangePath.indexOf(","); - const startPath = rangeEndIndex >= 0 ? rangePath.slice(0, rangeEndIndex) : rangePath; - return `${sectionPath}!${commonPath}${startPath}`; -} - -function previewTTSQueue(segments: TTSSegment[], limit = 8) { - return segments.slice(0, limit).map((segment, index) => ({ - index, - cfi: segment.cfi || null, - text: normalizeTTSDebugText(segment.text), - })); -} +const normalize = (s: TTSSegment): TTSSegment => ({ + text: s.text.replace(/\s+/g, " ").trim(), + cfi: s.cfi || "", +}); +const getSegmentKey = (segment: TTSSegment) => + segment.cfi ? `${segment.cfi}::${segment.text}` : segment.text; +const dedupe = (items: TTSSegment[]) => { + const seen = new Set(); + return items.map(normalize).filter((s) => { + if (!s.text) return false; + const key = getSegmentKey(s); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; export function useReaderTTS({ bookId, @@ -160,2538 +111,575 @@ export function useReaderTTS({ webViewReady, showTTS, setShowTTS, - setShowControls, bridgeRef, toc, bookCoverUrl, colors, goToHref, }: UseReaderTTSOptions): UseReaderTTSResult { - // ─── TTS Store ───────────────────────────────────────────────────────────── - const ttsPlay = useTTSStore((s) => s.play); - const ttsAppend = useTTSStore((s) => s.append); - const ttsPause = useTTSStore((s) => s.pause); - const ttsResume = useTTSStore((s) => s.resume); - const ttsStop = useTTSStore((s) => s.stop); - const ttsPlayState = useTTSStore((s) => s.playState); - const ttsCurrentText = useTTSStore((s) => s.currentText); - const ttsCurrentSegmentText = useTTSStore((s) => s.currentSegmentText); - const ttsConfig = useTTSStore((s) => s.config); - const ttsUpdateConfig = useTTSStore((s) => s.updateConfig); - const ttsSetOnEnd = useTTSStore((s) => s.setOnEnd); - const ttsSetCurrentBook = useTTSStore((s) => s.setCurrentBook); - const ttsSetCurrentLocation = useTTSStore((s) => s.setCurrentLocation); - const ttsCurrentLocationCfi = useTTSStore((s) => s.currentLocationCfi); - const ttsCurrentBookId = useTTSStore((s) => s.currentBookId); - const ttsCurrentChunkIndex = useTTSStore((s) => s.currentChunkIndex); - const ttsTotalChunks = useTTSStore((s) => s.totalChunks); - const ttsJumpToChunk = useTTSStore((s) => s.jumpToChunk); - - // ─── TTS State ────────────────────────────────────────────────────────────── - const [ttsCoverUri, setTtsCoverUri] = useState(undefined); - const [ttsLastText, setTtsLastText] = useState(""); - const [ttsSegments, setTtsSegments] = useState([]); - const [ttsPrevPageSegments, setTtsPrevPageSegments] = useState([]); - const [ttsFutureSegments, setTtsFutureSegments] = useState([]); - const [ttsChunkOffset, setTtsChunkOffset] = useState(0); - const [ttsSourceKind, setTtsSourceKind] = useState<"page" | "selection">("page"); - const [ttsContinuousEnabled, setTtsContinuousEnabled] = useState(true); - - // ─── TTS Refs ─────────────────────────────────────────────────────────────── - const ttsSegmentsRef = useRef([]); - const ttsLastTextRef = useRef(""); - const ttsFutureSegmentsRef = useRef([]); - const ttsPrevPageSegmentsRef = useRef([]); - const ttsChunkOffsetRef = useRef(0); - const ttsLoadMoreAboveRef = useRef(null); - const ttsLoadMoreBelowRef = useRef(null); - const ttsExhaustedAboveAnchorsRef = useRef(new Set()); - const ttsExhaustedBelowAnchorsRef = useRef(new Set()); - const lastTTSLyricPrimeSignatureRef = useRef(null); - const didForceReapplyTTSHighlightRef = useRef(false); - const ttsLyricPrimeRequestIdRef = useRef(0); - const lastFollowedTTSCfiRef = useRef(null); - const ttsRecoveringLyricsRef = useRef(null); - const ttsContextCacheRef = useRef>( - new Map(), + const play = useTTSStore((s) => s.play), + preload = useTTSStore((s) => s.preload), + pause = useTTSStore((s) => s.pause), + resume = useTTSStore((s) => s.resume), + stop = useTTSStore((s) => s.stop); + const playState = useTTSStore((s) => s.playState), + currentText = useTTSStore((s) => s.currentText); + const config = useTTSStore((s) => s.config), + updateConfig = useTTSStore((s) => s.updateConfig), + setOnEnd = useTTSStore((s) => s.setOnEnd); + const setCurrentBook = useTTSStore((s) => s.setCurrentBook), + setCurrentLocation = useTTSStore((s) => s.setCurrentLocation), + currentLocationCfi = useTTSStore((s) => s.currentLocationCfi); + const currentBookId = useTTSStore((s) => s.currentBookId), + currentChunkIndex = useTTSStore((s) => s.currentChunkIndex), + jumpToChunk = useTTSStore((s) => s.jumpToChunk); + const [ttsCoverUri, setTtsCoverUri] = useState(), + [ttsLastText, setTtsLastText] = useState(""); + const [ttsSegments, setTtsSegments] = useState([]), + [ttsPrevPageSegments, setTtsPrevPageSegments] = useState([]), + [ttsFutureSegments, setTtsFutureSegments] = useState([]), + [ttsLyricSegments, setTtsLyricSegments] = useState([]); + const [ttsSourceKind, setTtsSourceKind] = useState<"page" | "selection">("page"), + [ttsContinuousEnabled, setTtsContinuousEnabled] = useState(true); + const readingSessionRef = useRef(new TTSReadingSession()); + const segmentsRef = useRef([]), + lyricSegmentsRef = useRef([]), + sourceRef = useRef<"page" | "selection">("page"), + continuousRef = useRef(true); + const pendingTTSContinueCallbackRef = useRef<(() => void) | null>(null), + pendingTTSContinueSectionRef = useRef(null), + pendingTTSContinueSafetyTimerRef = useRef | null>(null); + const generationRef = useRef(0), + handlePageEndRef = useRef<() => void>(() => undefined); + + const clearPending = useCallback(() => { + pendingTTSContinueCallbackRef.current = null; + pendingTTSContinueSectionRef.current = null; + if (pendingTTSContinueSafetyTimerRef.current) + clearTimeout(pendingTTSContinueSafetyTimerRef.current); + pendingTTSContinueSafetyTimerRef.current = null; + }, []); + const resetLyrics = useCallback(() => { + lyricSegmentsRef.current = []; + setTtsLyricSegments([]); + }, []); + useEffect(() => { + resetLyrics(); + }, [bookId, resetLyrics]); + const commit = useCallback( + (current: TTSSegment[], previous: TTSSegment[] = [], future: TTSSegment[] = []) => { + const next = dedupe(current); + const session = readingSessionRef.current; + session.start( + sourceRef.current, + next.map((segment) => ({ text: segment.text, cfi: segment.cfi || null })), + continuousRef.current, + ); + session.setContext( + previous.map((segment) => ({ text: segment.text, cfi: segment.cfi || null })), + future.map((segment) => ({ text: segment.text, cfi: segment.cfi || null })), + ); + setTtsSegments(next); + setTtsPrevPageSegments(dedupe(previous)); + setTtsFutureSegments(dedupe(future)); + const lyricSegments = mergeTTSLyricContext( + lyricSegmentsRef.current, + previous, + next, + future, + ) as TTSSegment[]; + lyricSegmentsRef.current = lyricSegments; + setTtsLyricSegments(lyricSegments); + segmentsRef.current = next; + }, + [], + ); + const loadContext = useCallback( + async (cfi: string | null, before = 12, after = 12) => { + if (!cfi || !bridgeRef.current?.getTTSSegmentContext) return { before: [], after: [] }; + try { + const result = await bridgeRef.current.getTTSSegmentContext(cfi, before, after); + return { before: dedupe(result.before || []), after: dedupe(result.after || []) }; + } catch { + return { before: [], after: [] }; + } + }, + [bridgeRef], ); - const ttsContextInflightRef = useRef< - Map> - >(new Map()); - const ttsContinuousRef = useRef(false); - const pendingTTSContinueCallbackRef = useRef<(() => void) | null>(null); - const pendingTTSContinueSafetyTimerRef = useRef | null>(null); - const startPageTTSFromCfiRef = useRef< - ((targetCfi: string, targetText?: string) => Promise) | null - >(null); - const ttsHandlingPageEndRef = useRef(false); - const ttsLastStopHandledSignatureRef = useRef(null); - const ttsDynamicAppendRequestRef = useRef(null); - const ttsPrefetchedChapterTransitionRef = useRef<{ - startChunkIndex: number; - targetIndex: number; - title: string; - href: string; - firstCfi: string; - navigated: boolean; - } | null>(null); - const ttsStartChapterRef = useRef(""); - const previousReaderBookIdRef = useRef(null); - // Mirrors of frequently-changing store values — used inside handleTTSPageEnd - // so the callback doesn't need to be re-created on every chunk advance. - const ttsCurrentChunkIndexRef = useRef(0); - const ttsCurrentLocationCfiRef = useRef(null); - const ttsTotalChunksRef = useRef(0); - - const ttsHighlightColor = "rgba(96, 165, 250, 0.35)"; - // Keep mirrors in sync every render (no useEffect needed — render is synchronous). - ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; - ttsCurrentLocationCfiRef.current = ttsCurrentLocationCfi; - ttsTotalChunksRef.current = ttsTotalChunks; + const preloadFollowingSegments = useCallback( + (current: TTSSegment[], future: TTSSegment[]) => { + if (!continuousRef.current) return; + const currentKeys = new Set(current.map(getSegmentKey)); + const following = dedupe(future).filter( + (segment) => !currentKeys.has(getSegmentKey(segment)), + ); + if (following.length) preload(following.map((segment) => segment.text)); + }, + [preload], + ); - // ─── Cover URI resolution ─────────────────────────────────────────────────── - useEffect(() => { - const raw = bookCoverUrl; - if (!raw) { - setTtsCoverUri(undefined); + const startPageFromCfi = useCallback( + async (cfi?: string | null, options: { revealPlayer?: boolean } = {}) => { + if (!webViewReady || !bridgeRef.current) return; + clearPending(); + const generation = ++generationRef.current; + const segments = dedupe(await bridgeRef.current.getVisibleTTSSegments(cfi || null)); + if (!segments.length || generation !== generationRef.current) return; + const context = await loadContext(segments[0]?.cfi || cfi || null); + if (generation !== generationRef.current) return; + sourceRef.current = "page"; + commit(segments, context.before, context.after); + setTtsSourceKind("page"); + const text = segments + .map((s) => s.text) + .join(" ") + .trim(); + setTtsLastText(text); + setCurrentBook(bookTitle, currentChapter, bookId); + setCurrentLocation(segments[0]?.cfi || cfi || currentCfi); + setOnEnd(continuousRef.current ? handlePageEndRef.current : null); + play(segments.map((s) => s.text)); + preloadFollowingSegments(segments, context.after); + if (options.revealPlayer) setShowTTS(true); + }, + [ + bookId, + bookTitle, + bridgeRef, + clearPending, + commit, + currentChapter, + currentCfi, + loadContext, + play, + preloadFollowingSegments, + setCurrentBook, + setCurrentLocation, + setOnEnd, + setShowTTS, + webViewReady, + ], + ); + const startTTSFromSelection = useCallback( + async (selectionCfi: string) => { + if (!selectionCfi) return; + resetLyrics(); + continuousRef.current = ttsContinuousEnabled; + await startPageFromCfi(selectionCfi, { revealPlayer: true }); + }, + [resetLyrics, startPageFromCfi, ttsContinuousEnabled], + ); + const handlePageEnd = useCallback(async () => { + if (!continuousRef.current || sourceRef.current !== "page") return; + const last = segmentsRef.current.at(-1)?.cfi || currentCfi; + const context = await loadContext(last || null, 0, 1); + const next = context.after[0]; + if (next?.cfi && bridgeRef.current) { + const generation = ++generationRef.current; + clearPending(); + pendingTTSContinueCallbackRef.current = () => { + if (generation === generationRef.current) void startPageFromCfi(next.cfi); + }; + pendingTTSContinueSectionRef.current = null; + bridgeRef.current.goToCFI(next.cfi); + pendingTTSContinueSafetyTimerRef.current = setTimeout(() => { + const cb = pendingTTSContinueCallbackRef.current; + if (pendingTTSContinueSectionRef.current !== null) { + console.warn("[ReaderTTS] section continuation timed out; waiting for target relocate", { + targetSection: pendingTTSContinueSectionRef.current, + }); + const targetSection = pendingTTSContinueSectionRef.current; + setTimeout(() => { + if (pendingTTSContinueSectionRef.current === targetSection) clearPending(); + }, 10000); + return; + } + clearPending(); + cb?.(); + }, 1500); return; } - if (raw.startsWith("http") || raw.startsWith("blob") || raw.startsWith("file")) { - setTtsCoverUri(raw); + const section = currentSectionIndex + 1; + if (bridgeRef.current?.goToSection && section < toc.length) { + const generation = ++generationRef.current; + clearPending(); + pendingTTSContinueCallbackRef.current = () => { + if (generation === generationRef.current) void startPageFromCfi(null); + }; + pendingTTSContinueSectionRef.current = section; + bridgeRef.current.goToSection(section); return; } + stop(); + }, [ + bridgeRef, + clearPending, + currentCfi, + currentSectionIndex, + loadContext, + startPageFromCfi, + stop, + toc.length, + ]); + handlePageEndRef.current = handlePageEnd; + useEffect(() => { + if (!bookCoverUrl) return setTtsCoverUri(undefined); + if (/^(https?|blob|file):/u.test(bookCoverUrl)) return setTtsCoverUri(bookCoverUrl); let cancelled = false; - (async () => { + void (async () => { try { - const platform = getPlatformService(); - const appData = await platform.getAppDataDir(); - const absPath = await platform.joinPath(appData, raw); - if (!cancelled) { - setTtsCoverUri(absPath); - } + const p = getPlatformService(); + const path = await p.joinPath(await p.getAppDataDir(), bookCoverUrl); + if (!cancelled) setTtsCoverUri(path); } catch { - if (!cancelled) { - setTtsCoverUri(undefined); - } + if (!cancelled) setTtsCoverUri(undefined); } })(); - return () => { cancelled = true; }; }, [bookCoverUrl]); - - // ─── Reset context caches when bookId changes ─────────────────────────────── + useEffect( + () => () => { + clearPending(); + if (useTTSStore.getState().currentBookId !== bookId) return; + setOnEnd(null); + stop(); + }, + [bookId, clearPending, setOnEnd, stop], + ); useEffect(() => { - const previousBookId = previousReaderBookIdRef.current; - previousReaderBookIdRef.current = bookId; - const switchedBook = !!previousBookId && previousBookId !== bookId; - - ttsContextCacheRef.current.clear(); - ttsContextInflightRef.current.clear(); - ttsPrefetchedChapterTransitionRef.current = null; - setTtsLastText(""); - setTtsSegments([]); - setTtsPrevPageSegments([]); - setTtsFutureSegments([]); - setTtsChunkOffset(0); - setTtsSourceKind("page"); - ttsSegmentsRef.current = []; - ttsPrevPageSegmentsRef.current = []; - ttsFutureSegmentsRef.current = []; - ttsLastTextRef.current = ""; - ttsChunkOffsetRef.current = 0; - ttsLoadMoreAboveRef.current = null; - ttsLoadMoreBelowRef.current = null; - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - lastTTSLyricPrimeSignatureRef.current = null; - didForceReapplyTTSHighlightRef.current = false; - ttsLyricPrimeRequestIdRef.current += 1; - lastFollowedTTSCfiRef.current = null; - ttsRecoveringLyricsRef.current = null; - ttsContinuousRef.current = false; - ttsHandlingPageEndRef.current = false; - ttsLastStopHandledSignatureRef.current = null; - ttsDynamicAppendRequestRef.current = null; - ttsStartChapterRef.current = ""; - pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } - if (switchedBook) { - setShowTTS(false); + const ownsActivePageSession = + currentBookId === bookId && + ttsSourceKind === "page" && + (playState === "playing" || playState === "paused" || playState === "loading"); + if (!ownsActivePageSession) { bridgeRef.current?.setTTSHighlight(null); + return; + } + const cfi = ttsSegments[currentChunkIndex]?.cfi || currentLocationCfi; + if (cfi) { + bridgeRef.current?.setTTSHighlight(cfi, TTS_READER_HIGHLIGHT_COLOR); + setCurrentLocation(cfi); } - }, [bookId, bridgeRef, setShowTTS]); - - // ─── Sync segment refs whenever state changes ─────────────────────────────── - useEffect(() => { - ttsSegmentsRef.current = ttsSegments; - ttsPrevPageSegmentsRef.current = ttsPrevPageSegments; - ttsLastTextRef.current = ttsLastText; - ttsFutureSegmentsRef.current = ttsFutureSegments; - }, [ttsFutureSegments, ttsLastText, ttsPrevPageSegments, ttsSegments]); - - useEffect(() => { - ttsChunkOffsetRef.current = ttsChunkOffset; - }, [ttsChunkOffset]); - - // ─── Derived values ───────────────────────────────────────────────────────── - const getTTSSegmentIdentity = useCallback( - (segment: TTSSegment | null | undefined) => - `${getTTSCfiStartIdentity(segment?.cfi)}::${normalizeTTSSegmentIdentityText(segment?.text)}`, - [], - ); - - const allLyricSegments = useMemo( - () => [...ttsPrevPageSegments, ...ttsSegments, ...ttsFutureSegments], - [ttsFutureSegments, ttsPrevPageSegments, ttsSegments], - ); - const ttsDisplaySegments = useMemo( - () => [...ttsSegments, ...ttsFutureSegments], - [ttsFutureSegments, ttsSegments], - ); - const localTTSChunkIndex = Math.max(0, ttsCurrentChunkIndex - ttsChunkOffset); - - const currentTTSSegment = useMemo(() => { - if (ttsCurrentBookId !== bookId) return null; - const normalizedCurrentText = (ttsCurrentSegmentText || ttsCurrentText || "") - .replace(/\s+/g, " ") - .trim(); - const normalizedSegmentText = (segment?: TTSSegment | null) => - (segment?.text || "").replace(/\s+/g, " ").trim(); - const indexSegment = - ttsSegments.length > 0 && localTTSChunkIndex >= 0 && localTTSChunkIndex < ttsSegments.length - ? ttsSegments[localTTSChunkIndex] || null - : null; - const cfiMatchedSegment = ttsCurrentLocationCfi - ? allLyricSegments.find((segment) => { - if (segment.cfi !== ttsCurrentLocationCfi) return false; - if ( - normalizedCurrentText && - (segment.text || "").replace(/\s+/g, " ").trim() !== normalizedCurrentText - ) { - return false; - } - return true; - }) || - allLyricSegments.find((segment) => segment.cfi === ttsCurrentLocationCfi) || - null - : null; - const textMatchedSegment = - normalizedCurrentText.length > 0 - ? allLyricSegments.find( - (segment) => normalizedSegmentText(segment) === normalizedCurrentText, - ) || null - : null; - - // The native player reports chunk index as the source of truth. CFI/text are - // useful fallbacks, but letting stale CFI win here makes highlight and lyric - // controls drift after dynamic append or quick jumps. - if (indexSegment) return indexSegment; - if (cfiMatchedSegment) return cfiMatchedSegment; - if (textMatchedSegment) return textMatchedSegment; - return null; }, [ - allLyricSegments, bookId, - localTTSChunkIndex, - ttsCurrentBookId, - ttsCurrentLocationCfi, - ttsCurrentSegmentText, - ttsCurrentText, + bridgeRef, + currentBookId, + currentChunkIndex, + currentLocationCfi, + playState, + setCurrentLocation, + ttsSourceKind, ttsSegments, ]); - const resolvedTTSSegmentCfi = useMemo(() => { - if (ttsCurrentBookId !== bookId) return null; - if (currentTTSSegment?.cfi) return currentTTSSegment.cfi; - return ttsCurrentLocationCfi || null; - }, [bookId, currentTTSSegment?.cfi, ttsCurrentBookId, ttsCurrentLocationCfi]); - - const ttsSourceLabel = ttsSourceKind === "selection" ? "来自选中文本" : "从当前页开始"; - - // ─── Utility callbacks ────────────────────────────────────────────────────── - const syncTTSChunkOffset = useCallback((nextOffset: number) => { - ttsChunkOffsetRef.current = nextOffset; - setTtsChunkOffset(nextOffset); - }, []); - - const resolveForegroundTTSCfi = useCallback(() => { - const state = useTTSStore.getState(); - if (state.currentBookId !== bookId) return null; - - const localIndex = state.currentChunkIndex - ttsChunkOffsetRef.current; - const indexSegment = - localIndex >= 0 && localIndex < ttsSegmentsRef.current.length - ? ttsSegmentsRef.current[localIndex] || null - : null; - - return ( - indexSegment?.cfi || state.currentLocationCfi || resolvedTTSSegmentCfi || currentCfi || null - ); - }, [bookId, currentCfi, resolvedTTSSegmentCfi]); - - const dedupeTTSSegments = useCallback( - (segments: TTSSegment[]) => { - const seenIdentities = new Set(); - const result: TTSSegment[] = []; - for (const segment of segments) { - if (!segment.text.trim()) continue; - const identity = getTTSSegmentIdentity(segment); - if (seenIdentities.has(identity)) continue; - seenIdentities.add(identity); - result.push(segment); - } - return result; + const startSelectionTTS = useCallback( + (text: string, cfi?: string | null) => { + const segments = splitNarrationText(text).map((value) => ({ text: value, cfi: cfi || "" })); + clearPending(); + generationRef.current += 1; + continuousRef.current = false; + sourceRef.current = "selection"; + resetLyrics(); + setTtsSourceKind("selection"); + setOnEnd(null); + commit(segments); + setTtsLastText(text.trim()); + setCurrentBook(bookTitle, currentChapter, bookId); + setCurrentLocation(cfi || currentCfi); + play(segments.map((s) => s.text)); + setShowTTS(true); }, - [getTTSSegmentIdentity], + [ + bookId, + bookTitle, + clearPending, + commit, + currentChapter, + currentCfi, + play, + setCurrentBook, + setCurrentLocation, + setOnEnd, + resetLyrics, + setShowTTS, + ], ); - - const filterDistinctTTSSegments = useCallback( - (incoming: TTSSegment[], ...existingGroups: TTSSegment[][]) => { - const blockedIdentities = new Set(); - for (const segment of existingGroups.flat()) { - blockedIdentities.add(getTTSSegmentIdentity(segment)); - } - const result: TTSSegment[] = []; - for (const segment of dedupeTTSSegments(incoming)) { - const identity = getTTSSegmentIdentity(segment); - if (blockedIdentities.has(identity)) continue; - blockedIdentities.add(identity); - result.push(segment); - } - return result; - }, - [dedupeTTSSegments, getTTSSegmentIdentity], + const startPage = useCallback(async () => { + clearPending(); + const generation = ++generationRef.current; + continuousRef.current = ttsContinuousEnabled; + const segments = dedupe((await bridgeRef.current?.getVisibleTTSSegments(null)) || []); + if (!segments.length || generation !== generationRef.current) return; + const context = await loadContext(segments[0]?.cfi || currentCfi || null); + if (generation !== generationRef.current) return; + sourceRef.current = "page"; + resetLyrics(); + setTtsSourceKind("page"); + commit(segments, context.before, context.after); + setTtsLastText(segments.map((s) => s.text).join(" ")); + setCurrentBook(bookTitle, currentChapter, bookId); + setCurrentLocation(segments[0]?.cfi || currentCfi); + setOnEnd(ttsContinuousEnabled ? handlePageEndRef.current : null); + play(segments.map((s) => s.text)); + preloadFollowingSegments(segments, context.after); + setShowTTS(true); + }, [ + bookId, + bookTitle, + bridgeRef, + clearPending, + commit, + currentChapter, + currentCfi, + loadContext, + play, + preloadFollowingSegments, + setCurrentBook, + setCurrentLocation, + setOnEnd, + resetLyrics, + setShowTTS, + ttsContinuousEnabled, + ]); + const handleToggleTTS = useCallback(async () => { + if (showTTS) return setShowTTS(false); + if (currentBookId === bookId && playState !== "stopped" && sourceRef.current === "page") + return setShowTTS(true); + await startPage(); + }, [bookId, currentBookId, playState, setShowTTS, showTTS, startPage]); + const handleTTSReplay = useCallback( + async () => + sourceRef.current === "selection" + ? startSelectionTTS(currentText || ttsLastText, currentLocationCfi) + : startPage(), + [currentLocationCfi, currentText, startPage, startSelectionTTS, ttsLastText], ); - - const areTTSSegmentListsEqual = useCallback((a: TTSSegment[], b: TTSSegment[]) => { - if (a === b) return true; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i += 1) { - if ((a[i]?.cfi || null) !== (b[i]?.cfi || null)) return false; - if ((a[i]?.text || "").trim() !== (b[i]?.text || "").trim()) return false; - } - return true; - }, []); - - const mergeUniqueTTSSegments = useCallback( - (base: TTSSegment[], incoming: TTSSegment[], direction: "prepend" | "append" = "append") => { - const ordered = direction === "prepend" ? [...incoming, ...base] : [...base, ...incoming]; - return dedupeTTSSegments(ordered); - }, - [dedupeTTSSegments], + const handleTTSPlayPause = useCallback(async () => { + if (playState === "playing" || playState === "loading") pause(); + else if (playState === "paused") resume(); + else if (sourceRef.current === "selection") + startSelectionTTS(currentText || ttsLastText, currentLocationCfi); + else await startPage(); + }, [ + currentLocationCfi, + currentText, + pause, + playState, + resume, + startPage, + startSelectionTTS, + ttsLastText, + ]); + const handleAdjustTTSRate = useCallback( + (delta: number) => + updateConfig({ + rate: Math.max( + 0.5, + Math.min(2, Number((normalizeTTSConfig(config).rate + delta).toFixed(2))), + ), + }), + [config, updateConfig], ); - - const normalizeExtractedTTSSegments = useCallback( - (segments: TTSSegment[]) => - dedupeTTSSegments( - (segments || []) - .map((segment) => ({ - text: segment.text.trim(), - cfi: segment.cfi, - })) - .filter((segment) => segment.text.length > 0), - ), - [dedupeTTSSegments], + const handleAdjustTTSPitch = useCallback( + (delta: number) => + updateConfig({ + pitch: Math.max( + 0.5, + Math.min(2, Number((normalizeTTSConfig(config).pitch + delta).toFixed(2))), + ), + }), + [config, updateConfig], ); - - const findReadableSectionTTSSegments = useCallback( - async ( - startSectionIndex: number, - direction: 1 | -1, - count = TTS_NEXT_CHAPTER_PREFETCH_SEGMENTS, - ) => { - if (!bridgeRef.current?.getSectionTTSSegments) return null; - const step = direction < 0 ? -1 : 1; - for (let attempt = 0; attempt < 10; attempt += 1) { - const sectionIndex = startSectionIndex + attempt * step; - if (sectionIndex < 0) break; - const segments = normalizeExtractedTTSSegments( - await bridgeRef.current.getSectionTTSSegments(sectionIndex, count), - ); - if (segments.length > 0) { - return { sectionIndex, segments }; - } - } - return null; - }, - [bridgeRef, normalizeExtractedTTSSegments], + const handleUpdateTTSConfig = useCallback( + (updates: Partial) => + updateConfig(mergeTTSConfigUpdates(normalizeTTSConfig(config), updates)), + [config, updateConfig], ); - - // ─── Context cache ────────────────────────────────────────────────────────── - const getCachedTTSSegmentContext = useCallback( - async ( - cfi: string | null | undefined, - before = TTS_CONTEXT_WINDOW, - after = TTS_CONTEXT_WINDOW, - ) => { - if (!cfi || !bridgeRef.current?.getTTSSegmentContext) { - return { before: [], after: [] }; - } - const key = `${cfi}|${before}|${after}`; - const cached = ttsContextCacheRef.current.get(key); - if (cached) { - ttsContextCacheRef.current.delete(key); - ttsContextCacheRef.current.set(key, cached); - return cached; + const handleToggleTTSContinuous = useCallback(() => { + const next = !continuousRef.current; + continuousRef.current = next; + setTtsContinuousEnabled(next); + setOnEnd(next && sourceRef.current === "page" ? handlePageEndRef.current : null); + }, [setOnEnd]); + const handleJumpToTTSSegment = useCallback( + (offset: number) => { + clearPending(); + generationRef.current += 1; + // TTSPage sends the active queue index (lyric index minus the previous + // context count), not a delta from the currently spoken chunk. + const index = offset; + if (index >= 0 && index < ttsSegments.length) { + jumpToChunk(index); + setCurrentLocation(ttsSegments[index]?.cfi); + return; } - - const inflight = ttsContextInflightRef.current.get(key); - if (inflight) return inflight; - - const request = bridgeRef.current - .getTTSSegmentContext(cfi, before, after) - .then((context) => { - const normalized = { - before: dedupeTTSSegments( - (context.before || []) - .map((segment) => ({ text: segment.text.trim(), cfi: segment.cfi })) - .filter((segment) => segment.text.length > 0), - ), - after: dedupeTTSSegments( - (context.after || []) - .map((segment) => ({ text: segment.text.trim(), cfi: segment.cfi })) - .filter((segment) => segment.text.length > 0), - ), - }; - ttsContextCacheRef.current.set(key, normalized); - while (ttsContextCacheRef.current.size > TTS_CONTEXT_CACHE_LIMIT) { - const oldestKey = ttsContextCacheRef.current.keys().next().value; - if (!oldestKey) break; - ttsContextCacheRef.current.delete(oldestKey); - } - return normalized; - }) - .finally(() => { - ttsContextInflightRef.current.delete(key); - }); - - ttsContextInflightRef.current.set(key, request); - return request; - }, - [bridgeRef, dedupeTTSSegments], - ); - - // ─── getNormalizedVisibleTTSSegments ─────────────────────────────────────── - const getNormalizedVisibleTTSSegments = useCallback( - async (alignCfi?: string | null) => { - const segmentCandidates = await bridgeRef.current?.getVisibleTTSSegments(alignCfi); - const segments = - segmentCandidates && segmentCandidates.length > 0 - ? segmentCandidates - : splitNarrationText((await bridgeRef.current?.getVisibleText()) || "").map( - (segmentText) => ({ - text: segmentText, - cfi: alignCfi || "", - }), - ); - - return segments - .map((segment) => ({ - text: segment.text.trim(), - cfi: segment.cfi, - })) - .filter((segment) => segment.text.length > 0); - }, - [bridgeRef], - ); - - // ─── logTTSExtractionDiagnostics ────────────────────────────────────────── - const logTTSExtractionDiagnostics = useCallback( - async ({ - reason, - alignCfi, - targetCfi, - targetText, - rawVisibleSegments, - playbackSegments, - }: { - reason: string; - alignCfi?: string | null; - targetCfi?: string | null; - targetText?: string | null; - rawVisibleSegments: TTSSegment[]; - playbackSegments: TTSSegment[]; - }) => { - if (!__DEV__) return; - if (!bridgeRef.current) return; - - try { - const [chapterParagraphs, visibleText] = await Promise.all([ - bridgeRef.current.getChapterParagraphs(), - bridgeRef.current.getVisibleText(), - ]); - const chapterText = chapterParagraphs - .map((paragraph: { id: string; text: string; tagName: string }) => - normalizeTTSDebugText(paragraph.text), - ) - .filter(Boolean) - .join("\n"); - const normalizedVisibleText = normalizeTTSDebugText(visibleText); - const pageSentences = splitNarrationText(normalizedVisibleText) - .map((sentence) => normalizeTTSDebugText(sentence)) - .filter(Boolean); - const normalizedRawVisibleSegments = normalizeTTSDebugSegments(rawVisibleSegments); - const normalizedPlaybackSegments = normalizeTTSDebugSegments(playbackSegments); - const missingFromRaw = collectMissingTTSDebugSentences( - pageSentences, - normalizedRawVisibleSegments, - ); - const missingFromPlayback = collectMissingTTSDebugSentences( - pageSentences, - normalizedPlaybackSegments, - ); - const pageFirstSentence = pageSentences[0] || null; - const rawFirstSentence = normalizedRawVisibleSegments[0]?.text || null; - const playbackFirstSentence = normalizedPlaybackSegments[0]?.text || null; - - console.log("[ReaderScreen][TTS][diagnostics] summary", { - reason, - chapterTitle: currentChapter, - currentCfi, - alignCfi: alignCfi || null, - targetCfi: targetCfi || null, - targetTextLength: normalizeTTSDebugText(targetText).length, - chapterParagraphCount: chapterParagraphs.length, - chapterTextLength: chapterText.length, - visibleTextLength: normalizedVisibleText.length, - pageSentenceCount: pageSentences.length, - rawVisibleSegmentCount: normalizedRawVisibleSegments.length, - playbackSegmentCount: normalizedPlaybackSegments.length, - missingFromRawCount: missingFromRaw.length, - missingFromPlaybackCount: missingFromPlayback.length, - pageFirstSentence, - rawFirstSentence, - playbackFirstSentence, - pageFirstIndexInRaw: findTTSDebugSentenceIndex( - pageFirstSentence, - normalizedRawVisibleSegments, - ), - pageFirstIndexInPlayback: findTTSDebugSentenceIndex( - pageFirstSentence, - normalizedPlaybackSegments, - ), - rawFirstCfi: normalizedRawVisibleSegments[0]?.cfi || null, - playbackFirstCfi: normalizedPlaybackSegments[0]?.cfi || null, - playbackLastCfi: - normalizedPlaybackSegments[normalizedPlaybackSegments.length - 1]?.cfi || null, - }); - - logTTSDebugText("[ReaderScreen][TTS][diagnostics] chapter-text", chapterText); - logTTSDebugText("[ReaderScreen][TTS][diagnostics] visible-text", normalizedVisibleText); - logTTSDebugSentenceList( - "[ReaderScreen][TTS][diagnostics] visible-sentences", - pageSentences, - ); - logTTSDebugSegments( - "[ReaderScreen][TTS][diagnostics] raw-visible-segments", - normalizedRawVisibleSegments, - ); - logTTSDebugSegments( - "[ReaderScreen][TTS][diagnostics] playback-segments", - normalizedPlaybackSegments, - ); - if (missingFromRaw.length > 0) { - logTTSDebugSentenceList( - "[ReaderScreen][TTS][diagnostics] missing-from-raw", - missingFromRaw, - ); - } - if (missingFromPlayback.length > 0) { - logTTSDebugSentenceList( - "[ReaderScreen][TTS][diagnostics] missing-from-playback", - missingFromPlayback, - ); - } - } catch (error) { - console.warn("[ReaderScreen][TTS][diagnostics] failed", { - reason, - error: error instanceof Error ? error.message : String(error), - }); + const jump = readingSessionRef.current.jump(offset); + if (jump.kind === "outside" && jump.target.cfi) { + const snapshot = jump.snapshot; + const next = snapshot.current as TTSSegment[]; + setTtsSegments(next); + setTtsPrevPageSegments(snapshot.previous as TTSSegment[]); + setTtsFutureSegments(snapshot.future as TTSSegment[]); + segmentsRef.current = next; + setCurrentLocation(jump.target.cfi); + void startPageFromCfi(jump.target.cfi); } }, - [bridgeRef, currentCfi, currentChapter], + [ + clearPending, + jumpToChunk, + setCurrentLocation, + startPageFromCfi, + ttsSegments, + ], ); - - // ─── primeTTSLyricContext ────────────────────────────────────────────────── - const primeTTSLyricContext = useCallback( - async ( - currentCfi: string | null | undefined, - firstCfi?: string | null, - lastCfi?: string | null, - ) => { - if (!currentCfi) return; - const requestId = ttsLyricPrimeRequestIdRef.current + 1; - ttsLyricPrimeRequestIdRef.current = requestId; - const [centerContext, leadingContext, trailingContext] = await Promise.all([ - getCachedTTSSegmentContext(currentCfi, TTS_CONTEXT_WINDOW, TTS_CONTEXT_WINDOW), - firstCfi - ? getCachedTTSSegmentContext(firstCfi, TTS_CONTEXT_WINDOW, 0) - : Promise.resolve({ before: [], after: [] }), - lastCfi - ? getCachedTTSSegmentContext(lastCfi, 0, TTS_CONTEXT_WINDOW) - : Promise.resolve({ before: [], after: [] }), - ]); - - const previous = filterDistinctTTSSegments( - [...(leadingContext.before || []), ...(centerContext.before || [])], - ttsSegmentsRef.current, - ).slice(-(TTS_CONTEXT_WINDOW * 2)); - - const future = filterDistinctTTSSegments( - [...(centerContext.after || []), ...(trailingContext.after || [])], - previous, - ttsSegmentsRef.current, - ).slice(0, TTS_CONTEXT_WINDOW * 2); - - if (ttsLyricPrimeRequestIdRef.current !== requestId) { + const handleJumpToTTSLyricSegment = useCallback( + (segment: { text: string; cfi?: string | null }, offset: number) => { + clearPending(); + generationRef.current += 1; + if (segment.cfi) { + const queueIndex = ttsSegments.findIndex((item) => item.cfi === segment.cfi); + if (queueIndex >= 0) jumpToChunk(queueIndex); + else void startPageFromCfi(segment.cfi); return; } - - setTtsPrevPageSegments((prev) => { - const next = mergeUniqueTTSSegments(prev, previous, "append"); - if (areTTSSegmentListsEqual(prev, next)) { - ttsPrevPageSegmentsRef.current = prev; - return prev; - } - ttsPrevPageSegmentsRef.current = next; - return next; - }); - setTtsFutureSegments((prev) => { - const next = mergeUniqueTTSSegments(prev, future, "append"); - if (areTTSSegmentListsEqual(prev, next)) { - ttsFutureSegmentsRef.current = prev; - return prev; - } - ttsFutureSegmentsRef.current = next; - return next; - }); + if (offset >= 0 && offset < ttsSegments.length) jumpToChunk(offset); }, - [ - areTTSSegmentListsEqual, - filterDistinctTTSSegments, - getCachedTTSSegmentContext, - mergeUniqueTTSSegments, - ], + [clearPending, jumpToChunk, startPageFromCfi, ttsSegments], ); - - const queueTTSChapterTransition = useCallback( - (targetIndex: number, options?: { autoResume?: boolean }) => { - if (!bridgeRef.current?.getSectionTTSSegments) return false; - const targetSectionIndex = Math.max(0, targetIndex); - ttsPrefetchedChapterTransitionRef.current = null; - - const autoResume = options?.autoResume !== false; - const nextChapterTitle = currentChapter; - - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; + const handleLoadMoreAboveTTSLyrics = useCallback(async () => { + const anchor = ttsPrevPageSegments[0]?.cfi || ttsSegments[0]?.cfi; + if (!anchor) return; + const context = await loadContext(anchor, 24, 0); + const previous = dedupe([...context.before, ...ttsPrevPageSegments]); + const lyrics = mergeTTSLyrics(lyricSegmentsRef.current, context.before, "above") as TTSSegment[]; + lyricSegmentsRef.current = lyrics; + setTtsLyricSegments(lyrics); + readingSessionRef.current.setContext( + previous.map((segment) => ({ text: segment.text, cfi: segment.cfi || null })), + ttsFutureSegments.map((segment) => ({ text: segment.text, cfi: segment.cfi || null })), + ); + setTtsPrevPageSegments(previous); + }, [loadContext, ttsFutureSegments, ttsPrevPageSegments, ttsSegments]); + const handleLoadMoreBelowTTSLyrics = useCallback(async () => { + const anchor = ttsFutureSegments.at(-1)?.cfi || ttsSegments.at(-1)?.cfi; + if (!anchor) return; + const context = await loadContext(anchor, 0, 24); + const future = dedupe([...ttsFutureSegments, ...context.after]); + const lyrics = mergeTTSLyrics(lyricSegmentsRef.current, context.after, "below") as TTSSegment[]; + lyricSegmentsRef.current = lyrics; + setTtsLyricSegments(lyrics); + readingSessionRef.current.setContext( + ttsPrevPageSegments.map((segment) => ({ text: segment.text, cfi: segment.cfi || null })), + future.map((segment) => ({ text: segment.text, cfi: segment.cfi || null })), + ); + setTtsFutureSegments(future); + }, [loadContext, ttsFutureSegments, ttsPrevPageSegments, ttsSegments]); + const queueChapterTransition = useCallback( + (index: number) => { + if (index < 0 || index >= toc.length) return; + const navigate = () => { + if (bridgeRef.current?.goToSection) bridgeRef.current.goToSection(index); + else if (toc[index]?.href) goToHref(toc[index].href); + }; + if (sourceRef.current !== "page" || playState === "stopped") { + navigate(); + return; } + clearPending(); + const generation = ++generationRef.current; pendingTTSContinueCallbackRef.current = () => { - pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } - - void (async () => { - const direction = targetSectionIndex < currentSectionIndex ? -1 : 1; - const readableSection = await findReadableSectionTTSSegments( - targetSectionIndex, - direction, - TTS_NEXT_CHAPTER_PREFETCH_SEGMENTS, - ); - const resolvedSectionIndex = readableSection?.sectionIndex ?? targetSectionIndex; - const normalizedSegments = readableSection?.segments || []; - if (__DEV__) { - console.log("[ReaderScreen][TTS][queue-transition] extracted-next-chapter", { - targetIndex: resolvedSectionIndex, - requestedSectionIndex: targetSectionIndex, - targetHref: `section:${resolvedSectionIndex}`, - nextChapterTitle, - normalizedSegmentsLength: normalizedSegments.length, - firstSentence: normalizeTTSDebugText(normalizedSegments[0]?.text), - secondSentence: normalizeTTSDebugText(normalizedSegments[1]?.text), - queuePreview: previewTTSQueue(normalizedSegments), - }); - } - if (normalizedSegments.length === 0) { - ttsContinuousRef.current = false; - ttsSetOnEnd(null); - ttsStop(); - return; - } - - const nextText = normalizedSegments - .map((segment) => segment.text) - .join(" ") - .trim(); - - setTtsSourceKind("page"); - setTtsContinuousEnabled(autoResume); - setTtsLastText(nextText); - setTtsSegments(normalizedSegments); - setTtsFutureSegments([]); - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsLyricPrimeRequestIdRef.current += 1; - ttsLastTextRef.current = nextText; - ttsSegmentsRef.current = normalizedSegments; - ttsFutureSegmentsRef.current = []; - syncTTSChunkOffset(0); - ttsStartChapterRef.current = nextChapterTitle; - ttsSetCurrentBook(bookTitle, nextChapterTitle, bookId, ttsCoverUri); - - const firstVisibleCfi = normalizedSegments[0]?.cfi || ""; - const lastVisibleCfi = - normalizedSegments[normalizedSegments.length - 1]?.cfi || firstVisibleCfi; - ttsSetCurrentLocation(firstVisibleCfi); - if (firstVisibleCfi) { - bridgeRef.current?.goToCFI(firstVisibleCfi); - } - - void primeTTSLyricContext(firstVisibleCfi, firstVisibleCfi, lastVisibleCfi); - - if (autoResume) { - ttsContinuousRef.current = true; - if (__DEV__) { - console.log("[ReaderScreen][TTS][queue-transition] play-next-chapter", { - targetHref: `section:${resolvedSectionIndex}`, - targetSectionIndex: resolvedSectionIndex, - requestedSectionIndex: targetSectionIndex, - firstCfi: firstVisibleCfi || null, - firstSentence: normalizeTTSDebugText(normalizedSegments[0]?.text), - secondSentence: normalizeTTSDebugText(normalizedSegments[1]?.text), - queuePreview: previewTTSQueue(normalizedSegments), - }); - } - ttsPlay(normalizedSegments.map((segment) => segment.text)); - } - })(); + if (generation !== generationRef.current) return; + void startPageFromCfi(null); }; - + pendingTTSContinueSectionRef.current = index; pendingTTSContinueSafetyTimerRef.current = setTimeout(() => { - const cb = pendingTTSContinueCallbackRef.current; - pendingTTSContinueCallbackRef.current = null; - if (cb) void cb(); - }, 1200); - - bridgeRef.current.setTTSHighlight(null); - bridgeRef.current.goToSection?.(targetSectionIndex); - return true; + const callback = pendingTTSContinueCallbackRef.current; + if (pendingTTSContinueSectionRef.current !== null) { + console.warn("[ReaderTTS] manual section continuation timed out; waiting for target relocate", { + targetSection: pendingTTSContinueSectionRef.current, + }); + const targetSection = pendingTTSContinueSectionRef.current; + setTimeout(() => { + if (pendingTTSContinueSectionRef.current === targetSection) clearPending(); + }, 10000); + return; + } + clearPending(); + callback?.(); + }, 1500); + stop(); + navigate(); }, - [ - bookId, - bookTitle, - bridgeRef, - currentChapter, - currentSectionIndex, - findReadableSectionTTSSegments, - primeTTSLyricContext, - syncTTSChunkOffset, - ttsCoverUri, - ttsPlay, - ttsSetCurrentBook, - ttsSetCurrentLocation, - ttsSetOnEnd, - ttsStop, - ], + [bridgeRef, clearPending, goToHref, playState, startPageFromCfi, stop, toc], ); - - const prefetchNextChapterTTSSegments = useCallback(async () => { - if (!bridgeRef.current?.getSectionTTSSegments) return []; - const requestedNextSectionIndex = currentSectionIndex >= 0 ? currentSectionIndex + 1 : -1; - if (requestedNextSectionIndex < 0) return []; - const readableSection = await findReadableSectionTTSSegments( - requestedNextSectionIndex, - 1, - TTS_NEXT_CHAPTER_PREFETCH_SEGMENTS, - ); - if (!readableSection) return []; - const nextChapterIndex = readableSection.sectionIndex; - const targetHref = `section:${nextChapterIndex}`; - - const existingTransition = ttsPrefetchedChapterTransitionRef.current; - if ( - existingTransition?.targetIndex === nextChapterIndex && - existingTransition.href === targetHref - ) { - return []; - } - - const appendSegments = filterDistinctTTSSegments( - readableSection.segments, - ttsPrevPageSegmentsRef.current, - ttsSegmentsRef.current, - ttsFutureSegmentsRef.current, - ).slice(0, TTS_NEXT_CHAPTER_PREFETCH_SEGMENTS); - - if (appendSegments.length === 0) return []; - - ttsPrefetchedChapterTransitionRef.current = { - startChunkIndex: ttsChunkOffsetRef.current + ttsSegmentsRef.current.length, - targetIndex: nextChapterIndex, - title: currentChapter, - href: targetHref, - firstCfi: appendSegments[0]?.cfi || "", - navigated: false, - }; - - if (__DEV__) { - console.log("[ReaderScreen][TTS] prefetched-next-chapter", { - targetIndex: nextChapterIndex, - requestedSectionIndex: requestedNextSectionIndex, - title: currentChapter, - segmentCount: appendSegments.length, - startChunkIndex: ttsPrefetchedChapterTransitionRef.current.startChunkIndex, - firstCfi: appendSegments[0]?.cfi || null, - firstSentence: normalizeTTSDebugText(appendSegments[0]?.text), - }); + const handleTTSPrevChapter = useCallback( + () => queueChapterTransition(Math.max(0, currentSectionIndex - 1)), + [currentSectionIndex, queueChapterTransition], + ); + const handleTTSNextChapter = useCallback( + () => queueChapterTransition(currentSectionIndex + 1), + [currentSectionIndex, queueChapterTransition], + ); + const handleTTSStop = useCallback(() => { + clearPending(); + continuousRef.current = false; + setOnEnd(null); + stop(); + setShowTTS(false); + }, [clearPending, setOnEnd, setShowTTS, stop]); + const handleTTSReturnToReading = useCallback(() => { + const cfi = ttsSegments[currentChunkIndex]?.cfi || currentLocationCfi || currentCfi; + setShowTTS(false); + if (cfi) { + bridgeRef.current?.goToCFI(cfi); + bridgeRef.current?.flashHighlight(cfi, colors.primary, 1000); } - - return appendSegments; }, [ bridgeRef, - currentChapter, - currentSectionIndex, - findReadableSectionTTSSegments, - filterDistinctTTSSegments, + colors.primary, + currentCfi, + currentChunkIndex, + currentLocationCfi, + setShowTTS, + ttsSegments, ]); - - // ─── recoverTTSLyricsState ──────────────────────────────────────────────── - const recoverTTSLyricsState = useCallback(async () => { - if (ttsSourceKind !== "page") return false; - if (ttsCurrentBookId !== bookId) return false; - const normalizedStoreSegmentText = normalizeTTSDebugText(ttsCurrentSegmentText); - const alignCfi = currentCfi || ttsCurrentLocationCfi || resolvedTTSSegmentCfi || null; - let anchorCfi = resolvedTTSSegmentCfi || ttsCurrentLocationCfi || currentCfi || null; - - if (normalizedStoreSegmentText && bridgeRef.current) { - try { - const visibleSegments = await getNormalizedVisibleTTSSegments(alignCfi); - const visibleIndex = visibleSegments.findIndex( - (segment) => normalizeTTSDebugText(segment.text) === normalizedStoreSegmentText, - ); - if (visibleIndex >= 0) { - const visibleSeed = visibleSegments.slice(visibleIndex); - const matchedVisible = visibleSeed[0] || null; - anchorCfi = matchedVisible?.cfi || anchorCfi; - const context = await getCachedTTSSegmentContext( - anchorCfi, - TTS_CONTEXT_WINDOW, - TTS_CONTEXT_WINDOW * 2, - ); - const previous = filterDistinctTTSSegments(context.before || [], visibleSeed).slice( - -(TTS_CONTEXT_WINDOW * 2), - ); - const future = filterDistinctTTSSegments( - context.after || [], - previous, - visibleSeed, - ).slice(0, TTS_CONTEXT_WINDOW * 2); - const nextText = - visibleSeed - .map((segment) => segment.text) - .join(" ") - .trim() || normalizedStoreSegmentText; - - if (__DEV__) { - console.log("[ReaderScreen][TTS] recover-visible-match", { - anchorCfi, - visibleSegmentsLength: visibleSegments.length, - visibleIndex, - recoveredSegmentsLength: visibleSeed.length, - previousLength: previous.length, - futureLength: future.length, - normalizedStoreSegmentTextLength: normalizedStoreSegmentText.length, - }); - } - - setTtsPrevPageSegments(previous); - setTtsSegments(visibleSeed); - setTtsFutureSegments(future); - setTtsLastText(nextText); - ttsPrevPageSegmentsRef.current = previous; - ttsSegmentsRef.current = visibleSeed; - ttsFutureSegmentsRef.current = future; - ttsLastTextRef.current = nextText; - syncTTSChunkOffset(ttsCurrentChunkIndex); - if (anchorCfi) { - ttsSetCurrentLocation(anchorCfi); - } - return true; - } - } catch (error) { - console.warn("[ReaderScreen][TTS] recover-visible-match failed", { - error: error instanceof Error ? error.message : String(error), - alignCfi, - anchorCfi, - }); - } - } - - if (!anchorCfi) return false; - - const seedSegments = (() => { - if (normalizedStoreSegmentText) { - return [{ text: normalizedStoreSegmentText, cfi: anchorCfi }]; - } - return splitNarrationText(ttsCurrentText || "") - .map((text) => text.trim()) - .filter(Boolean) - .map((text) => ({ text, cfi: anchorCfi })); - })(); - - const requestKey = `${anchorCfi}|${ttsCurrentChunkIndex}|${seedSegments[0]?.text || ""}`; - if (ttsRecoveringLyricsRef.current === requestKey) return false; - ttsRecoveringLyricsRef.current = requestKey; - - try { - const context = await getCachedTTSSegmentContext( - anchorCfi, - TTS_CONTEXT_WINDOW, - TTS_CONTEXT_WINDOW * 2, - ); - const nextSegments = - seedSegments.length > 0 - ? dedupeTTSSegments([ - ...seedSegments, - ...filterDistinctTTSSegments(context.after || [], seedSegments), - ]) - : dedupeTTSSegments(context.after || []); - const previous = filterDistinctTTSSegments(context.before || [], nextSegments).slice( - -(TTS_CONTEXT_WINDOW * 2), - ); - - if (!previous.length && !nextSegments.length) { - return false; - } - - const nextText = - nextSegments - .map((segment) => segment.text) - .join(" ") - .trim() || - (ttsCurrentText || "").trim() || - ttsLastTextRef.current; - - setTtsPrevPageSegments(previous); - setTtsSegments(nextSegments); - setTtsFutureSegments([]); - setTtsLastText(nextText); - ttsPrevPageSegmentsRef.current = previous; - ttsSegmentsRef.current = nextSegments; - ttsFutureSegmentsRef.current = []; - ttsLastTextRef.current = nextText; - syncTTSChunkOffset(ttsCurrentChunkIndex); - if (__DEV__) { - console.log("[ReaderScreen][TTS] recover-context-fallback", { - anchorCfi, - previousLength: previous.length, - nextSegmentsLength: nextSegments.length, - nextTextLength: nextText.length, - currentChunkIndex: ttsCurrentChunkIndex, - }); - } - return true; - } finally { - if (ttsRecoveringLyricsRef.current === requestKey) { - ttsRecoveringLyricsRef.current = null; - } - } - }, [ - bookId, - bridgeRef, - currentCfi, - dedupeTTSSegments, - filterDistinctTTSSegments, - getCachedTTSSegmentContext, - getNormalizedVisibleTTSSegments, - resolvedTTSSegmentCfi, - syncTTSChunkOffset, - ttsCurrentBookId, - ttsCurrentChunkIndex, - ttsCurrentLocationCfi, - ttsCurrentSegmentText, - ttsCurrentText, - ttsSetCurrentLocation, - ttsSourceKind, - ]); - - // ─── handleTTSPageEnd ───────────────────────────────────────────────────── - const handleTTSPageEnd = useCallback(() => { - const shouldContinue = ttsContinuousRef.current && ttsSourceKind === "page"; - console.log("[ReaderScreen][TTS] handle-page-end", { - shouldContinue, - continuousRef: ttsContinuousRef.current, - continuousEnabled: ttsContinuousEnabled, - sourceKind: ttsSourceKind, - currentChunkIndex: ttsCurrentChunkIndexRef.current, - totalChunks: ttsTotalChunksRef.current, - currentLocationCfi: ttsCurrentLocationCfiRef.current, - currentSegmentCfi: (currentTTSSegment as TTSSegment | null)?.cfi || null, - activeSegmentsLength: ttsSegmentsRef.current.length, - hasRestartFromCfi: !!startPageTTSFromCfiRef.current, - activeQueuePreview: previewTTSQueue(ttsSegmentsRef.current), - }); - if (!shouldContinue) return; - if (ttsHandlingPageEndRef.current) { - console.log("[ReaderScreen][TTS] handle-page-end skipped: already running"); - return; - } - ttsHandlingPageEndRef.current = true; - const previousSegments = ttsSegmentsRef.current; - const previousFirstCfi = - previousSegments[0]?.cfi || - (currentTTSSegment as TTSSegment | null)?.cfi || - currentCfi || - null; - const previousLastCfi = - previousSegments[previousSegments.length - 1]?.cfi || - (currentTTSSegment as TTSSegment | null)?.cfi || - previousFirstCfi; - const previousText = ttsLastTextRef.current; - console.log("[ReaderScreen][TTS] page end", { - previousFirstCfi, - previousLastCfi, - previousTextLength: previousText.length, - previousSegmentCount: previousSegments.length, - }); - - if (previousSegments.length > 0) { - setTtsPrevPageSegments((prev) => { - const next = mergeUniqueTTSSegments(prev, previousSegments, "append"); - ttsPrevPageSegmentsRef.current = next; - return next; - }); - } - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } - pendingTTSContinueCallbackRef.current = null; - - bridgeRef.current?.setTTSHighlight(null); - - void (async () => { - try { - if (!ttsContinuousRef.current) return; - const restartFromCfi = startPageTTSFromCfiRef.current; - const context = await getCachedTTSSegmentContext( - previousLastCfi, - 0, - TTS_CONTEXT_WINDOW * 2, - ); - const nextSegment = - (context.after || []).find((segment) => segment.text.trim().length > 0) || null; - console.log("[ReaderScreen][TTS] continue from context", { - previousLastCfi, - nextCfi: nextSegment?.cfi || null, - nextTextLength: nextSegment?.text?.length || 0, - nextText: normalizeTTSDebugText(nextSegment?.text), - afterCount: context.after?.length || 0, - afterPreview: previewTTSQueue(context.after || []), - }); - - if (nextSegment?.cfi && restartFromCfi) { - await restartFromCfi(nextSegment.cfi, nextSegment.text); - return; - } - - const nextChapterIndex = currentSectionIndex >= 0 ? currentSectionIndex + 1 : -1; - if ( - nextChapterIndex >= 0 && - queueTTSChapterTransition(nextChapterIndex, { autoResume: true }) - ) { - return; - } - - ttsContinuousRef.current = false; - ttsSetOnEnd(null); - ttsStop(); - } finally { - ttsHandlingPageEndRef.current = false; - } - })(); - }, [ - bridgeRef, - currentCfi, - currentTTSSegment, - getCachedTTSSegmentContext, - mergeUniqueTTSSegments, - currentSectionIndex, - queueTTSChapterTransition, - ttsContinuousEnabled, - ttsSetOnEnd, - ttsSourceKind, - ttsStop, - ]); - - // ─── startPageTTSFromCfi ────────────────────────────────────────────────── - const startPageTTSFromCfi = useCallback( - async (targetCfi: string, targetText?: string) => { - if (!targetCfi || !bridgeRef.current) return; - const normalizedTargetText = (targetText || "").trim(); - pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } - bridgeRef.current.goToCFI(targetCfi); - await new Promise((resolve) => setTimeout(resolve, 320)); - const normalizedSegments = await getNormalizedVisibleTTSSegments(targetCfi); - const context = await getCachedTTSSegmentContext( - targetCfi, - TTS_CONTEXT_WINDOW, - TTS_CONTEXT_WINDOW * 2, - ); - const exactVisibleIndex = normalizedSegments.findIndex((segment) => { - if (segment.cfi !== targetCfi) return false; - if (normalizedTargetText && segment.text.trim() !== normalizedTargetText) return false; - return true; - }); - const visibleIndexByCfi = - exactVisibleIndex >= 0 - ? exactVisibleIndex - : normalizedSegments.findIndex((segment) => segment.cfi === targetCfi); - const seedSegment = - normalizedTargetText.length > 0 ? [{ text: normalizedTargetText, cfi: targetCfi }] : []; - const visibleSegments = - visibleIndexByCfi >= 0 - ? normalizedSegments.slice(visibleIndexByCfi) - : dedupeTTSSegments([ - ...seedSegment, - ...filterDistinctTTSSegments(context.after || [], seedSegment), - ]); - if (!visibleSegments.length) return; - const previousContext = filterDistinctTTSSegments(context.before || [], visibleSegments); - const previous = mergeUniqueTTSSegments( - ttsPrevPageSegmentsRef.current, - previousContext, - "append", - ); - let future = filterDistinctTTSSegments(context.after || [], previous, visibleSegments); - let playbackSegments = visibleSegments; - if (ttsSourceKind === "page" && ttsContinuousEnabled) { - const frontLoadedFuture = future.slice(0, TTS_DYNAMIC_APPEND_SEGMENTS); - playbackSegments = mergeUniqueTTSSegments(visibleSegments, frontLoadedFuture, "append"); - future = filterDistinctTTSSegments(future, playbackSegments); - } - const nextText = playbackSegments - .map((segment) => segment.text) - .join(" ") - .trim(); - if (__DEV__) { - console.log("[ReaderScreen][TTS] start-from-cfi", { - targetCfi, - normalizedTargetTextLength: normalizedTargetText.length, - normalizedSegmentsLength: normalizedSegments.length, - exactVisibleIndex, - visibleIndexByCfi, - visibleSegmentsLength: visibleSegments.length, - playbackSegmentsLength: playbackSegments.length, - previousLength: previous.length, - futureLength: future.length, - firstVisibleCfi: visibleSegments[0]?.cfi || null, - lastVisibleCfi: visibleSegments[visibleSegments.length - 1]?.cfi || null, - firstVisibleText: normalizeTTSDebugText(visibleSegments[0]?.text), - }); - } - void logTTSExtractionDiagnostics({ - reason: "start-from-cfi", - alignCfi: targetCfi, - targetCfi, - targetText: normalizedTargetText, - rawVisibleSegments: normalizedSegments, - playbackSegments, - }); - ttsPrefetchedChapterTransitionRef.current = null; - setTtsSegments(playbackSegments); - setTtsPrevPageSegments(previous); - setTtsFutureSegments(future); - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsLyricPrimeRequestIdRef.current += 1; - setTtsLastText(nextText); - ttsSegmentsRef.current = playbackSegments; - ttsPrevPageSegmentsRef.current = previous; - ttsLastTextRef.current = nextText; - ttsFutureSegmentsRef.current = future; - syncTTSChunkOffset(0); - ttsSetCurrentLocation(playbackSegments[0]?.cfi || targetCfi); - ttsContinuousRef.current = ttsSourceKind === "page" && ttsContinuousEnabled; - ttsSetOnEnd(ttsContinuousRef.current ? handleTTSPageEnd : null); - ttsPlay(playbackSegments.map((segment) => segment.text)); - }, - [ - bridgeRef, - dedupeTTSSegments, - filterDistinctTTSSegments, - getCachedTTSSegmentContext, - getNormalizedVisibleTTSSegments, - handleTTSPageEnd, - logTTSExtractionDiagnostics, - mergeUniqueTTSSegments, - syncTTSChunkOffset, - ttsContinuousEnabled, - ttsPlay, - ttsSetCurrentLocation, - ttsSetOnEnd, - ttsSourceKind, - ], - ); - - useEffect(() => { - startPageTTSFromCfiRef.current = startPageTTSFromCfi; - }, [startPageTTSFromCfi]); - - // ─── startPageTTS ───────────────────────────────────────────────────────── - const startPageTTS = useCallback( - async (continuous = ttsContinuousEnabled) => { - // Start from the currently visible page, not from currentCfi. During fast - // page/chapter transitions currentCfi can still point at the previous - // relocation or a mid-page sentence, which makes TTS skip the real first - // visible sentence. - const normalizedSegments = await getNormalizedVisibleTTSSegments(null); - const pageAnchorCfi = normalizedSegments[0]?.cfi || currentCfi || null; - if (__DEV__) { - console.log("[ReaderScreen][TTS][queue-start] visible-page", { - currentChapter, - currentCfi, - pageAnchorCfi, - visibleSegmentsLength: normalizedSegments.length, - visibleFirstSentence: normalizeTTSDebugText(normalizedSegments[0]?.text), - visibleSecondSentence: normalizeTTSDebugText(normalizedSegments[1]?.text), - visibleQueuePreview: previewTTSQueue(normalizedSegments), - }); - } - const normalized = normalizedSegments - .map((segment) => segment.text) - .join(" ") - .trim(); - if (!normalized) return; - ttsPrefetchedChapterTransitionRef.current = null; - let playbackSegments = normalizedSegments; - let futureSegments: TTSSegment[] = []; - if (continuous) { - const lastCfi = - normalizedSegments[normalizedSegments.length - 1]?.cfi || - normalizedSegments[0]?.cfi || - pageAnchorCfi; - if (lastCfi) { - const context = await getCachedTTSSegmentContext(lastCfi, 0, TTS_DYNAMIC_APPEND_SEGMENTS); - futureSegments = filterDistinctTTSSegments(context.after || [], playbackSegments).slice( - 0, - TTS_DYNAMIC_APPEND_SEGMENTS, - ); - playbackSegments = mergeUniqueTTSSegments(playbackSegments, futureSegments, "append"); - - // Prime refs before optional chapter prefetch so the transition index - // matches the native queue we are about to hand to TrackPlayer. - ttsSegmentsRef.current = playbackSegments; - ttsPrevPageSegmentsRef.current = []; - ttsFutureSegmentsRef.current = []; - ttsChunkOffsetRef.current = 0; - - if (futureSegments.length < TTS_DYNAMIC_APPEND_THRESHOLD) { - const nextChapterSegments = await prefetchNextChapterTTSSegments(); - if (nextChapterSegments.length > 0) { - playbackSegments = mergeUniqueTTSSegments( - playbackSegments, - nextChapterSegments, - "append", - ); - } - } - - futureSegments = filterDistinctTTSSegments(futureSegments, playbackSegments); - } - } - const playbackText = playbackSegments - .map((segment) => segment.text) - .join(" ") - .trim(); - if (__DEV__) { - console.log("[ReaderScreen][TTS][queue-start] playback-page", { - pageAnchorCfi, - segmentCount: normalizedSegments.length, - playbackSegmentCount: playbackSegments.length, - firstCfi: normalizedSegments[0]?.cfi || null, - lastCfi: playbackSegments[playbackSegments.length - 1]?.cfi || null, - visibleFirstSentence: normalizeTTSDebugText(normalizedSegments[0]?.text), - playbackFirstSentence: normalizeTTSDebugText(playbackSegments[0]?.text), - playbackSecondSentence: normalizeTTSDebugText(playbackSegments[1]?.text), - playbackQueuePreview: previewTTSQueue(playbackSegments), - futureQueuePreview: previewTTSQueue(futureSegments), - }); - } - void logTTSExtractionDiagnostics({ - reason: "start-page", - alignCfi: pageAnchorCfi, - targetCfi: normalizedSegments[0]?.cfi || pageAnchorCfi, - targetText: normalizedSegments[0]?.text || null, - rawVisibleSegments: normalizedSegments, - playbackSegments, - }); - ttsStartChapterRef.current = currentChapter; - setTtsSourceKind("page"); - setTtsContinuousEnabled(continuous); - setTtsLastText(playbackText); - setTtsSegments(playbackSegments); - setTtsPrevPageSegments([]); - setTtsFutureSegments(futureSegments); - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsLyricPrimeRequestIdRef.current += 1; - ttsLastTextRef.current = playbackText; - ttsSegmentsRef.current = playbackSegments; - ttsPrevPageSegmentsRef.current = []; - ttsFutureSegmentsRef.current = futureSegments; - syncTTSChunkOffset(0); - ttsContinuousRef.current = continuous; - ttsSetOnEnd(continuous ? handleTTSPageEnd : null); - ttsSetCurrentBook(bookTitle, currentChapter, bookId, ttsCoverUri); - ttsSetCurrentLocation(playbackSegments[0]?.cfi || pageAnchorCfi); - setShowControls(false); - setShowTTS(true); - void primeTTSLyricContext( - normalizedSegments[0]?.cfi || pageAnchorCfi, - normalizedSegments[0]?.cfi || pageAnchorCfi, - normalizedSegments[normalizedSegments.length - 1]?.cfi || - normalizedSegments[0]?.cfi || - pageAnchorCfi, - ); - ttsPlay( - playbackSegments.length > 0 ? playbackSegments.map((segment) => segment.text) : normalized, - ); - }, - [ - bookId, - bookTitle, - currentChapter, - currentCfi, - filterDistinctTTSSegments, - getCachedTTSSegmentContext, - handleTTSPageEnd, - logTTSExtractionDiagnostics, - getNormalizedVisibleTTSSegments, - mergeUniqueTTSSegments, - prefetchNextChapterTTSSegments, - primeTTSLyricContext, - setShowControls, - setShowTTS, - syncTTSChunkOffset, - ttsContinuousEnabled, - ttsCoverUri, - ttsPlay, - ttsSetCurrentBook, - ttsSetCurrentLocation, - ttsSetOnEnd, - ], - ); - - // ─── startSelectionTTS ──────────────────────────────────────────────────── - const startSelectionTTS = useCallback( - (text: string, selectionCfi?: string | null) => { - const normalized = text.trim(); - if (!normalized) return; - const segments = splitNarrationText(normalized).map((segmentText) => ({ - text: segmentText, - cfi: selectionCfi || currentCfi, - })); - ttsPrefetchedChapterTransitionRef.current = null; - setTtsSourceKind("selection"); - setTtsContinuousEnabled(false); - setTtsLastText(normalized); - setTtsSegments(segments); - setTtsPrevPageSegments([]); - setTtsFutureSegments([]); - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsLyricPrimeRequestIdRef.current += 1; - ttsLastTextRef.current = normalized; - ttsSegmentsRef.current = segments; - ttsPrevPageSegmentsRef.current = []; - ttsFutureSegmentsRef.current = []; - syncTTSChunkOffset(0); - ttsContinuousRef.current = false; - ttsSetOnEnd(null); - ttsSetCurrentLocation(selectionCfi || currentCfi); - ttsSetCurrentBook(bookTitle, currentChapter, bookId, ttsCoverUri); - setShowControls(false); - ttsPlay(segments.length > 0 ? segments.map((segment) => segment.text) : normalized); - }, - [ - bookId, - bookTitle, - currentChapter, - currentCfi, - setShowControls, - syncTTSChunkOffset, - ttsCoverUri, - ttsPlay, - ttsSetCurrentBook, - ttsSetCurrentLocation, - ttsSetOnEnd, - ], - ); - - // ─── Public handlers ────────────────────────────────────────────────────── - const handleToggleTTS = useCallback(async () => { - if (showTTS) { - setShowTTS(false); - return; - } - - const hasActiveSession = - ttsCurrentBookId === bookId && - (ttsPlayState !== "stopped" || !!(ttsCurrentText || ttsLastText).trim()); - const isPlaying = ttsPlayState === "playing" || ttsPlayState === "loading"; - const chapterChanged = - ttsStartChapterRef.current !== "" && ttsStartChapterRef.current !== currentChapter; - // Selection sessions only hold the snippet the user picked; opening the - // full listen page should promote to a page-level session instead of - // surfacing a one-sentence queue. - const isSelectionSession = ttsSourceKind === "selection"; - - if (hasActiveSession && isPlaying && !chapterChanged && !isSelectionSession) { - setShowControls(false); - setShowTTS(true); - return; - } - - ttsStartChapterRef.current = currentChapter; - await startPageTTS(ttsContinuousEnabled); - }, [ - currentChapter, - bookId, - setShowControls, - setShowTTS, - showTTS, - startPageTTS, - ttsContinuousEnabled, - ttsCurrentBookId, - ttsCurrentText, - ttsLastText, - ttsPlayState, - ttsSourceKind, - ]); - - const handleTTSReplay = useCallback(async () => { - if (ttsSourceKind === "selection") { - const text = (ttsCurrentText || ttsLastText).trim(); - if (text) { - startSelectionTTS(text); - } - return; - } - await startPageTTS(ttsContinuousEnabled); - }, [ - startPageTTS, - startSelectionTTS, - ttsContinuousEnabled, - ttsCurrentText, - ttsLastText, - ttsSourceKind, - ]); - - const handleTTSPlayPause = useCallback(async () => { - if (ttsPlayState === "loading" || ttsPlayState === "playing") { - ttsPause(); - return; - } - if (ttsPlayState === "paused") { - ttsResume(); - return; - } - - if (ttsSourceKind === "selection") { - const text = (ttsCurrentText || ttsLastText).trim(); - if (text) { - startSelectionTTS(text); - } - return; - } - - await startPageTTS(ttsContinuousEnabled); - }, [ - startPageTTS, - startSelectionTTS, - ttsContinuousEnabled, - ttsCurrentText, - ttsLastText, - ttsPause, - ttsPlayState, - ttsResume, - ttsSourceKind, - ]); - - const handleAdjustTTSRate = useCallback( - (delta: number) => { - const nextRate = Math.max(0.5, Math.min(2, Math.round((ttsConfig.rate + delta) * 10) / 10)); - ttsUpdateConfig({ rate: nextRate }); - }, - [ttsConfig.rate, ttsUpdateConfig], - ); - - const handleAdjustTTSPitch = useCallback( - (delta: number) => { - const nextPitch = Math.max(0.5, Math.min(2, Math.round((ttsConfig.pitch + delta) * 10) / 10)); - ttsUpdateConfig({ pitch: nextPitch }); - }, - [ttsConfig.pitch, ttsUpdateConfig], - ); - - const handleUpdateTTSConfig = useCallback( - (updates: Partial) => { - const nextConfig = normalizeTTSConfig({ ...ttsConfig, ...updates }); - const hasActiveSession = - ttsCurrentBookId === bookId && - (ttsPlayState === "playing" || ttsPlayState === "paused" || ttsPlayState === "loading"); - const restartSourceKind = ttsSourceKind; - const restartSelectionText = (ttsCurrentText || ttsLastText).trim(); - const restartCfi = - currentTTSSegment?.cfi || resolvedTTSSegmentCfi || ttsCurrentLocationCfi || currentCfi; - const restartText = - currentTTSSegment?.text || normalizeTTSDebugText(ttsCurrentSegmentText) || undefined; - - console.log("[ReaderScreen][TTS] update-config", { - updates, - currentEngine: ttsConfig.engine, - nextEngine: nextConfig.engine, - hasActiveSession, - sourceKind: restartSourceKind, - restartCfi, - }); - - ttsUpdateConfig(updates); - - if (!hasActiveSession) return; - - setTimeout(() => { - if (restartSourceKind === "selection") { - if (restartSelectionText) { - startSelectionTTS(restartSelectionText, restartCfi || undefined); - } - return; - } - - const restartFromCfi = startPageTTSFromCfiRef.current; - if (restartFromCfi && restartCfi) { - void restartFromCfi(restartCfi, restartText); - return; - } - - void startPageTTS(ttsContinuousEnabled); - }, 0); - }, - [ - bookId, - currentCfi, - currentTTSSegment?.cfi, - currentTTSSegment?.text, - resolvedTTSSegmentCfi, - startPageTTS, - startSelectionTTS, - ttsConfig, - ttsContinuousEnabled, - ttsCurrentBookId, - ttsCurrentLocationCfi, - ttsCurrentSegmentText, - ttsCurrentText, - ttsLastText, - ttsPlayState, - ttsSourceKind, - ttsUpdateConfig, - ], - ); - - const handleToggleTTSContinuous = useCallback(() => { - setTtsContinuousEnabled((prev) => { - const next = !prev; - const shouldContinue = next && ttsSourceKind === "page"; - ttsContinuousRef.current = shouldContinue; - ttsSetOnEnd(shouldContinue ? handleTTSPageEnd : null); - return next; - }); - }, [handleTTSPageEnd, ttsSetOnEnd, ttsSourceKind]); - - const handleJumpToTTSSegment = useCallback( - (offsetFromCurrent = 0) => { - if (offsetFromCurrent < 0) { - const prevIndex = ttsPrevPageSegments.length + offsetFromCurrent; - const safeIdx = Math.max(0, prevIndex); - const fromPrev = ttsPrevPageSegments.slice(safeIdx); - const allSegments = [...fromPrev, ...ttsSegments]; - if (allSegments.length === 0) return; - const newPrev = ttsPrevPageSegments.slice(0, safeIdx); - const nextText = allSegments - .map((segment) => segment.text) - .join(" ") - .trim(); - const nextCfi = allSegments[0]?.cfi || currentCfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(allSegments); - setTtsLastText(nextText); - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsSegmentsRef.current = allSegments; - ttsPrevPageSegmentsRef.current = newPrev; - ttsLastTextRef.current = nextText; - syncTTSChunkOffset(0); - ttsSetCurrentLocation(nextCfi); - if (nextCfi) bridgeRef.current?.goToCFI(nextCfi); - setTimeout(() => ttsPlay(allSegments.map((s) => s.text)), 0); - } else { - const safeIdx = Math.max(0, Math.min(offsetFromCurrent, ttsSegments.length - 1)); - const sliced = ttsSegments.slice(safeIdx); - if (sliced.length === 0) return; - const newPrev = [...ttsPrevPageSegments, ...ttsSegments.slice(0, safeIdx)]; - const nextText = sliced - .map((segment) => segment.text) - .join(" ") - .trim(); - const nextCfi = sliced[0]?.cfi || currentCfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(sliced); - setTtsLastText(nextText); - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsSegmentsRef.current = sliced; - ttsPrevPageSegmentsRef.current = newPrev; - ttsLastTextRef.current = nextText; - syncTTSChunkOffset(0); - ttsSetCurrentLocation(nextCfi); - if (nextCfi) bridgeRef.current?.goToCFI(nextCfi); - setTimeout(() => ttsPlay(sliced.map((s) => s.text)), 0); - } - }, - [ - bridgeRef, - currentCfi, - syncTTSChunkOffset, - ttsPlay, - ttsPrevPageSegments, - ttsSegments, - ttsSetCurrentLocation, - ], + const ttsDisplaySegments = useMemo( + () => [...ttsSegments, ...ttsFutureSegments], + [ttsFutureSegments, ttsSegments], ); - - const handleJumpToTTSLyricSegment = useCallback( - (segment: { text: string; cfi?: string | null }, offsetFromCurrent: number) => { - if (offsetFromCurrent >= 0 && offsetFromCurrent < ttsSegments.length) { - ttsJumpToChunk(offsetFromCurrent); - if (segment.cfi) { - ttsSetCurrentLocation(segment.cfi); - bridgeRef.current?.goToCFI(segment.cfi); - bridgeRef.current?.setTTSHighlight(segment.cfi, ttsHighlightColor); - } - return; - } - - if (offsetFromCurrent < 0) { - const prevIndex = ttsPrevPageSegments.length + offsetFromCurrent; - const safeIdx = Math.max(0, prevIndex); - const fromPrev = ttsPrevPageSegments.slice(safeIdx); - const allSegments = [...fromPrev, ...ttsSegments, ...ttsFutureSegments]; - if (allSegments.length === 0) return; - const newPrev = ttsPrevPageSegments.slice(0, safeIdx); - const nextText = allSegments - .map((s) => s.text) - .join(" ") - .trim(); - const nextCfi = allSegments[0]?.cfi || currentCfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(allSegments); - setTtsFutureSegments([]); - setTtsLastText(nextText); - ttsLyricPrimeRequestIdRef.current += 1; - ttsSegmentsRef.current = allSegments; - ttsPrevPageSegmentsRef.current = newPrev; - ttsLastTextRef.current = nextText; - ttsFutureSegmentsRef.current = []; - syncTTSChunkOffset(0); - ttsSetCurrentLocation(nextCfi); - if (nextCfi) bridgeRef.current?.goToCFI(nextCfi); - ttsPlay(allSegments.map((s) => s.text)); - return; - } - - const futureOffset = offsetFromCurrent - ttsSegments.length; - if (futureOffset >= 0 && futureOffset < ttsFutureSegments.length) { - const consumedCurrent = ttsSegments; - const skippedFuture = ttsFutureSegments.slice(0, futureOffset); - const remainingFuture = ttsFutureSegments.slice(futureOffset); - const newPrev = [...ttsPrevPageSegments, ...consumedCurrent, ...skippedFuture]; - const nextText = remainingFuture - .map((s) => s.text) - .join(" ") - .trim(); - const nextCfi = remainingFuture[0]?.cfi || currentCfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(remainingFuture); - setTtsFutureSegments([]); - setTtsLastText(nextText); - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsLyricPrimeRequestIdRef.current += 1; - ttsSegmentsRef.current = remainingFuture; - ttsPrevPageSegmentsRef.current = newPrev; - ttsLastTextRef.current = nextText; - ttsFutureSegmentsRef.current = []; - syncTTSChunkOffset(0); - ttsSetCurrentLocation(nextCfi); - if (nextCfi) bridgeRef.current?.goToCFI(nextCfi); - ttsPlay(remainingFuture.map((s) => s.text)); - void primeTTSLyricContext( - remainingFuture[0]?.cfi || nextCfi, - remainingFuture[0]?.cfi || nextCfi, - remainingFuture[remainingFuture.length - 1]?.cfi || nextCfi, - ); - return; - } - - if (segment.cfi) { - void startPageTTSFromCfi(segment.cfi, segment.text); - } - }, - [ - bridgeRef, - currentCfi, - primeTTSLyricContext, - startPageTTSFromCfi, - syncTTSChunkOffset, - ttsJumpToChunk, - ttsPlay, - ttsPrevPageSegments, - ttsSegments, - ttsFutureSegments, - ttsSetCurrentLocation, - ], + const allLyricSegments = useMemo( + () => ttsLyricSegments, + [ttsLyricSegments], ); - - const handleLoadMoreAboveTTSLyrics = useCallback(async () => { - const anchorCfi = ttsPrevPageSegments[0]?.cfi || ttsSegments[0]?.cfi || null; - if ( - !anchorCfi || - ttsLoadMoreAboveRef.current === anchorCfi || - ttsExhaustedAboveAnchorsRef.current.has(anchorCfi) - ) { - return; - } - ttsLoadMoreAboveRef.current = anchorCfi; - try { - const context = await getCachedTTSSegmentContext(anchorCfi, TTS_CONTEXT_WINDOW, 0); - if (context.before?.length) { - const incoming = filterDistinctTTSSegments( - context.before, - ttsPrevPageSegments, - ttsSegments, - ); - if (incoming.length > 0) { - ttsExhaustedAboveAnchorsRef.current.delete(anchorCfi); - setTtsPrevPageSegments((prev) => { - const next = mergeUniqueTTSSegments(prev, incoming, "prepend"); - ttsPrevPageSegmentsRef.current = next; - return next; - }); - } else { - ttsExhaustedAboveAnchorsRef.current.add(anchorCfi); - } - } else { - ttsExhaustedAboveAnchorsRef.current.add(anchorCfi); - } - } finally { - ttsLoadMoreAboveRef.current = null; - } - }, [ - filterDistinctTTSSegments, - getCachedTTSSegmentContext, - mergeUniqueTTSSegments, - ttsPrevPageSegments, - ttsSegments, - ]); - - const handleLoadMoreBelowTTSLyrics = useCallback(async () => { - const anchorCfi = - ttsFutureSegments[ttsFutureSegments.length - 1]?.cfi || - ttsSegments[ttsSegments.length - 1]?.cfi || - null; - if ( - !anchorCfi || - ttsLoadMoreBelowRef.current === anchorCfi || - ttsExhaustedBelowAnchorsRef.current.has(anchorCfi) - ) { - return; - } - ttsLoadMoreBelowRef.current = anchorCfi; - try { - const context = await getCachedTTSSegmentContext(anchorCfi, 0, TTS_CONTEXT_WINDOW); - if (context.after?.length) { - const incoming = filterDistinctTTSSegments( - context.after, - ttsPrevPageSegments, - ttsSegments, - ttsFutureSegments, - ); - if (incoming.length > 0) { - ttsExhaustedBelowAnchorsRef.current.delete(anchorCfi); - setTtsFutureSegments((prev) => { - const next = mergeUniqueTTSSegments(prev, incoming, "append"); - ttsFutureSegmentsRef.current = next; - return next; - }); - } else { - ttsExhaustedBelowAnchorsRef.current.add(anchorCfi); - } - } else { - ttsExhaustedBelowAnchorsRef.current.add(anchorCfi); - } - } finally { - ttsLoadMoreBelowRef.current = null; - } - }, [ - filterDistinctTTSSegments, - getCachedTTSSegmentContext, - mergeUniqueTTSSegments, - ttsFutureSegments, - ttsPrevPageSegments, - ttsSegments, - ]); - - const handleTTSPrevChapter = useCallback(() => { - const prevIdx = currentSectionIndex > 0 ? currentSectionIndex - 1 : 0; - if (ttsSourceKind === "page" && ttsPlayState !== "stopped") { - queueTTSChapterTransition(prevIdx, { autoResume: true }); - return; - } - if (bridgeRef.current?.goToSection) { - bridgeRef.current.goToSection(prevIdx); - return; - } - const prevHref = toc[prevIdx]?.href; - if (prevHref) goToHref(prevHref); - }, [ - bridgeRef, - currentSectionIndex, - goToHref, - queueTTSChapterTransition, - toc, - ttsPlayState, - ttsSourceKind, - ]); - - const handleTTSNextChapter = useCallback(() => { - const nextIdx = currentSectionIndex >= 0 ? currentSectionIndex + 1 : 0; - if (ttsSourceKind === "page" && ttsPlayState !== "stopped") { - queueTTSChapterTransition(nextIdx, { autoResume: true }); - return; - } - if (bridgeRef.current?.goToSection) { - bridgeRef.current.goToSection(nextIdx); - return; - } - const nextHref = toc[nextIdx]?.href; - if (nextHref) goToHref(nextHref); - }, [ - bridgeRef, - currentSectionIndex, - goToHref, - queueTTSChapterTransition, - toc, - ttsPlayState, - ttsSourceKind, - ]); - - const handleTTSStop = useCallback(() => { - ttsContinuousRef.current = false; - ttsPrefetchedChapterTransitionRef.current = null; - ttsSetOnEnd(null); - bridgeRef.current?.setTTSHighlight(null); - lastFollowedTTSCfiRef.current = null; - setTtsSegments([]); - ttsSegmentsRef.current = []; - setTtsPrevPageSegments([]); - setTtsFutureSegments([]); - ttsPrevPageSegmentsRef.current = []; - ttsExhaustedAboveAnchorsRef.current.clear(); - ttsExhaustedBelowAnchorsRef.current.clear(); - ttsLyricPrimeRequestIdRef.current += 1; - ttsFutureSegmentsRef.current = []; - syncTTSChunkOffset(0); - setTtsLastText(""); - ttsLastTextRef.current = ""; - ttsStop(); - }, [bridgeRef, syncTTSChunkOffset, ttsSetOnEnd, ttsStop]); - - const handleTTSReturnToReading = useCallback(() => { - const targetCfi = resolvedTTSSegmentCfi || ttsCurrentLocationCfi || currentCfi; - setShowTTS(false); - if (!targetCfi) return; - lastFollowedTTSCfiRef.current = targetCfi; - bridgeRef.current?.goToCFI(targetCfi); - bridgeRef.current?.flashHighlight(targetCfi, colors.primary, 1200); - setTimeout(() => { - bridgeRef.current?.setTTSHighlight(targetCfi, ttsHighlightColor, true); - }, 120); - }, [ - bridgeRef, - colors.primary, - currentCfi, - resolvedTTSSegmentCfi, - setShowTTS, - ttsCurrentLocationCfi, - ]); - - // ─── TTS event bus listeners ─────────────────────────────────────────────── - useEffect(() => { - return eventBus.on( - "tts:jump-to-current", - ({ bookId: targetBookId, cfi: targetCfi, respond }) => { - if (targetBookId !== bookId || !targetCfi) return; - setShowTTS(false); - bridgeRef.current?.goToCFI(targetCfi); - bridgeRef.current?.flashHighlight(targetCfi, colors.primary, 1200); - respond?.(); - }, - ); - }, [bookId, bridgeRef, colors.primary, setShowTTS]); - - useEffect(() => { - return eventBus.on("tts:open-lyrics-page", ({ bookId: targetBookId, respond }) => { - if (targetBookId !== bookId) return; - setShowTTS(true); - respond?.(); - }); - }, [bookId, setShowTTS]); - - // ─── TTS highlight sync effects ─────────────────────────────────────────── - useEffect(() => { - if (!webViewReady) return; - if (ttsCurrentBookId !== bookId) { - bridgeRef.current?.setTTSHighlight(null); - return; - } - if (showTTS) { - bridgeRef.current?.setTTSHighlight(null); - return; - } - if (ttsPlayState !== "playing" && ttsPlayState !== "paused" && ttsPlayState !== "loading") { - bridgeRef.current?.setTTSHighlight(null); - return; - } - if (ttsSourceKind !== "page") { - bridgeRef.current?.setTTSHighlight(null); - return; - } - const targetCfi = resolvedTTSSegmentCfi; - if (!targetCfi) { - bridgeRef.current?.setTTSHighlight(null); - return; - } - bridgeRef.current?.setTTSHighlight(targetCfi, ttsHighlightColor); - }, [ - bridgeRef, - resolvedTTSSegmentCfi, - showTTS, - ttsCurrentBookId, - ttsPlayState, - ttsSourceKind, - webViewReady, - bookId, - ]); - - useEffect(() => { - if (!webViewReady) { - didForceReapplyTTSHighlightRef.current = false; - return; - } - if (ttsCurrentBookId !== bookId) { - didForceReapplyTTSHighlightRef.current = false; - return; - } - if (showTTS) { - didForceReapplyTTSHighlightRef.current = false; - return; - } - if (didForceReapplyTTSHighlightRef.current) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState !== "playing" && ttsPlayState !== "paused" && ttsPlayState !== "loading") - return; - const targetCfi = resolvedTTSSegmentCfi; - if (!targetCfi) return; - bridgeRef.current?.setTTSHighlight(targetCfi, ttsHighlightColor, true); - didForceReapplyTTSHighlightRef.current = true; - }, [ - bridgeRef, - resolvedTTSSegmentCfi, - showTTS, - ttsCurrentBookId, - ttsPlayState, - ttsSourceKind, - webViewReady, - bookId, - ]); - - useEffect(() => { - if (!webViewReady || showTTS) return; - if (ttsCurrentBookId !== bookId) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState !== "playing" && ttsPlayState !== "paused" && ttsPlayState !== "loading") - return; - const targetCfi = resolvedTTSSegmentCfi || currentCfi || null; - if (!targetCfi) return; - const timer = setTimeout(() => { - bridgeRef.current?.setTTSHighlight(targetCfi, ttsHighlightColor, true); - }, 180); - return () => clearTimeout(timer); - }, [ - bridgeRef, - currentCfi, - resolvedTTSSegmentCfi, - showTTS, - ttsCurrentBookId, - ttsPlayState, - ttsSourceKind, - webViewReady, - bookId, - ]); - - // ─── Foreground resync: force highlight update after returning from background ── - // When the OS suspends the app, React effects don't run but native audio can continue. - // On return to foreground, force-resync the WebView highlight to current TTS position. - useEffect(() => { - const handleAppStateChange = (nextState: AppStateStatus) => { - if (nextState !== "active") return; - if (!webViewReady || !bridgeRef.current) return; - if (ttsCurrentBookId !== bookId) return; - if (ttsPlayState !== "playing" && ttsPlayState !== "paused") return; - if (ttsSourceKind !== "page") return; - - // Small delay to let WebView fully resume before injecting JS - setTimeout(() => { - const targetCfi = resolveForegroundTTSCfi(); - if (!targetCfi) return; - bridgeRef.current?.setTTSHighlight(targetCfi, ttsHighlightColor, true); - // Also navigate to the current TTS position if user scrolled away - bridgeRef.current?.goToCFI(targetCfi); - }, 450); - }; - - const subscription = AppState.addEventListener("change", handleAppStateChange); - return () => subscription.remove(); - }, [ - bookId, - bridgeRef, - resolveForegroundTTSCfi, - ttsCurrentBookId, - ttsPlayState, - ttsSourceKind, - webViewReady, - ]); - - useEffect(() => { - if (!webViewReady) { - lastFollowedTTSCfiRef.current = null; - return; - } - if (ttsCurrentBookId !== bookId) { - lastFollowedTTSCfiRef.current = null; - return; - } - if (showTTS || ttsSourceKind !== "page") { - lastFollowedTTSCfiRef.current = null; - return; - } - if (ttsPlayState !== "playing" && ttsPlayState !== "loading") { - lastFollowedTTSCfiRef.current = null; - return; - } - const targetCfi = resolvedTTSSegmentCfi; - if (!targetCfi || lastFollowedTTSCfiRef.current === targetCfi) return; - lastFollowedTTSCfiRef.current = targetCfi; - bridgeRef.current?.goToCFI(targetCfi); - }, [ - bridgeRef, - resolvedTTSSegmentCfi, - showTTS, - ttsCurrentBookId, - ttsPlayState, - ttsSourceKind, - webViewReady, - bookId, - ]); - - useEffect(() => { - if (ttsCurrentBookId !== bookId) return; - if (ttsSourceKind === "page") { - const spokenCfi = resolvedTTSSegmentCfi; - if (__DEV__ && spokenCfi && spokenCfi !== ttsCurrentLocationCfi) { - console.log("[ReaderScreen][TTS] update-current-location", { - currentChunkIndex: ttsCurrentChunkIndex, - ttsChunkOffset, - localTTSChunkIndex, - spokenCfi, - previousLocationCfi: ttsCurrentLocationCfi, - ttsSegmentsLength: ttsSegments.length, - }); - } - if (spokenCfi && spokenCfi !== ttsCurrentLocationCfi) { - ttsSetCurrentLocation(spokenCfi); - } - return; - } - - if (currentCfi && currentCfi !== ttsCurrentLocationCfi) { - ttsSetCurrentLocation(currentCfi); - } - }, [ - bookId, - currentCfi, - localTTSChunkIndex, - resolvedTTSSegmentCfi, - ttsChunkOffset, - ttsCurrentBookId, - ttsCurrentChunkIndex, - ttsCurrentLocationCfi, - ttsSegments.length, - ttsSetCurrentLocation, - ttsSourceKind, - ]); - - // ─── DEV-only debug effects ─────────────────────────────────────────────── - useEffect(() => { - if (!__DEV__) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState === "stopped") return; - console.log("[ReaderScreen][TTS] segment-resolution", { - currentChunkIndex: ttsCurrentChunkIndex, - ttsChunkOffset, - localTTSChunkIndex, - resolvedTTSSegmentCfi, - queueSentenceAtLocalIndex: normalizeTTSDebugText(ttsSegments[localTTSChunkIndex]?.text), - queueNextSentence: normalizeTTSDebugText(ttsSegments[localTTSChunkIndex + 1]?.text), - currentSegmentText: normalizeTTSDebugText(currentTTSSegment?.text), - storeSegmentText: normalizeTTSDebugText(ttsCurrentSegmentText), - currentLocationCfi: ttsCurrentLocationCfi, - ttsSegmentsLength: ttsSegments.length, - prevSegmentsLength: ttsPrevPageSegments.length, - futureSegmentsLength: ttsFutureSegments.length, - }); - }, [ - currentTTSSegment?.text, - localTTSChunkIndex, - resolvedTTSSegmentCfi, - ttsChunkOffset, - ttsCurrentChunkIndex, - ttsCurrentLocationCfi, - ttsCurrentSegmentText, - ttsFutureSegments.length, - ttsPlayState, - ttsPrevPageSegments.length, - ttsSegments, - ttsSegments.length, - ttsSourceKind, - ]); - - useEffect(() => { - if (!__DEV__) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState === "stopped") return; - if (ttsSegments.length === 0) return; - if (localTTSChunkIndex >= 0 && localTTSChunkIndex < ttsSegments.length) return; - console.log("[ReaderScreen][TTS] chunk-out-of-range", { - currentChunkIndex: ttsCurrentChunkIndex, - ttsChunkOffset, - localTTSChunkIndex, - ttsSegmentsLength: ttsSegments.length, - ttsFutureSegmentsLength: ttsFutureSegments.length, - currentLocationCfi: ttsCurrentLocationCfi, - currentSegmentTextLength: (ttsCurrentSegmentText || "").length, - }); - }, [ - localTTSChunkIndex, - ttsChunkOffset, - ttsCurrentChunkIndex, - ttsCurrentLocationCfi, - ttsCurrentSegmentText, - ttsFutureSegments.length, - ttsPlayState, - ttsSegments.length, - ttsSourceKind, - ]); - - // ─── Continuous reading effects ─────────────────────────────────────────── - useEffect(() => { - const shouldKeepContinuousIntent = - ttsCurrentBookId === bookId && ttsSourceKind === "page" && ttsContinuousEnabled; - const shouldInstallEndHandler = - shouldKeepContinuousIntent && - (ttsPlayState === "playing" || ttsPlayState === "paused" || ttsPlayState === "loading"); - ttsContinuousRef.current = shouldKeepContinuousIntent; - ttsSetOnEnd(shouldInstallEndHandler ? handleTTSPageEnd : null); - }, [ - bookId, - handleTTSPageEnd, - ttsContinuousEnabled, - ttsCurrentBookId, - ttsPlayState, - ttsSetOnEnd, - ttsSourceKind, - ]); - - const ttsDynamicAppendTrigger = `${ttsCurrentChunkIndex}:${ttsSegments.length}`; - - // ─── Dynamically append next-page context near the end ───────────────────── - // Keep the native playback queue fed before the visible page runs out. This - // avoids the audible gap where we used to wait for onEnd and then restart TTS. - useEffect(() => { - if (!ttsContinuousRef.current) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState !== "playing") return; - const segments = ttsSegmentsRef.current; - if (segments.length === 0) return; - const localIndex = Math.max(0, ttsCurrentChunkIndexRef.current - ttsChunkOffset); - const remaining = segments.length - 1 - localIndex; - if (remaining > TTS_DYNAMIC_APPEND_THRESHOLD) return; - const lastCfi = segments[segments.length - 1]?.cfi; - if (!lastCfi) return; - - const requestKey = `${bookId}|${lastCfi}|${ttsDynamicAppendTrigger}|${ttsFutureSegmentsRef.current.length}`; - if (ttsDynamicAppendRequestRef.current === requestKey) return; - ttsDynamicAppendRequestRef.current = requestKey; - const requestId = ttsLyricPrimeRequestIdRef.current; - - void (async () => { - try { - const context = await getCachedTTSSegmentContext(lastCfi, 0, TTS_DYNAMIC_APPEND_SEGMENTS); - if (ttsLyricPrimeRequestIdRef.current !== requestId || !ttsContinuousRef.current) { - return; - } - - const candidates = [ - ...ttsFutureSegmentsRef.current, - ...((context.after || []) as TTSSegment[]), - ]; - let appendSegments = filterDistinctTTSSegments( - candidates, - ttsPrevPageSegmentsRef.current, - ttsSegmentsRef.current, - ).slice(0, TTS_DYNAMIC_APPEND_SEGMENTS); - - if (appendSegments.length === 0) { - appendSegments = await prefetchNextChapterTTSSegments(); - } - - if (appendSegments.length === 0) { - if (__DEV__) { - console.log("[ReaderScreen][TTS][queue-append] no-append-segments", { - lastCfi, - contextAfterLength: context.after?.length || 0, - }); - } - return; - } - - if (__DEV__) { - console.log("[ReaderScreen][TTS][queue-append] appending", { - lastCfi, - currentChunkIndex: ttsCurrentChunkIndexRef.current, - activeQueueLength: ttsSegmentsRef.current.length, - appendSegmentsLength: appendSegments.length, - appendFirstSentence: normalizeTTSDebugText(appendSegments[0]?.text), - appendSecondSentence: normalizeTTSDebugText(appendSegments[1]?.text), - appendPreview: previewTTSQueue(appendSegments), - }); - } - - const didAppend = ttsAppend(appendSegments.map((segment) => segment.text)); - if (__DEV__) { - console.log("[ReaderScreen][TTS][queue-append] append-result", { - didAppend, - appendSegmentsLength: appendSegments.length, - }); - } - if (!didAppend) { - setTtsFutureSegments((prev) => { - const next = mergeUniqueTTSSegments(prev, appendSegments, "append"); - ttsFutureSegmentsRef.current = next; - return next; - }); - return; - } - - setTtsSegments((prev) => { - // appendSegments has already been filtered against the active queue. - // Preserve exact order/length parity with the native player queue. - const next = [...prev, ...appendSegments]; - ttsSegmentsRef.current = next; - return next; - }); - setTtsFutureSegments((prev) => { - const next = filterDistinctTTSSegments(prev, appendSegments); - ttsFutureSegmentsRef.current = next; - return next; - }); - setTtsLastText((prev) => { - const appendedText = appendSegments - .map((segment) => segment.text) - .join(" ") - .trim(); - const next = [prev, appendedText].filter(Boolean).join(" ").trim(); - ttsLastTextRef.current = next; - return next; - }); - } catch (error) { - console.warn("[ReaderScreen][TTS] dynamic append failed", { - lastCfi, - error: error instanceof Error ? error.message : String(error), - }); - } finally { - if (ttsDynamicAppendRequestRef.current === requestKey) { - ttsDynamicAppendRequestRef.current = null; - } - } - })(); - }, [ - bookId, - filterDistinctTTSSegments, - getCachedTTSSegmentContext, - mergeUniqueTTSSegments, - prefetchNextChapterTTSSegments, - ttsAppend, - ttsChunkOffset, - ttsDynamicAppendTrigger, - ttsPlayState, - ttsSourceKind, - ]); - - // When the dynamic queue has already appended the next chapter, keep the - // reader view and metadata in step with playback instead of waiting for onEnd. - useEffect(() => { - const transition = ttsPrefetchedChapterTransitionRef.current; - if (!transition || transition.navigated) return; - if (ttsCurrentBookId !== bookId) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState !== "playing" && ttsPlayState !== "loading") return; - if (ttsCurrentChunkIndex < transition.startChunkIndex) return; - - const targetCfi = currentTTSSegment?.cfi || transition.firstCfi; - transition.navigated = true; - ttsStartChapterRef.current = transition.title; - ttsSetCurrentBook(bookTitle, transition.title, bookId, ttsCoverUri); - if (targetCfi) { - ttsSetCurrentLocation(targetCfi); - bridgeRef.current?.goToCFI(targetCfi); - } else if (transition.href.startsWith("section:")) { - const targetSectionIndex = Number(transition.href.slice("section:".length)); - if (Number.isInteger(targetSectionIndex)) { - bridgeRef.current?.goToSection?.(targetSectionIndex); - } - } else { - goToHref(transition.href); - } - - if (__DEV__) { - console.log("[ReaderScreen][TTS] navigated-to-prefetched-chapter", { - targetIndex: transition.targetIndex, - title: transition.title, - startChunkIndex: transition.startChunkIndex, - currentChunkIndex: ttsCurrentChunkIndex, - targetCfi: targetCfi || null, - }); - } - }, [ - bookId, - bookTitle, - bridgeRef, - currentTTSSegment?.cfi, - goToHref, - ttsCoverUri, - ttsCurrentBookId, - ttsCurrentChunkIndex, - ttsPlayState, - ttsSetCurrentBook, - ttsSetCurrentLocation, - ttsSourceKind, - ]); - - useEffect(() => { - const naturalStopSignature = - ttsSourceKind === "page" && - ttsContinuousRef.current && - ttsPlayState === "stopped" && - ttsTotalChunks > 0 && - ttsCurrentChunkIndex >= Math.max(0, ttsTotalChunks - 1) - ? `${ttsCurrentChunkIndex}:${resolvedTTSSegmentCfi || ""}:${ttsTotalChunks}` - : null; - - if (!naturalStopSignature) { - ttsLastStopHandledSignatureRef.current = null; - return; - } - if (ttsLastStopHandledSignatureRef.current === naturalStopSignature) { - return; - } - ttsLastStopHandledSignatureRef.current = naturalStopSignature; - console.log("[ReaderScreen][TTS] natural-stop-fallback", { - signature: naturalStopSignature, - currentChunkIndex: ttsCurrentChunkIndex, - totalChunks: ttsTotalChunks, - resolvedTTSSegmentCfi, - currentLocationCfi: ttsCurrentLocationCfi, - }); - handleTTSPageEnd(); - }, [ - handleTTSPageEnd, - resolvedTTSSegmentCfi, - ttsCurrentChunkIndex, - ttsCurrentLocationCfi, - ttsPlayState, - ttsSourceKind, - ttsTotalChunks, - ]); - - // ─── Lyric recovery effects ─────────────────────────────────────────────── - useEffect(() => { - if (!showTTS) { - ttsRecoveringLyricsRef.current = null; - return; - } - if (ttsCurrentBookId !== bookId) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState === "stopped") return; - if (ttsSegments.length > 0 || ttsPrevPageSegments.length > 0 || ttsFutureSegments.length > 0) { - return; - } - void recoverTTSLyricsState(); - }, [ - bookId, - recoverTTSLyricsState, - showTTS, - ttsCurrentBookId, - ttsFutureSegments.length, - ttsPlayState, - ttsPrevPageSegments.length, - ttsSegments.length, - ttsSourceKind, - ]); - - useEffect(() => { - if (!webViewReady) return; - if (ttsCurrentBookId !== bookId) return; - if (ttsSourceKind !== "page") return; - if (ttsPlayState === "stopped") return; - - const hasAnySegments = - ttsSegments.length > 0 || ttsPrevPageSegments.length > 0 || ttsFutureSegments.length > 0; - const needsRecovery = - !hasAnySegments || - (!currentTTSSegment && - (normalizeTTSDebugText(ttsCurrentSegmentText).length > 0 || - normalizeTTSDebugText(ttsCurrentLocationCfi).length > 0)); - - if (!needsRecovery) return; - - if (__DEV__) { - console.log("[ReaderScreen][TTS] recover-state-needed", { - showTTS, - playState: ttsPlayState, - currentChunkIndex: ttsCurrentChunkIndex, - hasAnySegments, - currentSegmentResolved: !!currentTTSSegment, - storeSegmentTextLength: normalizeTTSDebugText(ttsCurrentSegmentText).length, - currentLocationCfi: ttsCurrentLocationCfi, - ttsSegmentsLength: ttsSegments.length, - prevSegmentsLength: ttsPrevPageSegments.length, - futureSegmentsLength: ttsFutureSegments.length, - }); - } - - void recoverTTSLyricsState(); - }, [ - bookId, - currentTTSSegment, - recoverTTSLyricsState, - showTTS, - ttsCurrentBookId, - ttsCurrentChunkIndex, - ttsCurrentLocationCfi, - ttsCurrentSegmentText, - ttsFutureSegments.length, - ttsPlayState, - ttsPrevPageSegments.length, - ttsSegments.length, - ttsSourceKind, - webViewReady, - ]); - - useEffect(() => { - if (!showTTS) { - lastTTSLyricPrimeSignatureRef.current = null; - return; - } - if (ttsCurrentBookId !== bookId) return; - const activeCfi = - currentTTSSegment?.cfi || resolvedTTSSegmentCfi || ttsSegments[0]?.cfi || null; - if (!activeCfi) return; - const firstVisibleCfi = ttsSegments[0]?.cfi || activeCfi; - const lastVisibleCfi = ttsSegments[ttsSegments.length - 1]?.cfi || activeCfi; - const signature = `${activeCfi}|${firstVisibleCfi}|${lastVisibleCfi}`; - if (lastTTSLyricPrimeSignatureRef.current === signature) return; - lastTTSLyricPrimeSignatureRef.current = signature; - void primeTTSLyricContext(activeCfi, firstVisibleCfi, lastVisibleCfi); - }, [ - currentTTSSegment?.cfi, - bookId, - primeTTSLyricContext, - resolvedTTSSegmentCfi, - showTTS, - ttsCurrentBookId, - ttsSegments, - ]); - - // ─── Cleanup on unmount ──────────────────────────────────────────────────── - useEffect(() => { - return () => { - ttsContinuousRef.current = false; - ttsSetOnEnd(null); - bridgeRef.current?.setTTSHighlight(null); - }; - }, [bridgeRef, ttsSetOnEnd]); - + const currentTTSSegment = + currentBookId === bookId + ? ttsSegments[currentChunkIndex] || + allLyricSegments.find((s) => s.cfi === currentLocationCfi) || + null + : null; return { - // State ttsCoverUri, ttsLastText, ttsSegments, ttsPrevPageSegments, ttsFutureSegments, - ttsChunkOffset, + lyricSegments: ttsLyricSegments, + ttsChunkOffset: 0, ttsSourceKind, ttsContinuousEnabled, - ttsSourceLabel, - - // Derived + ttsSourceLabel: ttsSourceKind === "selection" ? "来自选中文本" : "从当前页开始", allLyricSegments, ttsDisplaySegments, currentTTSSegment, - resolvedTTSSegmentCfi, - ttsHighlightColor, - localTTSChunkIndex, - - // Handlers + resolvedTTSSegmentCfi: currentTTSSegment?.cfi || currentLocationCfi || null, + ttsHighlightColor: TTS_READER_HIGHLIGHT_COLOR, + localTTSChunkIndex: currentChunkIndex, handleToggleTTS, handleTTSReplay, handleTTSPlayPause, @@ -2705,10 +693,11 @@ export function useReaderTTS({ handleLoadMoreBelowTTSLyrics, handleTTSPrevChapter, handleTTSNextChapter, - startSelectionTTS, + startTTSFromSelection, handleTTSStop, handleTTSReturnToReading, pendingTTSContinueCallbackRef, + pendingTTSContinueSectionRef, pendingTTSContinueSafetyTimerRef, }; } diff --git a/packages/app-expo/src/services/PlaybackService.ts b/packages/app-expo/src/services/PlaybackService.ts index b00cf1141..5a048cc1b 100644 --- a/packages/app-expo/src/services/PlaybackService.ts +++ b/packages/app-expo/src/services/PlaybackService.ts @@ -6,9 +6,8 @@ * attempts to start its ForegroundService. * * Remote events (play/pause/stop/next/prev) are handled here and forwarded - * to the TTS store, duplicating the handlers in App.tsx bootstrap for safety. - * The App.tsx handlers remain as a fallback for cases where the service - * handler hasn't loaded yet. + * to the shared TTS store. Keeping this bridge in the playback service means + * it also works while the React UI is backgrounded or unmounted. */ import TrackPlayer, { Event } from "react-native-track-player"; import { useTTSStore } from "../stores/tts-store"; diff --git a/packages/app-expo/src/stores/tts-store.ts b/packages/app-expo/src/stores/tts-store.ts index 4a3d11129..b56c624e8 100644 --- a/packages/app-expo/src/stores/tts-store.ts +++ b/packages/app-expo/src/stores/tts-store.ts @@ -2,10 +2,12 @@ import { DEFAULT_TTS_CONFIG, type ITTSPlayer, type TTSConfig, - type TTSProfile, + TTSPlaybackController, VOICE_RESPEAK_DEBOUNCE_MS, isActivePlay, + mergeTTSConfigUpdates, normalizeTTSConfig, + preloadTTSChunks, shouldRespeakForSynthChange, splitNarrationText, } from "@readany/core/tts"; @@ -14,9 +16,9 @@ import TrackPlayer from "react-native-track-player"; import { create } from "zustand"; import { ExpoSpeechTTSPlayer } from "../lib/platform/expo-speech-player"; import { canUseSystemTtsSynthesis } from "../lib/platform/system-tts-synthesis"; +import { TrackPlayerCloudTTSPlayer } from "../lib/platform/track-player-cloud-tts-player"; import { TrackPlayerDashScopeTTSPlayer } from "../lib/platform/track-player-dashscope-player"; import { TrackPlayerEdgeTTSPlayer } from "../lib/platform/track-player-edge-player"; -import { TrackPlayerCloudTTSPlayer } from "../lib/platform/track-player-cloud-tts-player"; import { TrackPlayerSystemTTSPlayer } from "../lib/platform/track-player-system-player"; import { withPersist } from "./persist"; @@ -52,11 +54,7 @@ let _edgeTTS: ITTSPlayer | null = null; let _dashscopeTTS: ITTSPlayer | null = null; let _xiaomiTTS: ITTSPlayer | null = null; let _openAICompatibleTTS: ITTSPlayer | null = null; -let _activeTTS: ITTSPlayer | null = null; - -let _sessionSegments: string[] = []; -let _sessionCurrentIndex = 0; -let _sessionGeneration = 0; +let _controller: TTSPlaybackController | null = null; let _sleepTimerHandle: ReturnType | null = null; function getSystemTTS(): ITTSPlayer { @@ -108,33 +106,11 @@ function scheduleRespeak(): void { _respeakTimer = null; const { playState, jumpToChunk } = useTTSStore.getState(); if (isActivePlay(playState)) { - jumpToChunk(_sessionCurrentIndex); + jumpToChunk(useTTSStore.getState().currentChunkIndex); } }, VOICE_RESPEAK_DEBOUNCE_MS); } -function detachAndStopPlayer(player: ITTSPlayer | null): void { - if (!player) return; - player.onStateChange = undefined; - player.onChunkChange = undefined; - player.onError = undefined; - player.onEnd = undefined; - try { - player.stop(); - } catch (err) { - console.warn("[TTS] Failed to stop player:", err); - } -} - -function detachAndStopAllPlayers(): void { - _activeTTS = null; - detachAndStopPlayer(_systemTTS); - detachAndStopPlayer(_edgeTTS); - detachAndStopPlayer(_dashscopeTTS); - detachAndStopPlayer(_xiaomiTTS); - detachAndStopPlayer(_openAICompatibleTTS); -} - function normalizeSegments(text: string | string[]): string[] { if (Array.isArray(text)) { return text.map((segment) => segment.trim()).filter(Boolean); @@ -144,66 +120,6 @@ function normalizeSegments(text: string | string[]): string[] { .filter(Boolean); } -function previewSessionSegments(segments: string[], limit = 8) { - return segments.slice(0, limit).map((text, index) => ({ - index, - text: text.replace(/\s+/g, " ").trim(), - })); -} - -function syncProfileUpdatesFromLegacyFields( - previousConfig: TTSConfig, - updates: Partial, -): Partial { - const targetProvider = updates.engine ?? previousConfig.engine; - const requestedProfileId = updates.activeProfileId ?? previousConfig.activeProfileId; - const profileUpdates: Partial = {}; - - if (targetProvider === "edge" && updates.edgeVoice !== undefined) { - profileUpdates.voice = updates.edgeVoice; - } else if (targetProvider === "system" && updates.voiceName !== undefined) { - profileUpdates.voice = updates.voiceName; - } else if (targetProvider === "dashscope") { - if (updates.dashscopeApiKey !== undefined) profileUpdates.apiKey = updates.dashscopeApiKey; - if (updates.dashscopeVoice !== undefined) profileUpdates.voice = updates.dashscopeVoice; - } else if (targetProvider === "xiaomi") { - if (updates.xiaomiBaseUrl !== undefined) profileUpdates.baseUrl = updates.xiaomiBaseUrl; - if (updates.xiaomiApiKey !== undefined) profileUpdates.apiKey = updates.xiaomiApiKey; - if (updates.xiaomiVoice !== undefined) profileUpdates.voice = updates.xiaomiVoice; - if (updates.xiaomiStylePrompt !== undefined) { - profileUpdates.stylePrompt = updates.xiaomiStylePrompt; - } - } else if (targetProvider === "openai-compatible") { - if (updates.openaiTtsBaseUrl !== undefined) profileUpdates.baseUrl = updates.openaiTtsBaseUrl; - if (updates.openaiTtsApiKey !== undefined) profileUpdates.apiKey = updates.openaiTtsApiKey; - if (updates.openaiTtsEndpoint !== undefined) profileUpdates.endpoint = updates.openaiTtsEndpoint; - if (updates.openaiTtsModel !== undefined) profileUpdates.model = updates.openaiTtsModel; - if (updates.openaiTtsVoice !== undefined) profileUpdates.voice = updates.openaiTtsVoice; - if (updates.openaiTtsFormat !== undefined) profileUpdates.format = updates.openaiTtsFormat; - if (updates.openaiTtsStylePrompt !== undefined) { - profileUpdates.stylePrompt = updates.openaiTtsStylePrompt; - } - } - - if (Object.keys(profileUpdates).length === 0) return updates; - - const sourceProfiles = updates.profiles ?? previousConfig.profiles; - const requestedProfile = sourceProfiles.find((profile) => profile.id === requestedProfileId); - const targetProfileId = - requestedProfile?.provider === targetProvider - ? requestedProfile.id - : sourceProfiles.find((profile) => profile.provider === targetProvider)?.id; - - if (!targetProfileId) return updates; - - return { - ...updates, - profiles: sourceProfiles.map((profile) => - profile.id === targetProfileId ? { ...profile, ...profileUpdates } : profile, - ), - }; -} - function getPlayerForConfig(config: TTSConfig): ITTSPlayer { if (config.engine === "dashscope") { return getDashScopeTTS(); @@ -220,126 +136,6 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer { return getSystemTTS(); } -function startPlayback( - segments: string[], - config: TTSConfig, - startIndex: number, - set: (partial: Partial) => void, - get: () => TTSState, -): void { - const player = getPlayerForConfig(config); - const gen = _sessionGeneration; - let isStarting = true; - _activeTTS = player; - - // Set artwork getter for RNTP players - if ( - "setArtworkGetter" in player && - typeof (player as { setArtworkGetter?: unknown }).setArtworkGetter === "function" - ) { - (player as { setArtworkGetter: (getter: () => string | undefined) => void }).setArtworkGetter( - () => get().currentArtwork || undefined, - ); - } - - // Set title getter for RNTP players — chapter name shown on lock screen - // / control center / notification, with fallback to book title. - if ( - "setTitleGetter" in player && - typeof (player as { setTitleGetter?: unknown }).setTitleGetter === "function" - ) { - (player as { setTitleGetter: (getter: () => string | undefined) => void }).setTitleGetter( - () => { - const state = get(); - return state.currentChapterTitle || state.currentBookTitle || undefined; - }, - ); - } - - player.onStateChange = (playState) => { - if (gen !== _sessionGeneration) return; - if (isStarting && playState === "stopped") return; - console.log("[TTSStore][player] state-change", { - playState, - gen, - currentIndex: _sessionCurrentIndex, - total: _sessionSegments.length, - currentText: _sessionSegments[_sessionCurrentIndex] || "", - }); - if (playState === "stopped") { - _activeTTS = null; - } - set({ playState }); - }; - - player.onChunkChange = (chunkIndex) => { - if (gen !== _sessionGeneration) return; - const absoluteIndex = startIndex + chunkIndex; - _sessionCurrentIndex = absoluteIndex; - console.log("[TTSStore][player] chunk-change", { - chunkIndex, - absoluteIndex, - startIndex, - total: _sessionSegments.length, - currentText: _sessionSegments[absoluteIndex] || "", - nextText: _sessionSegments[absoluteIndex + 1] || "", - }); - set({ - currentChunkIndex: absoluteIndex, - totalChunks: _sessionSegments.length, - currentSegmentText: _sessionSegments[absoluteIndex] || "", - }); - }; - - player.onError = (error) => { - if (gen !== _sessionGeneration) return; - console.error("[TTSStore][player] error", error); - _activeTTS = null; - set({ playState: "stopped" }); - }; - - player.onEnd = () => { - if (gen !== _sessionGeneration) return; - _activeTTS = null; - const lastIndex = Math.max(0, _sessionSegments.length - 1); - _sessionCurrentIndex = lastIndex; - console.log("[TTSStore][player] end", { - gen, - lastIndex, - total: _sessionSegments.length, - lastText: _sessionSegments[lastIndex] || "", - queuePreview: previewSessionSegments(_sessionSegments), - hasOnEnd: !!get().onEnd, - }); - set({ - playState: "stopped", - currentChunkIndex: lastIndex, - totalChunks: _sessionSegments.length, - currentSegmentText: _sessionSegments[lastIndex] || "", - }); - get().onEnd?.(); - }; - - let playback: void | Promise; - try { - playback = player.speak(segments, config); - } catch (error) { - isStarting = false; - if (gen !== _sessionGeneration) return; - console.error("[TTSStore] play failed:", error); - _activeTTS = null; - set({ playState: "stopped" }); - return; - } - isStarting = false; - void Promise.resolve(playback).catch((error) => { - if (gen !== _sessionGeneration) return; - console.error("[TTSStore] play failed:", error); - _activeTTS = null; - set({ playState: "stopped" }); - }); -} - export interface TTSState { playState: TTSPlayState; currentText: string; @@ -357,6 +153,7 @@ export interface TTSState { sleepTimerDurationMinutes: number | null; play: (text: string | string[]) => void; + preload: (text: string | string[]) => boolean; append: (text: string | string[]) => boolean; pause: () => void; resume: () => void; @@ -376,288 +173,246 @@ export interface TTSState { export const useTTSStore = create()( withPersist( "tts", - (set, get) => ({ - playState: "stopped", - currentText: "", - currentSegmentText: "", - config: DEFAULT_TTS_CONFIG, - onEnd: null, - currentBookTitle: "", - currentChapterTitle: "", - currentBookId: "", - currentArtwork: "", - currentLocationCfi: "", - currentChunkIndex: 0, - totalChunks: 0, - sleepTimerEndsAt: null, - sleepTimerDurationMinutes: null, + (set, get) => { + const controller = new TTSPlaybackController( + getPlayerForConfig, + { + onStateChange: (playState) => set({ playState }), + onSegmentChange: (index, total, text) => + set({ currentChunkIndex: index, totalChunks: total, currentSegmentText: text }), + onEnd: () => get().onEnd?.(), + onError: (error) => console.error("[TTS] playback failed", error), + configurePlayer: (player) => { + if ( + "setArtworkGetter" in player && + typeof (player as { setArtworkGetter?: unknown }).setArtworkGetter === "function" + ) { + ( + player as { setArtworkGetter: (getter: () => string | undefined) => void } + ).setArtworkGetter(() => get().currentArtwork || undefined); + } + if ( + "setTitleGetter" in player && + typeof (player as { setTitleGetter?: unknown }).setTitleGetter === "function" + ) { + ( + player as { setTitleGetter: (getter: () => string | undefined) => void } + ).setTitleGetter( + () => get().currentChapterTitle || get().currentBookTitle || undefined, + ); + } + }, + }, + preloadTTSChunks, + ); + _controller = controller; + return { + playState: "stopped", + currentText: "", + currentSegmentText: "", + config: DEFAULT_TTS_CONFIG, + onEnd: null, + currentBookTitle: "", + currentChapterTitle: "", + currentBookId: "", + currentArtwork: "", + currentLocationCfi: "", + currentChunkIndex: 0, + totalChunks: 0, + sleepTimerEndsAt: null, + sleepTimerDurationMinutes: null, + + play: (text: string | string[]) => { + clearRespeakTimer(); + const segments = normalizeSegments(text); + const joinedText = segments.join(" ").trim(); + if (!joinedText) { + console.log("[TTSStore] No text to speak"); + return; + } + + const config = normalizeTTSConfig(get().config); + set({ + playState: "loading", + currentText: joinedText, + currentSegmentText: segments[0] || "", + currentChunkIndex: 0, + totalChunks: segments.length, + }); - play: (text: string | string[]) => { - clearRespeakTimer(); - const segments = normalizeSegments(text); - const joinedText = segments.join(" ").trim(); - if (!joinedText) { - console.log("[TTSStore] No text to speak"); - return; - } - - const config = normalizeTTSConfig(get().config); - detachAndStopAllPlayers(); - _sessionGeneration += 1; - _sessionSegments = segments; - _sessionCurrentIndex = 0; - - console.log("[TTSStore] play called", { - engine: config.engine, - segments: segments.length, - edgeVoice: config.edgeVoice, - voiceName: config.voiceName, - firstText: segments[0] || "", - secondText: segments[1] || "", - queuePreview: previewSessionSegments(segments), - }); - - set({ - playState: "loading", - currentText: joinedText, - currentSegmentText: segments[0] || "", - currentChunkIndex: 0, - totalChunks: segments.length, - }); - - startPlayback(segments, config, 0, set, get); - }, - - append: (text: string | string[]) => { - const segments = normalizeSegments(text); - const joinedText = segments.join(" ").trim(); - if (!joinedText || !_activeTTS || typeof _activeTTS.append !== "function") { - return false; - } - - const previousSegments = _sessionSegments; - try { - _activeTTS.append(segments); - _sessionSegments = [..._sessionSegments, ...segments]; - console.log("[TTSStore] append called", { - appended: segments.length, - previousTotal: previousSegments.length, - nextTotal: _sessionSegments.length, - appendFirstText: segments[0] || "", - appendSecondText: segments[1] || "", - appendedPreview: previewSessionSegments(segments), - fullQueueTailPreview: previewSessionSegments(_sessionSegments.slice(-8)), + controller.play(segments, config); + }, + + preload: (text: string | string[]) => + controller.preload(text, normalizeTTSConfig(get().config)), + + append: (text: string | string[]) => { + const segments = normalizeSegments(text); + const joinedText = segments.join(" ").trim(); + if (!joinedText) return false; + const appended = controller.append(segments); + if (appended) { + const snapshot = controller.snapshot(); + set((state) => ({ + currentText: [state.currentText, joinedText].filter(Boolean).join(" ").trim(), + totalChunks: snapshot.segments.length, + currentSegmentText: + snapshot.segments[snapshot.currentIndex] || state.currentSegmentText, + })); + } + return appended; + }, + + pause: () => { + console.log("[TTSStore] pause called"); + clearRespeakTimer(); + const { playState } = get(); + if (playState !== "playing" && playState !== "loading") return; + controller.pause(); + }, + + resume: () => { + console.log("[TTSStore] resume called"); + if (get().playState === "paused") { + controller.resume(normalizeTTSConfig(get().config)); + return; + } + const snapshot = controller.snapshot(); + if (snapshot.segments.length === 0 || snapshot.currentIndex >= snapshot.segments.length) { + set({ playState: "stopped" }); + return; + } + controller.resume(normalizeTTSConfig(get().config)); + }, + + stop: () => { + console.log("[TTSStore] stop called"); + clearSleepTimerHandle(); + clearRespeakTimer(); + controller.clear(); + set({ + playState: "stopped", + currentText: "", + currentSegmentText: "", + onEnd: null, + currentChunkIndex: 0, + totalChunks: 0, + currentBookTitle: "", + currentChapterTitle: "", + currentBookId: "", + currentLocationCfi: "", + sleepTimerEndsAt: null, + sleepTimerDurationMinutes: null, }); - set((state) => ({ - currentText: [state.currentText, joinedText].filter(Boolean).join(" ").trim(), - totalChunks: _sessionSegments.length, - currentSegmentText: - _sessionSegments[_sessionCurrentIndex] || state.currentSegmentText || "", - })); - return true; - } catch (error) { - _sessionSegments = previousSegments; - console.warn("[TTSStore] append failed:", error); - return false; - } - }, - - pause: () => { - console.log("[TTSStore] pause called"); - clearRespeakTimer(); - const { playState } = get(); - if (playState !== "playing" && playState !== "loading") return; - _activeTTS?.pause(); - set({ playState: "paused" }); - }, - - resume: () => { - console.log("[TTSStore] resume called"); - if (get().playState === "paused" && _activeTTS) { - _activeTTS.resume(); - set({ playState: "playing" }); - return; - } - - if (_sessionSegments.length === 0 || _sessionCurrentIndex >= _sessionSegments.length) { - set({ playState: "stopped" }); - return; - } - - const config = normalizeTTSConfig(get().config); - const nextIndex = Math.max(0, Math.min(_sessionCurrentIndex, _sessionSegments.length - 1)); - const remainingSegments = _sessionSegments.slice(nextIndex); - if (remainingSegments.length === 0) { - set({ playState: "stopped" }); - return; - } - - detachAndStopAllPlayers(); - _sessionGeneration += 1; - _sessionCurrentIndex = nextIndex; - - set({ - playState: "loading", - currentSegmentText: _sessionSegments[nextIndex] || "", - currentChunkIndex: nextIndex, - totalChunks: _sessionSegments.length, - }); - - startPlayback(remainingSegments, config, nextIndex, set, get); - }, - - stop: () => { - console.log("[TTSStore] stop called"); - clearSleepTimerHandle(); - clearRespeakTimer(); - _sessionGeneration += 1; - detachAndStopAllPlayers(); - _sessionSegments = []; - _sessionCurrentIndex = 0; - set({ - playState: "stopped", - currentText: "", - currentSegmentText: "", - onEnd: null, - currentChunkIndex: 0, - totalChunks: 0, - currentBookTitle: "", - currentChapterTitle: "", - currentBookId: "", - currentLocationCfi: "", - sleepTimerEndsAt: null, - sleepTimerDurationMinutes: null, - }); - }, - - toggle: (text?: string) => { - console.log("[TTSStore] toggle called, playState:", get().playState); - const { playState, currentText, play } = get(); - if (playState === "playing" || playState === "loading") { - get().pause(); - } else if (playState === "paused") { - get().resume(); - } else if (text) { - play(text); - } else if (currentText) { - play(currentText); - } - }, - - updateConfig: (updates) => { - const previousConfig = normalizeTTSConfig(get().config); - const normalizedUpdates = syncProfileUpdatesFromLegacyFields(previousConfig, updates); - const nextConfig = normalizeTTSConfig({ ...previousConfig, ...normalizedUpdates }); - set({ config: nextConfig }); - - if ( - shouldRespeakForSynthChange(previousConfig, nextConfig) && - isActivePlay(get().playState) - ) { - scheduleRespeak(); - } else { - // 非重读变更(切引擎、或改了当前引擎不关心的字段)必须取消上一次合成变更排下的 - // 待执行 respeak,否则陈旧防抖定时器会 fire 并强制重启播放。 + }, + + toggle: (text?: string) => { + console.log("[TTSStore] toggle called, playState:", get().playState); + const { playState, currentText, play } = get(); + if (playState === "playing" || playState === "loading") { + get().pause(); + } else if (playState === "paused") { + get().resume(); + } else if (text) { + play(text); + } else if (currentText) { + play(currentText); + } + }, + + updateConfig: (updates) => { + const previousConfig = normalizeTTSConfig(get().config); + const normalizedUpdates = mergeTTSConfigUpdates(previousConfig, updates); + const nextConfig = normalizeTTSConfig({ ...previousConfig, ...normalizedUpdates }); + set({ config: nextConfig }); + + if ( + shouldRespeakForSynthChange(previousConfig, nextConfig) && + isActivePlay(get().playState) + ) { + scheduleRespeak(); + } else { + // 非重读变更(切引擎、或改了当前引擎不关心的字段)必须取消上一次合成变更排下的 + // 待执行 respeak,否则陈旧防抖定时器会 fire 并强制重启播放。 + clearRespeakTimer(); + } + }, + + setPlayState: (playState) => set({ playState }), + + setOnEnd: (cb) => { + console.log("[TTSStore] setOnEnd", { hasCallback: !!cb }); + set({ onEnd: cb }); + }, + + setCurrentBook: (title, chapter, bookId, artwork) => { + set({ + currentBookTitle: title, + currentChapterTitle: chapter, + currentBookId: bookId ?? "", + currentArtwork: artwork ?? "", + }); + // Sync notification bar metadata + TrackPlayer.getActiveTrackIndex() + .then((idx) => { + if (idx != null) { + TrackPlayer.updateMetadataForTrack(idx, { + title: chapter || title, + artist: title, + album: title || "ReadAny", + ...(artwork ? { artwork } : {}), + }).catch((err) => console.warn("[TTS] Failed to update track metadata:", err)); + } + }) + .catch((err) => console.warn("[TTS] Failed to get active track index:", err)); + }, + + setCurrentLocation: (cfi) => set({ currentLocationCfi: cfi ?? "" }), + + setChunkProgress: (index, total) => + set({ + currentChunkIndex: index, + totalChunks: total, + currentSegmentText: controller.snapshot().segments[index] || "", + }), + + jumpToChunk: (index: number) => { clearRespeakTimer(); - } - }, - - setPlayState: (playState) => set({ playState }), - - setOnEnd: (cb) => { - console.log("[TTSStore] setOnEnd", { hasCallback: !!cb }); - set({ onEnd: cb }); - }, - - setCurrentBook: (title, chapter, bookId, artwork) => { - set({ - currentBookTitle: title, - currentChapterTitle: chapter, - currentBookId: bookId ?? "", - currentArtwork: artwork ?? "", - }); - // Sync notification bar metadata - TrackPlayer.getActiveTrackIndex() - .then((idx) => { - if (idx != null) { - TrackPlayer.updateMetadataForTrack(idx, { - title: chapter || title, - artist: title, - album: title || "ReadAny", - ...(artwork ? { artwork } : {}), - }).catch((err) => console.warn("[TTS] Failed to update track metadata:", err)); - } - }) - .catch((err) => console.warn("[TTS] Failed to get active track index:", err)); - }, - - setCurrentLocation: (cfi) => set({ currentLocationCfi: cfi ?? "" }), - - setChunkProgress: (index, total) => - set({ - currentChunkIndex: index, - totalChunks: total, - currentSegmentText: _sessionSegments[index] || "", - }), - - jumpToChunk: (index: number) => { - clearRespeakTimer(); - if (index < 0 || index >= _sessionSegments.length) return; - - const config = normalizeTTSConfig(get().config); - const remainingSegments = _sessionSegments.slice(index); - if (remainingSegments.length === 0) { - set({ playState: "stopped" }); - return; - } - - console.log("[TTSStore] jumpToChunk", { - index, - engine: config.engine, - segments: _sessionSegments.length, - }); - - detachAndStopAllPlayers(); - _sessionGeneration += 1; - _sessionCurrentIndex = index; - - set({ - playState: "loading", - currentSegmentText: _sessionSegments[index] || "", - currentChunkIndex: index, - totalChunks: _sessionSegments.length, - }); - - startPlayback(remainingSegments, config, index, set, get); - }, - - setSleepTimer: (minutes: number) => { - const durationMinutes = Math.max(1, Math.round(minutes)); - const endsAt = Date.now() + durationMinutes * 60_000; - clearSleepTimerHandle(); - _sleepTimerHandle = setTimeout(() => { - _sleepTimerHandle = null; - if (get().sleepTimerEndsAt !== endsAt) return; + const snapshot = controller.snapshot(); + if (index < 0 || index >= snapshot.segments.length) return; + const config = normalizeTTSConfig(get().config); + set({ playState: "loading", currentChunkIndex: index }); + controller.jumpTo(index, config); + }, + + setSleepTimer: (minutes: number) => { + const durationMinutes = Math.max(1, Math.round(minutes)); + const endsAt = Date.now() + durationMinutes * 60_000; + clearSleepTimerHandle(); + _sleepTimerHandle = setTimeout(() => { + _sleepTimerHandle = null; + if (get().sleepTimerEndsAt !== endsAt) return; + set({ + sleepTimerEndsAt: null, + sleepTimerDurationMinutes: null, + }); + get().pause(); + }, durationMinutes * 60_000); + set({ + sleepTimerEndsAt: endsAt, + sleepTimerDurationMinutes: durationMinutes, + }); + }, + + clearSleepTimer: () => { + clearSleepTimerHandle(); set({ sleepTimerEndsAt: null, sleepTimerDurationMinutes: null, }); - get().pause(); - }, durationMinutes * 60_000); - set({ - sleepTimerEndsAt: endsAt, - sleepTimerDurationMinutes: durationMinutes, - }); - }, - - clearSleepTimer: () => { - clearSleepTimerHandle(); - set({ - sleepTimerEndsAt: null, - sleepTimerDurationMinutes: null, - }); - }, - }), + }, + }; + }, { playState: "stopped" as const, currentText: "", @@ -677,7 +432,7 @@ export const useTTSStore = create()( export function setTTSPlayerFactories(factories: Partial): void { _factories = { ...defaultFactories, ...factories }; - detachAndStopAllPlayers(); + _controller?.clear(); _systemTTS = null; _edgeTTS = null; _dashscopeTTS = null; diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx index fc686f352..fb843b5f0 100644 --- a/packages/app/src/components/reader/FoliateViewer.tsx +++ b/packages/app/src/components/reader/FoliateViewer.tsx @@ -289,6 +289,16 @@ function normalizeTTSSegmentText(text?: string | null) { return cleanText(String(text || "")); } +function normalizeTTSSegmentDetails(details: Array<{ text?: string; cfi?: string }>) { + const segments: TTSSegmentDetail[] = []; + for (const detail of details) { + const text = normalizeTTSSegmentText(detail.text); + const cfi = detail.cfi; + if (text && cfi) segments.push({ text, cfi }); + } + return segments; +} + function acceptTTSNode(node: Node) { if (!node.nodeValue?.trim()) return NodeFilter.FILTER_SKIP; if (isTTSFootnoteMarker(node.nodeValue)) return NodeFilter.FILTER_REJECT; @@ -296,16 +306,6 @@ function acceptTTSNode(node: Node) { return shouldSkipTTSNode(parent) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } -function getTTSSegmentIdentity(cfi?: string | null, text?: string | null) { - return `${cfi || ""}::${normalizeTTSSegmentText(text)}`; -} - -type PaginatedVisibleRange = { - left: number; - right: number; - source: "renderer" | "legacy-offset" | "size-fallback"; -}; - type RendererContent = { doc?: Document | null; index?: number | null; @@ -319,91 +319,36 @@ function getRendererContents(view: FoliateView | null): RendererContent[] { return (view?.renderer?.getContents?.() ?? []) as RendererContent[]; } -function getPaginatedVisibleRangeCandidates(renderer: { - start?: unknown; - end?: unknown; - size?: unknown; -}): PaginatedVisibleRange[] { - const start = Number(renderer.start ?? 0); - const end = Number(renderer.end ?? 0); - const size = Number(renderer.size ?? 0); - const candidates: PaginatedVisibleRange[] = []; - - if (Number.isFinite(start) && Number.isFinite(size) && size > 0) { - candidates.push({ left: start - size, right: start, source: "legacy-offset" }); - candidates.push({ left: start, right: start + size, source: "size-fallback" }); - } - - if (Number.isFinite(start) && Number.isFinite(end) && end > start) { - candidates.push({ left: start, right: end, source: "renderer" }); - } - - return candidates.filter( - (candidate, index, list) => - candidate.right > candidate.left && - list.findIndex((item) => item.left === candidate.left && item.right === candidate.right) === - index, - ); -} - -function rectIntersectsPaginatedRange(rect: DOMRect, range: PaginatedVisibleRange) { - return rect.right > range.left && rect.left < range.right; -} - -function scorePaginatedVisibleRange(doc: Document, range: PaginatedVisibleRange) { - if (!doc.body) return 0; - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { - acceptNode: acceptTTSNode, - }); - let score = 0; - let visited = 0; - for ( - let textNode = walker.nextNode() as Text | null; - textNode && visited < 500; - textNode = walker.nextNode() as Text | null - ) { - visited += 1; - const text = normalizeTTSSegmentText(textNode.nodeValue); - if (!text) continue; - try { - const textRange = doc.createRange(); - textRange.selectNodeContents(textNode); - const rects = Array.from(textRange.getClientRects()).filter( - (rect) => rect.width > 0 && rect.height > 0, - ); - if (rects.some((rect) => rectIntersectsPaginatedRange(rect, range))) { - score += Math.min(text.length, 120); - } - } catch { - // Ignore nodes that cannot be measured. - } +function resolveLoadedCfiRange(view: FoliateView, cfi: string): Range | null { + try { + const resolved = view.resolveCFI(cfi); + const content = getRendererContents(view).find( + ({ doc, index }) => doc && index === resolved?.index, + ); + const range = content?.doc ? resolved?.anchor?.(content.doc) : null; + return range && typeof range.cloneRange === "function" ? (range as Range) : null; + } catch { + return null; } - return score; } -function pickPaginatedVisibleRange( - doc: Document, - renderer: { start?: unknown; end?: unknown; size?: unknown }, -) { - const candidates = getPaginatedVisibleRangeCandidates(renderer); - if (candidates.length <= 1) return candidates[0] ?? null; - - const legacyRange = candidates.find((range) => range.source === "legacy-offset") ?? null; - if (legacyRange && scorePaginatedVisibleRange(doc, legacyRange) > 0) { - return legacyRange; - } - - const rendererRange = candidates.find((range) => range.source === "renderer") ?? null; - if (rendererRange && scorePaginatedVisibleRange(doc, rendererRange) > 0) { - return rendererRange; - } - - const fallbackRange = candidates.find((range) => range.source === "size-fallback") ?? null; - if (fallbackRange && scorePaginatedVisibleRange(doc, fallbackRange) > 0) { - return fallbackRange; +function describeTTSRange(range: Range | null | undefined) { + if (!range) return null; + try { + const doc = range.startContainer.ownerDocument ?? + (range.startContainer.nodeType === Node.DOCUMENT_NODE + ? (range.startContainer as Document) + : null); + return { + collapsed: range.collapsed, + startOffset: range.startOffset, + endOffset: range.endOffset, + text: normalizeTTSSegmentText(range.toString()).slice(0, 100), + sectionUrl: doc?.location?.href || null, + }; + } catch { + return { collapsed: range.collapsed }; } - - return legacyRange ?? rendererRange ?? fallbackRange ?? candidates[0]; } function getIframeClickMetrics(doc: Document, container: HTMLElement | null, clientX: number) { @@ -698,7 +643,7 @@ export interface FoliateViewerHandle { goToHref: (href: string) => void; goToFraction: (fraction: number) => void; goToCFI: (cfi: string) => Promise; - goToIndex: (index: number) => void; + goToIndex: (index: number) => Promise; highlightCFITemporarily: (cfi: string, duration?: number) => void; // biome-ignore lint: foliate-js annotation format addAnnotation: (annotation: any, remove?: boolean) => void; @@ -711,9 +656,13 @@ export interface FoliateViewerHandle { }) => AsyncGenerator | null; clearSearch: () => void; getView: () => FoliateView | null; - /** Get visible text on the current page for TTS */ + /** Get readable text from the current Foliate reading position for TTS */ getVisibleText: () => string; getVisibleTTSSegments: (alignCfi?: string | null) => Promise; + /** Advance Foliate's TTS cursor to the next block, matching Readest's progression model. */ + getNextTTSSegments: () => Promise; + /** Read the next Foliate block without advancing the playback cursor. */ + peekNextTTSSegments: () => Promise; getTTSSegmentContext: ( cfi: string, before?: number, @@ -797,6 +746,9 @@ export const FoliateViewer = forwardRef ) { const containerRef = useRef(null); const viewRef = useRef(null); + // Foliate's relocate range is the authoritative start of the current + // viewport and the only anchor passed into the TTS cursor. + const lastRelocateRangeRef = useRef(null); const isViewCreated = useRef(false); // PDF theme-filter state: per-book per-page light decision + doc -> index mapping const pdfPageLightCacheRef = useRef>>(new Map()); @@ -805,6 +757,11 @@ export const FoliateViewer = forwardRef const [footnotePreview, setFootnotePreview] = useState(null); const activeFootnoteKeyRef = useRef(null); + useEffect(() => { + if (!bookKey) return; + lastRelocateRangeRef.current = null; + }, [bookKey]); + const applyPdfPageThemeFilter = useCallback( async (doc: Document, index: number, theme: AppTheme) => { const filter = PDF_THEME_FILTERS[theme]; @@ -916,9 +873,13 @@ export const FoliateViewer = forwardRef ttsHighlightKeyRef.current = null; if (prev && viewRef.current) { try { + console.log("[ReaderTTS][highlight] clear-delete", { prev }); viewRef.current.deleteAnnotation({ value: prev }); - } catch { - // no-op + } catch (error) { + console.warn("[ReaderTTS][highlight] clear-delete failed", { + prev, + error: error instanceof Error ? error.message : String(error), + }); } } }, []); @@ -981,9 +942,23 @@ export const FoliateViewer = forwardRef ); }; const current = getPrimaryContent(); - if (!view || !current?.doc) return null; + if (!view || !current?.doc) { + console.warn("[ReaderTTS][ensure] unavailable", { + hasView: !!view, + hasRenderer: !!view?.renderer, + contentCount: getRendererContents(view).length, + primaryIndex: view?.renderer?.primaryIndex ?? null, + }); + return null; + } + + console.log("[ReaderTTS][ensure] init", { + primaryIndex: view.renderer?.primaryIndex ?? null, + contentIndex: current.index ?? null, + existing: !!view.tts, + }); - await view.initTTS("sentence", (range) => { + await view.initTTS("sentence", acceptTTSNode, (range) => { const contents = getRendererContents(view); const sourceDoc = range.commonAncestorContainer.nodeType === Node.DOCUMENT_NODE @@ -1044,8 +1019,22 @@ export const FoliateViewer = forwardRef } return cfi; + }, (range) => { + const sourceDoc = + range.commonAncestorContainer.nodeType === Node.DOCUMENT_NODE + ? (range.commonAncestorContainer as Document) + : range.commonAncestorContainer.ownerDocument; + const content = getRendererContents(view).find((item) => item?.doc === sourceDoc) ?? + getRendererContents(view).find((item) => item?.index === view.renderer?.primaryIndex); + return content?.index == null ? "" : view.getCFI(content.index, range.cloneRange()); }); + console.log("[ReaderTTS][ensure] ready", { + docMatchesPrimary: view.tts?.doc === current.doc, + hasFrom: typeof view.tts?.from === "function", + hasCurrentBlockDetails: typeof view.tts?.currentBlockDetails === "function", + hasCfi: typeof view.tts?.alignCfi === "function", + }); return view.tts ?? null; }, []); @@ -1054,408 +1043,81 @@ export const FoliateViewer = forwardRef const view = viewRef.current; const renderer = view?.renderer; const contents = getRendererContents(view); - if (!view || !renderer || !contents.length) return []; - const primaryContent = - contents.find((content) => content?.doc && content.index === renderer.primaryIndex) ?? - contents.find((content) => content?.doc) ?? - null; - - await ensureDesktopTTS(); - - const rangeByDoc = new WeakMap(); - const getVisibleRangeForDoc = (doc: Document) => { - if (rangeByDoc.has(doc)) return rangeByDoc.get(doc) ?? null; - const range = pickPaginatedVisibleRange(doc, renderer); - rangeByDoc.set(doc, range); - return range; - }; - - const getReaderViewportRect = () => { - const containerRect = containerRef.current?.getBoundingClientRect(); - const viewportRect = { - left: 0, - top: 0, - right: window.innerWidth, - bottom: window.innerHeight, - }; - if (!containerRect || containerRect.width <= 0 || containerRect.height <= 0) { - return viewportRect; - } - return { - left: Math.max(containerRect.left, viewportRect.left), - top: Math.max(containerRect.top, viewportRect.top), - right: Math.min(containerRect.right, viewportRect.right), - bottom: Math.min(containerRect.bottom, viewportRect.bottom), - }; - }; - - const mapIframeRectToHost = (rect: DOMRect, doc?: Document | null) => { - const iframe = doc?.defaultView?.frameElement as HTMLIFrameElement | null; - if (!iframe) return rect; - const iframeRect = iframe.getBoundingClientRect(); - const scaleX = iframe.clientWidth > 0 ? iframeRect.width / iframe.clientWidth : 1; - const scaleY = iframe.clientHeight > 0 ? iframeRect.height / iframe.clientHeight : 1; - return { - left: iframeRect.left + rect.left * scaleX, - top: iframeRect.top + rect.top * scaleY, - right: iframeRect.left + rect.right * scaleX, - bottom: iframeRect.top + rect.bottom * scaleY, - width: rect.width * scaleX, - height: rect.height * scaleY, - }; - }; - - const isHostRectVisible = (rect: { - left: number; - top: number; - right: number; - bottom: number; - width: number; - height: number; - }) => { - if (!rect || rect.width <= 0 || rect.height <= 0) return false; - const viewport = getReaderViewportRect(); - return ( - rect.right > viewport.left && - rect.left < viewport.right && - rect.bottom > viewport.top && - rect.top < viewport.bottom - ); - }; - - const getContentHostRect = (content: RendererContent) => { - const doc = content?.doc ?? null; - const iframe = doc?.defaultView?.frameElement as HTMLIFrameElement | null; - if (iframe) { - const rect = iframe.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }; - } - if (doc?.body) { - return mapIframeRectToHost(doc.body.getBoundingClientRect(), doc); - } - return null; - }; - - const scanContents = renderer.scrolled - ? contents - .filter((content) => !!content?.doc) - .map((content) => ({ content, rect: getContentHostRect(content) })) - .filter((item) => item.rect && isHostRectVisible(item.rect)) - .sort((a, b) => { - const topDelta = (a.rect?.top ?? 0) - (b.rect?.top ?? 0); - return Math.abs(topDelta) > 1 - ? topDelta - : (a.rect?.left ?? 0) - (b.rect?.left ?? 0); - }) - .map((item) => item.content) - : contents; - - const isRectVisibleInReader = (rect: DOMRect, doc?: Document | null) => { - if (!rect || rect.width <= 0 || rect.height <= 0) return false; - const isPaginated = !renderer.scrolled; - if (isPaginated) { - const visibleRange = doc ? getVisibleRangeForDoc(doc) : null; - return visibleRange ? rectIntersectsPaginatedRange(rect, visibleRange) : false; - } - return isHostRectVisible(mapIframeRectToHost(rect, doc)); - }; - - // Require the START of the sentence range to be visible on the current page, - // preventing sentences that began on the previous page from appearing as the - // first TTS segment. - const isRangeStartVisibleInReader = (range: Range) => { - try { - const doc = - range.commonAncestorContainer.nodeType === Node.DOCUMENT_NODE - ? (range.commonAncestorContainer as Document) - : range.commonAncestorContainer.ownerDocument; - const rects = Array.from(range.getClientRects()); - if (!rects.length) { - return isRectVisibleInReader(range.getBoundingClientRect(), doc); - } - return isRectVisibleInReader(rects[0], doc); - } catch { - return false; - } - }; - - const blockSelector = - "p, h1, h2, h3, h4, h5, h6, li, blockquote, dd, dt, figcaption, pre, td, th"; - const lang = - (primaryContent?.doc as Document | undefined)?.documentElement.lang || - (primaryContent?.doc as Document | undefined)?.documentElement.getAttribute("xml:lang") || - (primaryContent?.doc as Document | undefined)?.body.lang || - navigator.language || - "en"; - const SegmenterCtor = ( - Intl as typeof Intl & { - Segmenter?: new ( - locales?: string | string[], - options?: { granularity?: "grapheme" | "word" | "sentence" }, - ) => { - segment(input: string): Iterable<{ index: number; segment: string }>; - }; - } - ).Segmenter; - const segmenter = SegmenterCtor - ? new SegmenterCtor(lang, { granularity: "sentence" }) - : null; - - const segments: TTSSegmentDetail[] = []; - const seenVisibleIdentities = new Set(); - for (const current of scanContents) { - const doc = current?.doc as Document | undefined; - const sectionIndex = current?.index ?? 0; - if (!doc) continue; - - let visibleBlocks = Array.from(doc.querySelectorAll(blockSelector)).filter((block) => { - if (!block.textContent?.trim()) return false; - if (shouldSkipTTSNode(block)) return false; - return isRectVisibleInReader(block.getBoundingClientRect(), doc); + if (!view || !renderer || !contents.length) { + console.warn("[ReaderTTS][visible] missing renderer", { + hasView: !!view, + hasRenderer: !!renderer, + contentCount: contents.length, }); - - // Fallback: if no standard block elements found (e.g., epub uses only - //
/ for text), try broader selectors - if (visibleBlocks.length === 0) { - visibleBlocks = Array.from(doc.querySelectorAll("div, section, article, span")).filter( - (el) => { - if (!el.textContent?.trim()) return false; - if (shouldSkipTTSNode(el)) return false; - // Only leaf-level elements with direct text content - if (el.querySelector("div, section, article, p")) return false; - return isRectVisibleInReader(el.getBoundingClientRect(), doc); - }, - ); - } - - // Last resort for unusual books. In scrolled mode this is only safe - // when the document belongs to a real iframe, because visibility must - // be measured against the outer reader viewport rather than the - // iframe's full document height. - if ( - visibleBlocks.length === 0 && - doc.body?.textContent?.trim() && - (!renderer.scrolled || doc.defaultView?.frameElement) && - isRectVisibleInReader(doc.body.getBoundingClientRect(), doc) - ) { - visibleBlocks = [doc.body]; - } - - for (const block of visibleBlocks) { - const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT, { - acceptNode: acceptTTSNode, - }); - - const positionedNodes: Array<{ node: Text; start: number; end: number }> = []; - let absoluteText = ""; - for ( - let textNode = walker.nextNode() as Text | null; - textNode; - textNode = walker.nextNode() as Text | null - ) { - const text = textNode.nodeValue || ""; - const start = absoluteText.length; - absoluteText += text; - positionedNodes.push({ node: textNode, start, end: absoluteText.length }); - } - if (!absoluteText.trim() || positionedNodes.length === 0) continue; - - const rawSegments = segmenter - ? Array.from(segmenter.segment(absoluteText)).map( - (item: { index: number; segment: string }) => ({ - start: item.index, - end: item.index + item.segment.length, - }), - ) - : (absoluteText.match(/[^。!?!?;;\n]+[。!?!?;;…]?/gu) || [absoluteText]).reduce< - Array<{ start: number; end: number }> - >((acc, sentence) => { - const last = acc.length > 0 ? acc[acc.length - 1] : null; - const start = absoluteText.indexOf(sentence, last?.end ?? 0); - if (start >= 0) acc.push({ start, end: start + sentence.length }); - return acc; - }, []); - - const resolvePosition = (absoluteOffset: number, isEnd: boolean) => { - for (const item of positionedNodes) { - if (absoluteOffset < item.end || (isEnd && absoluteOffset <= item.end)) { - return { - node: item.node, - offset: Math.max( - 0, - Math.min(item.node.nodeValue?.length ?? 0, absoluteOffset - item.start), - ), - }; - } - } - const last = positionedNodes[positionedNodes.length - 1]; - return { node: last.node, offset: last.node.nodeValue?.length ?? 0 }; - }; - - for (const rawSegment of rawSegments.length - ? rawSegments - : [{ start: 0, end: absoluteText.length }]) { - let start = rawSegment.start; - let end = rawSegment.end; - while (start < end && /\s/u.test(absoluteText[start] ?? "")) start++; - while (end > start && /\s/u.test(absoluteText[end - 1] ?? "")) end--; - if (end - start < 2) continue; - - const startPos = resolvePosition(start, false); - const endPos = resolvePosition(end, true); - if (!startPos || !endPos) continue; - - const range = doc.createRange(); - range.setStart(startPos.node, startPos.offset); - range.setEnd(endPos.node, endPos.offset); - if (!isRangeStartVisibleInReader(range)) continue; - - const text = normalizeTTSSegmentText(absoluteText.slice(start, end)); - if (!text) continue; - - try { - const cfi = view.getCFI(sectionIndex, range); - const identity = getTTSSegmentIdentity(cfi, text); - if (cfi && !seenVisibleIdentities.has(identity)) { - seenVisibleIdentities.add(identity); - segments.push({ text, cfi }); - } - } catch { - // skip segment if CFI resolution fails - } - } - } + return []; + } + console.log("[ReaderTTS][visible] request", { + alignCfi: alignCfi || null, + primaryIndex: renderer.primaryIndex ?? null, + contentIndexes: contents.map((content) => content.index ?? null), + relocateRange: describeTTSRange(lastRelocateRangeRef.current), + }); + const foliateTTS = await ensureDesktopTTS(); + if (!foliateTTS) { + console.warn("[ReaderTTS][visible] no Foliate TTS instance"); + return []; } - const tts = view.tts as null | { - alignCfi?: (cfi: string) => { text?: string; cfi?: string } | null; - currentDetail?: () => { text?: string; cfi?: string } | null; - collectDetails?: ( - count?: number, - options?: { includeCurrent?: boolean; offset?: number }, - ) => Array<{ text?: string; cfi?: string }>; - }; - - if (segments.length > 0 && tts) { - try { - const alignTargetCfi = alignCfi || segments[0]?.cfi; - if (!alignTargetCfi) return segments; - if (typeof tts.alignCfi === "function") { - tts.alignCfi(alignTargetCfi); - } else if ( - typeof (tts as { highlightCfi?: (cfi: string) => unknown }).highlightCfi === - "function" - ) { - (tts as { highlightCfi: (cfi: string) => unknown }).highlightCfi(alignTargetCfi); - } - const currentDetail = - typeof tts.currentDetail === "function" ? tts.currentDetail() : null; - const followingDetails = - typeof tts.collectDetails === "function" - ? tts.collectDetails( - Math.max( - 0, - Math.max(segments.length, alignCfi ? 12 : segments.length) - - (currentDetail ? 1 : 0), - ), - { - includeCurrent: false, - offset: 1, - }, - ) - : []; - const seenAlignedIdentities = new Set(); - const alignedSegments = [currentDetail, ...(followingDetails || [])] - .filter( - (detail): detail is { text: string; cfi: string } => - !!detail?.text && !!detail?.cfi, - ) - .map((detail) => ({ - text: normalizeTTSSegmentText(detail.text), - cfi: detail.cfi, - })) - .filter((detail) => { - const identity = getTTSSegmentIdentity(detail.cfi, detail.text); - if (!detail.text || seenAlignedIdentities.has(identity)) { - return false; - } - seenAlignedIdentities.add(identity); - return true; - }); - if (alignedSegments.length > 0) { - let returnedSegments = segments; - let returnSource = "direct-visible"; - if (segments.length > 0) { - const visibleIdentities = new Set( - segments.map((segment) => getTTSSegmentIdentity(segment.cfi, segment.text)), - ); - const filtered = alignedSegments.filter((segment) => - visibleIdentities.has(getTTSSegmentIdentity(segment.cfi, segment.text)), - ); - if (filtered.length === segments.length) { - returnedSegments = filtered; - returnSource = "aligned-filtered"; - } else if (filtered.length > 0) { - returnedSegments = segments; - returnSource = "direct-partial-filtered-fallback"; - } else if (alignCfi) { - const alignedStart = alignedSegments[0] || null; - const alignedStartIdentity = alignedStart - ? getTTSSegmentIdentity(alignedStart.cfi, alignedStart.text) - : null; - const visibleStartIndex = alignedStartIdentity - ? segments.findIndex( - (segment) => - getTTSSegmentIdentity(segment.cfi, segment.text) === alignedStartIdentity, - ) - : -1; - if (visibleStartIndex >= 0) { - returnedSegments = segments.slice(visibleStartIndex); - returnSource = "direct-aligned-slice"; - } else { - returnedSegments = segments; - returnSource = "direct-fallback"; - } - } else { - returnedSegments = segments; - returnSource = "direct-visible"; - } - } - console.log("[FoliateViewer][TTS] visibleTTSSegments", { - alignCfi: alignCfi || null, - contentsCount: contents.length, - scannedContentsCount: scanContents.length, - directCount: segments.length, - alignedCount: alignedSegments.length, - returnedCount: returnedSegments.length, - returnSource, - firstVisibleText: segments[0]?.text || null, - }); - return returnedSegments; + // Readest's model: Foliate owns sentence boundaries and its cursor. + // Align to the latest relocate range/CFI, then consume Foliate details; + // do not walk text nodes or calculate viewport rectangles here. + try { + if (alignCfi) { + const alignRange = resolveLoadedCfiRange(view, alignCfi); + if (alignRange && typeof foliateTTS.from === "function") { + const caret = alignRange.cloneRange(); + caret.collapse(true); + foliateTTS.from(caret); + } else { + foliateTTS.alignCfi?.(alignCfi); } - } catch { - // fall through to manual segments + } else if (lastRelocateRangeRef.current) { + // Foliate owns the sentence boundary. Use only the viewport start + // as its cursor anchor; do not re-filter its result by DOM ranges. + const caret = lastRelocateRangeRef.current.cloneRange(); + caret.collapse(true); + foliateTTS.from?.(caret); } + } catch (error) { + console.error("[ReaderTTS][visible] alignment failed", { + alignCfi: alignCfi || null, + relocateRange: describeTTSRange(lastRelocateRangeRef.current), + error: error instanceof Error ? error.message : String(error), + }); + return []; } - console.log("[FoliateViewer][TTS] visibleTTSSegments", { - alignCfi: alignCfi || null, - contentsCount: contents.length, - scannedContentsCount: scanContents.length, - directCount: segments.length, - alignedCount: 0, - returnedCount: segments.length, - returnSource: "direct", - firstVisibleText: segments[0]?.text || null, + const rawDetails = foliateTTS.currentBlockDetails?.(24) ?? [ + foliateTTS.currentDetail?.(), + ...(foliateTTS.collectDetails?.(24, { includeCurrent: false, offset: 1 }) ?? []), + ]; + const details = rawDetails.filter( + (detail: { text?: string; cfi?: string } | null | undefined): detail is { text: string; cfi: string } => + !!detail?.text?.trim() && !!detail.cfi, + ); + const visible = details.map((detail: { text: string; cfi: string }) => ({ + text: normalizeTTSSegmentText(detail.text), + cfi: detail.cfi, + })); + console.log("[ReaderTTS][visible] result", { + rawCount: rawDetails.length, + rawPreview: rawDetails.slice(0, 3).map((detail: { text?: string; cfi?: string } | null | undefined) => ({ + text: detail?.text?.slice(0, 100) ?? "", + textLength: detail?.text?.length ?? 0, + cfi: detail?.cfi ?? null, + })), + detailCount: details.length, + count: visible.length, + first: visible[0] ?? null, + last: visible.at(-1) ?? null, + foliateCurrent: foliateTTS.currentDetail?.() ?? null, }); - return segments; + return visible; }, [ensureDesktopTTS], ); @@ -1468,65 +1130,59 @@ export const FoliateViewer = forwardRef ): Promise<{ before: TTSSegmentDetail[]; after: TTSSegmentDetail[] }> => { const tts = await ensureDesktopTTS(); if (!tts || !cfi) return { before: [], after: [] }; - + const readContext = (tts as { + contextDetailsByCfi?: ( + value: string, + beforeCount: number, + afterCount: number, + ) => { before: TTSSegmentDetail[]; after: TTSSegmentDetail[] }; + }).contextDetailsByCfi; + if (!readContext) return { before: [], after: [] }; try { - if (typeof tts.alignCfi === "function") { - tts.alignCfi(cfi); - } else if ( - typeof (tts as { highlightCfi?: (value: string) => unknown }).highlightCfi === - "function" - ) { - (tts as { highlightCfi: (value: string) => unknown }).highlightCfi(cfi); - } - } catch { + const result = readContext.call(tts, cfi, Math.max(0, before), Math.max(0, after)); + console.log("[ReaderTTS][context]", { + requestedCfi: cfi, + beforeCount: result.before.length, + afterCount: result.after.length, + afterPreview: result.after.slice(0, 3), + }); + return result; + } catch (error) { + console.warn("[ReaderTTS][context] lookup failed", { + requestedCfi: cfi, + error: error instanceof Error ? error.message : String(error), + }); return { before: [], after: [] }; } - - const currentDetail = typeof tts.currentDetail === "function" ? tts.currentDetail() : null; - const currentIdentity = - currentDetail?.text && currentDetail?.cfi - ? getTTSSegmentIdentity(currentDetail.cfi, currentDetail.text) - : null; - - const normalize = (details: Array<{ text?: string; cfi?: string }>) => { - const seen = new Set(); - const result: TTSSegmentDetail[] = []; - for (const detail of details) { - if (!detail?.text || !detail?.cfi) continue; - const text = normalizeTTSSegmentText(detail.text); - const identity = getTTSSegmentIdentity(detail.cfi, text); - if (!text || (currentIdentity && identity === currentIdentity) || seen.has(identity)) { - continue; - } - seen.add(identity); - result.push({ text, cfi: detail.cfi }); - } - return result; - }; - - const beforeDetails = - typeof tts.collectDetails === "function" - ? tts.collectDetails(Math.max(0, before), { - includeCurrent: false, - offset: -Math.max(0, before), - }) - : []; - const afterDetails = - typeof tts.collectDetails === "function" - ? tts.collectDetails(Math.max(0, after), { - includeCurrent: false, - offset: 1, - }) - : []; - - return { - before: normalize(beforeDetails), - after: normalize(afterDetails), - }; }, [ensureDesktopTTS], ); + const getNextTTSSegments = useCallback(async (): Promise => { + const tts = await ensureDesktopTTS(); + if (!tts || typeof tts.nextBlockDetails !== "function") return []; + const details = tts.nextBlockDetails(24) as Array<{ text?: string; cfi?: string }>; + const segments = normalizeTTSSegmentDetails(details); + console.log("[ReaderTTS][next-block]", { + count: segments.length, + segments, + first: segments[0] ?? null, + last: segments[segments.length - 1] ?? null, + }); + return segments; + }, [ensureDesktopTTS]); + + const peekNextTTSSegments = useCallback(async (): Promise => { + const tts = await ensureDesktopTTS(); + const peekNextBlockDetails = ( + tts as { peekNextBlockDetails?: (count?: number) => Array<{ text?: string; cfi?: string }> } + )?.peekNextBlockDetails; + if (!peekNextBlockDetails) return []; + const segments = normalizeTTSSegmentDetails(peekNextBlockDetails.call(tts, 24)); + console.log("[ReaderTTS][peek-next-block]", { count: segments.length, segments }); + return segments; + }, [ensureDesktopTTS]); + // --- Imperative handle for parent --- useImperativeHandle( ref, @@ -1546,8 +1202,8 @@ export const FoliateViewer = forwardRef goToCFI: async (cfi: string) => { await viewRef.current?.goTo(cfi); }, - goToIndex: (index: number) => { - viewRef.current?.goTo(index); + goToIndex: async (index: number) => { + await viewRef.current?.goTo(index); }, highlightCFITemporarily: (cfi: string, duration = 1000) => { const view = viewRef.current; @@ -1711,13 +1367,23 @@ export const FoliateViewer = forwardRef } }, getVisibleTTSSegments, + getNextTTSSegments, + peekNextTTSSegments, getTTSSegmentContext, setTTSHighlight: async (cfi: string | null, color?: string) => { + console.log("[ReaderTTS][highlight] request", { + cfi, + color: color || "rgba(96, 165, 250, 0.35)", + previousKey: ttsHighlightKeyRef.current, + }); ttsHighlightStateRef.current = { cfi, color: color || "rgba(96, 165, 250, 0.35)", }; if (!cfi) { + console.log("[ReaderTTS][highlight] clear", { + previousKey: ttsHighlightKeyRef.current, + }); clearTTSHighlight(); return; } @@ -1732,20 +1398,27 @@ export const FoliateViewer = forwardRef if (prev && prev !== key) { try { + console.log("[ReaderTTS][highlight] delete-previous", { prev, next: key }); await view.deleteAnnotation({ value: prev }); } catch { - // no-op + console.warn("[ReaderTTS][highlight] delete-previous failed", { prev, next: key }); } } try { + console.log("[ReaderTTS][highlight] add", { key, cfi }); await view.addAnnotation({ value: key, type: "tts-highlight", color: color || "rgba(96, 165, 250, 0.35)", }); - } catch { - // no-op + console.log("[ReaderTTS][highlight] add-complete", { key, cfi }); + } catch (error) { + console.warn("[ReaderTTS][highlight] add failed", { + key, + cfi, + error: error instanceof Error ? error.message : String(error), + }); } }, getChapterParagraphs: () => { @@ -1937,7 +1610,6 @@ export const FoliateViewer = forwardRef } }, }), - [viewReady], ); // --- Hooks --- @@ -2052,6 +1724,14 @@ export const FoliateViewer = forwardRef const relocateHandlerImpl = useCallback( (event: Event) => { const rawDetail = (event as CustomEvent).detail as RelocateDetail; + lastRelocateRangeRef.current = rawDetail.range?.cloneRange?.() ?? rawDetail.range ?? null; + console.log("[ReaderTTS][relocate]", { + reason: (rawDetail as RelocateDetail & { reason?: string }).reason ?? null, + cfi: rawDetail.cfi ?? null, + section: rawDetail.section?.current ?? null, + page: rawDetail.page ?? null, + range: describeTTSRange(lastRelocateRangeRef.current), + }); const rendererPage = viewRef.current?.renderer && typeof viewRef.current.renderer.page === "number" ? viewRef.current.renderer.page diff --git a/packages/app/src/components/reader/ReaderView.tsx b/packages/app/src/components/reader/ReaderView.tsx index ade0140b9..d38d7642b 100644 --- a/packages/app/src/components/reader/ReaderView.tsx +++ b/packages/app/src/components/reader/ReaderView.tsx @@ -34,7 +34,12 @@ import { useChapterTranslation } from "@readany/core/hooks"; import { getPlatformService } from "@readany/core/services"; import { getCSSFontFace, useFontStore, useReadingSessionStore } from "@readany/core/stores"; import { useRubyStore } from "@readany/core/stores/ruby-store"; -import { splitNarrationText } from "@readany/core/tts"; +import { + mergeTTSLyricContext, + splitNarrationText, + TTSReadingSession, + type TTSReadingSnapshot, +} from "@readany/core/tts"; import type { CitationPart, HighlightColor } from "@readany/core/types"; import { eventBus } from "@readany/core/utils/event-bus"; import { throttle } from "@readany/core/utils/throttle"; @@ -169,19 +174,23 @@ function getAdjacentTTSSectionIndex( currentIndex: number | null | undefined, totalSections: number | null | undefined, direction: "previous" | "next", + sections?: Array<{ linear?: string }>, ): number | null { if (typeof currentIndex !== "number" || !Number.isInteger(currentIndex) || currentIndex < 0) { return null; } - const targetIndex = direction === "next" ? currentIndex + 1 : currentIndex - 1; - if (targetIndex < 0) return null; - - if (typeof totalSections === "number" && Number.isFinite(totalSections)) { - if (totalSections <= 0 || targetIndex >= totalSections) return null; + const step = direction === "next" ? 1 : -1; + const upperBound = + typeof totalSections === "number" && Number.isFinite(totalSections) + ? totalSections + : sections?.length; + for (let targetIndex = currentIndex + step; targetIndex >= 0; targetIndex += step) { + if (upperBound != null && targetIndex >= upperBound) return null; + if (sections?.[targetIndex]?.linear === "no") continue; + return targetIndex; } - - return targetIndex; + return null; } /** @@ -415,6 +424,15 @@ type TTSSegment = { cfi: string | null; }; +function describeTTSPlaybackSegments(segments: TTSSegment[]) { + return segments.map((segment, index) => ({ + index, + cfi: segment.cfi, + text: segment.text.slice(0, 160), + textLength: segment.text.length, + })); +} + export function ReaderView({ bookId, tabId }: ReaderViewProps) { const TOOLBAR_PIN_STORAGE_KEY = "readany-reader-toolbar-pinned"; const readerTab = useReaderStore((s) => s.tabs[tabId]); @@ -807,6 +825,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const [ttsSegments, setTtsSegments] = useState([]); const [ttsPrevPageSegments, setTtsPrevPageSegments] = useState([]); const [ttsFutureSegments, setTtsFutureSegments] = useState([]); + const [ttsLyricSegments, setTtsLyricSegments] = useState([]); const [isToolbarPinned, setIsToolbarPinned] = useState(() => { if (typeof window === "undefined") return false; return window.localStorage.getItem(TOOLBAR_PIN_STORAGE_KEY) === "true"; @@ -834,6 +853,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const ttsPlayState = useTTSStore((s) => s.playState); const ttsPlay = useTTSStore((s) => s.play); + const ttsPreload = useTTSStore((s) => s.preload); const ttsPause = useTTSStore((s) => s.pause); const ttsResume = useTTSStore((s) => s.resume); const ttsStop = useTTSStore((s) => s.stop); @@ -868,8 +888,37 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const ttsSegmentsRef = useRef([]); const ttsLastTextRef = useRef(""); const ttsFutureSegmentsRef = useRef([]); + const ttsLyricSegmentsRef = useRef([]); const ttsLoadMoreAboveRef = useRef(null); const ttsLoadMoreBelowRef = useRef(null); + const ttsReadingSessionRef = useRef(new TTSReadingSession()); + + const commitTTSReadingSnapshot = useCallback((snapshot: TTSReadingSnapshot) => { + setTtsSourceKind(snapshot.mode); + setTtsContinuousEnabled(snapshot.continuous); + setTtsPrevPageSegments(snapshot.previous); + setTtsSegments(snapshot.current); + setTtsFutureSegments(snapshot.future); + const lyricSegments = mergeTTSLyricContext( + ttsLyricSegmentsRef.current, + snapshot.previous, + snapshot.current, + snapshot.future, + ) as TTSSegment[]; + ttsLyricSegmentsRef.current = lyricSegments; + setTtsLyricSegments(lyricSegments); + ttsSegmentsRef.current = snapshot.current; + ttsFutureSegmentsRef.current = snapshot.future; + }, []); + + const resetTTSLyrics = useCallback(() => { + ttsLyricSegmentsRef.current = []; + setTtsLyricSegments([]); + }, []); + + useEffect(() => { + resetTTSLyrics(); + }, [bookId, resetTTSLyrics]); useEffect(() => { ttsSegmentsRef.current = ttsSegments; @@ -881,9 +930,9 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const ttsContinuousRef = useRef(false); /** Stores the callback to resume TTS after a page turn; triggered by handleRelocate */ const pendingTTSContinueCallbackRef = useRef<(() => void) | null>(null); - const pendingTTSContinueSafetyTimerRef = useRef | null>(null); + const ttsContinuationGenerationRef = useRef(0); const startPageTTSFromCfiRef = useRef< - ((targetCfi: string, targetText?: string) => Promise) | null + ((targetCfi: string) => Promise) | null >(null); const previousReaderBookIdRef = useRef(null); @@ -907,21 +956,13 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { clearSessionBinding = false, }: { stopPlayback?: boolean; clearSessionBinding?: boolean } = {}) => { ttsContinuousRef.current = false; + ttsContinuationGenerationRef.current += 1; pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } + commitTTSReadingSnapshot(ttsReadingSessionRef.current.reset()); ttsLoadMoreAboveRef.current = null; ttsLoadMoreBelowRef.current = null; - ttsSegmentsRef.current = []; ttsLastTextRef.current = ""; - ttsFutureSegmentsRef.current = []; - setTtsSourceKind("page"); setTtsLastText(""); - setTtsSegments([]); - setTtsPrevPageSegments([]); - setTtsFutureSegments([]); if (clearSessionBinding) { ttsSetOnEnd(null); } @@ -930,7 +971,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ttsStop(); } }, - [ttsSetOnEnd, ttsStop], + [commitTTSReadingSnapshot, ttsSetOnEnd, ttsStop], ); useEffect(() => { @@ -1006,10 +1047,16 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { }, []); const goToCFISafely = useCallback( - (targetCfi: string) => { + async (targetCfi: string) => { if (!targetCfi) return; + console.log("[ReaderView][TTS][navigate] start", { + targetCfi, + }); suppressProgressTracking(); - foliateRef.current?.goToCFI(targetCfi); + await foliateRef.current?.goToCFI(targetCfi); + console.log("[ReaderView][TTS][navigate] complete", { + targetCfi, + }); }, [suppressProgressTracking], ); @@ -1024,10 +1071,10 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ); const goToSectionSafely = useCallback( - (sectionIndex: number) => { + async (sectionIndex: number) => { if (!Number.isInteger(sectionIndex) || sectionIndex < 0) return; suppressProgressTracking(); - foliateRef.current?.goToIndex(sectionIndex); + await foliateRef.current?.goToIndex(sectionIndex); }, [suppressProgressTracking], ); @@ -1354,15 +1401,10 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // Mark translation ready after first successful relocate (CFI navigation done) if (!translationReady) setTranslationReady(true); - // If TTS is waiting for a page turn to complete, fire the continuation callback now - // that the renderer has fully updated its position (renderer.start reflects new page). + // Continue TTS only after the renderer reports the new relocate range. if (pendingTTSContinueCallbackRef.current) { const cb = pendingTTSContinueCallbackRef.current; pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } void cb(); } }, @@ -1915,18 +1957,6 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { [dedupeTTSSegments], ); - const mergeUniqueTTSSegments: ( - base: TTSSegment[], - incoming: TTSSegment[], - direction?: "prepend" | "append", - ) => TTSSegment[] = useCallback( - (base: TTSSegment[], incoming: TTSSegment[], direction: "prepend" | "append" = "append") => { - const ordered = direction === "prepend" ? [...incoming, ...base] : [...base, ...incoming]; - return dedupeTTSSegments(ordered); - }, - [dedupeTTSSegments], - ); - const primeDesktopTTSLyricContext = useCallback( async (cfi: string | null | undefined) => { if (!cfi) return; @@ -1937,13 +1967,20 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { previous, ttsSegmentsRef.current, ); - setTtsPrevPageSegments(previous); - setTtsFutureSegments(future); - ttsFutureSegmentsRef.current = future; + const session = ttsReadingSessionRef.current; + session.setContext(previous, future); + commitTTSReadingSnapshot(session.snapshot()); }, - [filterDistinctTTSSegments], + [commitTTSReadingSnapshot, filterDistinctTTSSegments], ); + const preloadNextTTSBlock = useCallback(async () => { + if (!ttsContinuousRef.current) return; + const segments = await foliateRef.current?.peekNextTTSSegments(); + if (!ttsContinuousRef.current || !segments?.length) return; + ttsPreload(segments.map((segment) => segment.text)); + }, [ttsPreload]); + const getCurrentTTSSectionIndex = useCallback(() => { const sectionIndex = currentSectionIndexRef.current; return Number.isInteger(sectionIndex) && sectionIndex >= 0 ? sectionIndex : null; @@ -1973,20 +2010,29 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { } const autoResume = options?.autoResume !== false; + pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } - - pendingTTSContinueCallbackRef.current = () => { - pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } + void foliateRef.current?.setTTSHighlight(null); + void (async () => { + try { + await goToSectionSafely(targetSectionIndex); + if ( + currentSectionIndexRef.current !== targetSectionIndex || + (!ttsContinuousRef.current && autoResume) + ) { + console.warn("[ReaderView][TTS] skip stale section continuation", { + targetSectionIndex, + currentSectionIndex: currentSectionIndexRef.current, + continuous: ttsContinuousRef.current, + }); + if (autoResume) { + ttsContinuousRef.current = false; + ttsSetOnEnd(null); + ttsStop(); + } + return; + } - void (async () => { const segments = (await foliateRef.current?.getVisibleTTSSegments())?.map((segment) => ({ text: segment.text.trim(), @@ -1999,6 +2045,9 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ttsStop(); return; } + commitTTSReadingSnapshot( + ttsReadingSessionRef.current.start("page", normalizedSegments, autoResume), + ); const nextText = normalizedSegments .map((segment) => segment.text) @@ -2009,14 +2058,8 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { normalizedSegments[normalizedSegments.length - 1]?.cfi || firstVisibleCfi; const nextChapterTitle = currentChapterTitleRef.current || readerTab?.chapterTitle || ""; - setTtsSourceKind("page"); - setTtsContinuousEnabled(autoResume); setTtsLastText(nextText); - setTtsSegments(normalizedSegments); - setTtsFutureSegments([]); ttsLastTextRef.current = nextText; - ttsSegmentsRef.current = normalizedSegments; - ttsFutureSegmentsRef.current = []; ttsStartChapterRef.current = nextChapterTitle; ttsContinuousRef.current = autoResume; ttsSetCurrentBook(book?.meta.title ?? "", nextChapterTitle, bookId); @@ -2025,19 +2068,23 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { void primeDesktopTTSLyricContext(lastVisibleCfi); if (autoResume) { + console.log("[ReaderView][TTS] play section", { + targetSectionIndex, + segments: describeTTSPlaybackSegments(normalizedSegments), + }); ttsPlay(normalizedSegments.map((segment) => segment.text)); + void preloadNextTTSBlock(); } - })(); - }; - - pendingTTSContinueSafetyTimerRef.current = setTimeout(() => { - const cb = pendingTTSContinueCallbackRef.current; - pendingTTSContinueCallbackRef.current = null; - if (cb) void cb(); - }, 1200); - - void foliateRef.current?.setTTSHighlight(null); - goToSectionSafely(targetSectionIndex); + } catch (error) { + console.error("[ReaderView][TTS] section transition failed", { + targetSectionIndex, + error: error instanceof Error ? error.message : String(error), + }); + ttsContinuousRef.current = false; + ttsSetOnEnd(null); + ttsStop(); + } + })(); return true; }, [ @@ -2046,17 +2093,20 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { getKnownTTSSectionTotal, goToSectionSafely, primeDesktopTTSLyricContext, + preloadNextTTSBlock, readerTab?.chapterTitle, ttsPlay, ttsSetCurrentBook, ttsSetCurrentLocation, ttsSetOnEnd, ttsStop, + commitTTSReadingSnapshot, ], ); const handleTTSPageEnd = useCallback(() => { if (!ttsContinuousRef.current) return; + const continuationGeneration = ++ttsContinuationGenerationRef.current; const previousSegments = ttsSegmentsRef.current; const previousFirstCfi = previousSegments[0]?.cfi || currentTTSSegment?.cfi || readerTab?.currentCfi || null; @@ -2067,14 +2117,9 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const previousText = ttsLastTextRef.current; if (previousSegments.length > 0) { - setTtsPrevPageSegments((prev) => mergeUniqueTTSSegments(prev, previousSegments, "append")); + commitTTSReadingSnapshot(ttsReadingSessionRef.current.appendPrevious(previousSegments)); } - // Clear any lingering safety timer from a previous page turn - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } pendingTTSContinueCallbackRef.current = null; // Clear TTS highlight BEFORE navigating — otherwise addAnnotation for the @@ -2083,21 +2128,59 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { void (async () => { if (!ttsContinuousRef.current) return; - const restartFromCfi = startPageTTSFromCfiRef.current; - const context = await foliateRef.current?.getTTSSegmentContext(previousLastCfi || "", 0, 20); - const nextSegment = - (context?.after || []).find((segment) => segment.text.trim().length > 0) || null; - console.log("[ReaderView][TTS] continue from context", { + if (continuationGeneration !== ttsContinuationGenerationRef.current || !ttsContinuousRef.current) { + return; + } + + // Readest's progression model: Foliate owns the ordered block cursor. + // Advance it once, await navigation to the first sentence, then start + // that block. Context lookup is for the lyric window only and must not + // decide which sentence is spoken next. + const nextSegments = + (await foliateRef.current?.getNextTTSSegments())?.filter( + (segment) => segment.text.trim().length > 0 && !!segment.cfi, + ) ?? []; + const nextSegment = nextSegments[0] ?? null; + console.log("[ReaderView][TTS] continue from next block", { + continuationGeneration, + currentGeneration: ttsContinuationGenerationRef.current, + playState: ttsPlayState, + sourceKind: ttsSourceKind, previousFirstCfi, previousLastCfi, previousTextLength: previousText.length, + previousSegments: describeTTSPlaybackSegments(previousSegments), + nextSegments: describeTTSPlaybackSegments(nextSegments), nextCfi: nextSegment?.cfi || null, nextTextLength: nextSegment?.text?.length || 0, - afterCount: context?.after?.length || 0, + nextBlockCount: nextSegments.length, }); - if (nextSegment?.cfi && restartFromCfi) { - await restartFromCfi(nextSegment.cfi, nextSegment.text); + if (nextSegment?.cfi) { + await goToCFISafely(nextSegment.cfi); + if ( + continuationGeneration !== ttsContinuationGenerationRef.current || + !ttsContinuousRef.current + ) { + return; + } + const nextText = nextSegments.map((segment) => segment.text).join(" ").trim(); + const session = ttsReadingSessionRef.current; + session.start("page", nextSegments, true); + session.setContext(previousSegments, []); + commitTTSReadingSnapshot(session.snapshot()); + setTtsLastText(nextText); + ttsLastTextRef.current = nextText; + ttsSegmentsRef.current = nextSegments; + ttsContinuousRef.current = true; + ttsSetOnEnd(handleTTSPageEnd); + ttsSetCurrentLocation(nextSegment.cfi); + console.log("[ReaderView][TTS] play next block", { + continuationGeneration, + segments: describeTTSPlaybackSegments(nextSegments), + }); + ttsPlay(nextSegments.map((segment) => segment.text)); + void preloadNextTTSBlock(); return; } @@ -2105,6 +2188,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { getCurrentTTSSectionIndex(), getKnownTTSSectionTotal(), "next", + bookDoc?.sections, ); if ( nextChapterIndex !== null && @@ -2119,13 +2203,20 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ttsStop(); })(); }, [ + bookDoc?.sections, currentTTSSegment?.cfi, + commitTTSReadingSnapshot, + goToCFISafely, getCurrentTTSSectionIndex, getKnownTTSSectionTotal, - mergeUniqueTTSSegments, queueDesktopTTSSectionTransition, + preloadNextTTSBlock, readerTab?.currentCfi, + ttsPlay, + ttsPlayState, + ttsSetCurrentLocation, ttsSetOnEnd, + ttsSourceKind, ttsStop, ]); @@ -2139,6 +2230,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { getCurrentTTSSectionIndex(), getKnownTTSSectionTotal(), "previous", + bookDoc?.sections, ); if (idx === null) return; if (ttsSourceKind === "page" && ttsPlayState !== "stopped") { @@ -2147,6 +2239,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { } goToSectionSafely(idx); }, [ + bookDoc?.sections, getCurrentTTSSectionIndex, getKnownTTSSectionTotal, goToSectionSafely, @@ -2160,6 +2253,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { getCurrentTTSSectionIndex(), getKnownTTSSectionTotal(), "next", + bookDoc?.sections, ); if (idx === null) return; if (ttsSourceKind === "page" && ttsPlayState !== "stopped") { @@ -2168,6 +2262,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { } goToSectionSafely(idx); }, [ + bookDoc?.sections, getCurrentTTSSectionIndex, getKnownTTSSectionTotal, goToSectionSafely, @@ -2180,18 +2275,16 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { (text: string) => { const normalized = text.trim(); if (!normalized) return; + ttsContinuationGenerationRef.current += 1; + pendingTTSContinueCallbackRef.current = null; const segments = splitNarrationText(normalized) .filter(Boolean) .map((segmentText) => ({ text: segmentText, cfi: null })); - setTtsSourceKind("selection"); - setTtsContinuousEnabled(false); + resetTTSLyrics(); + const snapshot = ttsReadingSessionRef.current.start("selection", segments, false); + commitTTSReadingSnapshot(snapshot); setTtsLastText(normalized); - setTtsSegments(segments); - setTtsPrevPageSegments([]); - setTtsFutureSegments([]); ttsLastTextRef.current = normalized; - ttsSegmentsRef.current = segments; - ttsFutureSegmentsRef.current = []; ttsContinuousRef.current = false; ttsSetOnEnd(null); ttsSetCurrentBook(book?.meta.title ?? "", readerTab?.chapterTitle ?? "", bookId); @@ -2208,35 +2301,57 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { readerTab?.selectionCfi, readerTab?.currentCfi, bookId, + commitTTSReadingSnapshot, + resetTTSLyrics, ], ); const startPageTTS = useCallback( async (continuous = ttsContinuousEnabled) => { + ttsContinuationGenerationRef.current += 1; + pendingTTSContinueCallbackRef.current = null; const segments = (await foliateRef.current?.getVisibleTTSSegments())?.map((segment) => ({ text: segment.text.trim(), cfi: segment.cfi, })) ?? []; + console.log("[ReaderTTS][start-page] visible response", { + continuous, + bookId, + currentCfi: readerTab?.currentCfi || null, + chapter: readerTab?.chapterTitle || null, + count: segments.length, + first: segments[0] ?? null, + last: segments[segments.length - 1] ?? null, + }); const normalizedSegments = segments.filter((segment) => segment.text.length > 0); const normalized = normalizedSegments .map((segment) => segment.text) .join(" ") .trim(); + console.log("[ReaderTTS][start-page] normalized", { + rawCount: segments.length, + nonEmptyCount: normalizedSegments.length, + rawTextLengths: segments.map((segment) => segment.text.length), + normalizedLength: normalized.length, + firstRaw: segments[0] ?? null, + firstNonEmpty: normalizedSegments[0] ?? null, + }); if (!normalized) { + console.warn("[ReaderTTS][start-page] no readable text", { + currentCfi: readerTab?.currentCfi || null, + chapter: readerTab?.chapterTitle || null, + segmentCount: segments.length, + }); toast.error(t("tts.noTextFound", "No readable text found on this page")); return; } + resetTTSLyrics(); + const snapshot = ttsReadingSessionRef.current.start("page", normalizedSegments, continuous); + commitTTSReadingSnapshot(snapshot); ttsStartChapterRef.current = readerTab?.chapterTitle ?? ""; - setTtsSourceKind("page"); - setTtsContinuousEnabled(continuous); setTtsLastText(normalized); - setTtsSegments(normalizedSegments); - setTtsPrevPageSegments([]); - setTtsFutureSegments([]); ttsLastTextRef.current = normalized; - ttsSegmentsRef.current = normalizedSegments; - ttsFutureSegmentsRef.current = []; ttsContinuousRef.current = continuous; ttsSetOnEnd(continuous ? handleTTSPageEnd : null); ttsSetCurrentBook(book?.meta.title ?? "", readerTab?.chapterTitle ?? "", bookId); @@ -2248,11 +2363,16 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { readerTab?.currentCfi || "", ); + console.log("[ReaderTTS][start-page] play", { + continuous, + segments: describeTTSPlaybackSegments(normalizedSegments), + }); ttsPlay( normalizedSegments.length > 0 ? normalizedSegments.map((segment) => segment.text) : normalized, ); + void preloadNextTTSBlock(); }, [ handleTTSPageEnd, @@ -2266,61 +2386,88 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { readerTab?.currentCfi, bookId, primeDesktopTTSLyricContext, + preloadNextTTSBlock, + commitTTSReadingSnapshot, + resetTTSLyrics, + t, ], ); - const startPageTTSFromCfi = useCallback( - async (targetCfi: string, targetText?: string) => { + const playPageTTSAtCfi = useCallback( + async (targetCfi: string) => { if (!targetCfi) return; + const generation = ++ttsContinuationGenerationRef.current; pendingTTSContinueCallbackRef.current = null; - if (pendingTTSContinueSafetyTimerRef.current) { - clearTimeout(pendingTTSContinueSafetyTimerRef.current); - pendingTTSContinueSafetyTimerRef.current = null; - } - goToCFISafely(targetCfi); - await new Promise((resolve) => setTimeout(resolve, 280)); const segments = (await foliateRef.current?.getVisibleTTSSegments(targetCfi))?.map((segment) => ({ text: segment.text.trim(), cfi: segment.cfi, })) ?? []; - const visibleSegments = segments.filter((segment) => segment.text.length > 0); - const playbackSegments = visibleSegments.length - ? visibleSegments - : [{ text: (targetText || "").trim(), cfi: targetCfi }].filter( - (segment) => segment.text.length > 0, - ); - if (!playbackSegments.length) return; - const context = await foliateRef.current?.getTTSSegmentContext(targetCfi, 10, 10); + if (generation !== ttsContinuationGenerationRef.current) return; + const playbackSegments = segments.filter((segment) => segment.text.length > 0); + if (!playbackSegments.length) { + ttsContinuousRef.current = false; + ttsSetOnEnd(null); + ttsStop(); + return; + } + + const firstCfi = playbackSegments[0]?.cfi || targetCfi; + const context = await foliateRef.current?.getTTSSegmentContext(firstCfi, 10, 10); + if (generation !== ttsContinuationGenerationRef.current) return; const previous = filterDistinctTTSSegments(context?.before || [], playbackSegments); const future = filterDistinctTTSSegments(context?.after || [], previous, playbackSegments); + const session = ttsReadingSessionRef.current; + session.start("page", playbackSegments, ttsContinuousEnabled); + session.setContext(previous, future); + commitTTSReadingSnapshot(session.snapshot()); + const nextText = playbackSegments .map((segment) => segment.text) .join(" ") .trim(); - setTtsSegments(playbackSegments); - setTtsPrevPageSegments(previous); - setTtsFutureSegments(future); setTtsLastText(nextText); ttsSegmentsRef.current = playbackSegments; ttsLastTextRef.current = nextText; - ttsFutureSegmentsRef.current = future; - ttsSetCurrentLocation(playbackSegments[0]?.cfi || targetCfi); - ttsContinuousRef.current = ttsSourceKind === "page" && ttsContinuousEnabled; - ttsSetOnEnd(ttsContinuousRef.current ? handleTTSPageEnd : null); + ttsSetCurrentBook(book?.meta.title ?? "", readerTab?.chapterTitle ?? "", bookId); + ttsSetCurrentLocation(firstCfi); + ttsContinuousRef.current = ttsContinuousEnabled; + ttsSetOnEnd(ttsContinuousEnabled ? handleTTSPageEnd : null); + setShowTTS(true); ttsPlay(playbackSegments.map((segment) => segment.text)); + void preloadNextTTSBlock(); }, [ + book?.meta.title, + bookId, + commitTTSReadingSnapshot, filterDistinctTTSSegments, handleTTSPageEnd, + preloadNextTTSBlock, + readerTab?.chapterTitle, ttsContinuousEnabled, ttsPlay, + ttsSetCurrentBook, ttsSetCurrentLocation, ttsSetOnEnd, - ttsSourceKind, + ttsStop, ], ); + const startPageTTSFromCfi = useCallback( + async (targetCfi: string) => { + if (!targetCfi) return; + const generation = ++ttsContinuationGenerationRef.current; + pendingTTSContinueCallbackRef.current = () => { + pendingTTSContinueCallbackRef.current = null; + if (generation !== ttsContinuationGenerationRef.current) return; + void playPageTTSAtCfi(targetCfi); + }; + goToCFISafely(targetCfi); + }, + [goToCFISafely, playPageTTSAtCfi], + ); + useEffect(() => { startPageTTSFromCfiRef.current = startPageTTSFromCfi; }, [startPageTTSFromCfi]); @@ -2330,6 +2477,17 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // TTS: toggle reading from current page with auto page-turn const handleToggleTTS = useCallback(async () => { + console.log("[ReaderTTS][toggle]", { + showTTS, + bookId, + playState: ttsPlayState, + sourceKind: ttsSourceKind, + currentBookId: ttsCurrentBookId, + currentTextLength: (ttsCurrentText || ttsLastText || "").trim().length, + chapter: readerTab?.chapterTitle || null, + currentCfi: readerTab?.currentCfi || null, + selectionCfi: readerTab?.selectionCfi || null, + }); if (showTTS) { setShowTTS(false); return; @@ -2369,13 +2527,15 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { readerTab?.chapterTitle, ]); - // TTS: speak selected text (no auto page-turn) + // TTS: start continuous reading at the sentence containing the selection start. const handleSpeakSelection = useCallback(() => { - if (selection?.text) { - startSelectionTTS(selection.text); - } + const targetCfi = selection?.cfi; setSelection(null); - }, [selection, startSelectionTTS]); + if (!targetCfi) return; + resetTTSLyrics(); + ttsStartChapterRef.current = readerTab?.chapterTitle ?? ""; + void playPageTTSAtCfi(targetCfi); + }, [playPageTTSAtCfi, readerTab?.chapterTitle, resetTTSLyrics, selection?.cfi]); const handleTTSReplay = useCallback(async () => { if (ttsSourceKind === "selection") { @@ -2457,6 +2617,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { setTtsContinuousEnabled((prev) => { const next = !prev; const shouldContinue = next && ttsSourceKind === "page"; + ttsReadingSessionRef.current.setContinuous(shouldContinue); ttsContinuousRef.current = shouldContinue; ttsSetOnEnd(shouldContinue ? handleTTSPageEnd : null); return next; @@ -2465,58 +2626,39 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const handleJumpToTTSSegment = useCallback( (offsetFromCurrent = 0) => { - if (offsetFromCurrent < 0) { - const prevIndex = ttsPrevPageSegments.length + offsetFromCurrent; - const safeIdx = Math.max(0, prevIndex); - const fromPrev = ttsPrevPageSegments.slice(safeIdx); - const allSegments = [...fromPrev, ...ttsSegments]; - if (allSegments.length === 0) return; - const newPrev = ttsPrevPageSegments.slice(0, safeIdx); - const nextText = allSegments - .map((segment) => segment.text) - .join(" ") - .trim(); - const nextCfi = allSegments[0]?.cfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(allSegments); - setTtsLastText(nextText); - ttsSegmentsRef.current = allSegments; - ttsLastTextRef.current = nextText; - if (nextCfi) { - ttsSetCurrentLocation(nextCfi); - goToCFISafely(nextCfi); - foliateRef.current?.highlightCFITemporarily(nextCfi, 1200); - } - setTimeout(() => ttsPlay(allSegments.map((segment) => segment.text)), 0); + if (offsetFromCurrent >= 0 && offsetFromCurrent < ttsSegments.length) { + ttsJumpToChunk(offsetFromCurrent); return; } - - const safeIdx = Math.max(0, Math.min(offsetFromCurrent, ttsSegments.length - 1)); - const sliced = ttsSegments.slice(safeIdx); - if (sliced.length === 0) return; - const newPrev = [...ttsPrevPageSegments, ...ttsSegments.slice(0, safeIdx)]; - const nextText = sliced - .map((segment) => segment.text) - .join(" ") - .trim(); - const nextCfi = sliced[0]?.cfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(sliced); + const jump = ttsReadingSessionRef.current.jump(offsetFromCurrent); + if (jump.kind !== "outside") return; + const snapshot = jump.snapshot; + const nextText = snapshot.current.map((segment) => segment.text).join(" ").trim(); + commitTTSReadingSnapshot(snapshot); setTtsLastText(nextText); - ttsSegmentsRef.current = sliced; ttsLastTextRef.current = nextText; - if (nextCfi) { - ttsSetCurrentLocation(nextCfi); - goToCFISafely(nextCfi); - foliateRef.current?.highlightCFITemporarily(nextCfi, 1200); + if (jump.target.cfi) { + ttsSetCurrentLocation(jump.target.cfi); + goToCFISafely(jump.target.cfi); + foliateRef.current?.highlightCFITemporarily(jump.target.cfi, 1200); } - setTimeout(() => ttsPlay(sliced.map((segment) => segment.text)), 0); + ttsPlay(snapshot.current.map((segment) => segment.text)); }, - [ttsPrevPageSegments, ttsSegments, ttsSetCurrentLocation, ttsPlay, goToCFISafely], + [commitTTSReadingSnapshot, goToCFISafely, ttsJumpToChunk, ttsPlay, ttsSegments, ttsSetCurrentLocation], ); const handleJumpToTTSLyricSegment = useCallback( (segment: { text: string; cfi?: string | null }, offsetFromCurrent: number) => { + if (segment.cfi) { + const queueIndex = ttsSegments.findIndex((item) => item.cfi === segment.cfi); + if (queueIndex >= 0) { + ttsJumpToChunk(queueIndex); + void foliateRef.current?.setTTSHighlight(segment.cfi); + } else if (ttsSourceKind === "page") { + void startPageTTSFromCfi(segment.cfi); + } + return; + } // offsetFromCurrent = lyricIndex - prevCount // lyricSegments = [...ttsPrevPageSegments, ...ttsSegments, ...ttsFutureSegments] @@ -2529,77 +2671,38 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { return; } - // ── Jumping to a prev-page segment (data already in memory) ── - if (offsetFromCurrent < 0) { - const prevIndex = ttsPrevPageSegments.length + offsetFromCurrent; - const safeIdx = Math.max(0, prevIndex); - const fromPrev = ttsPrevPageSegments.slice(safeIdx); - const allSegments = [...fromPrev, ...ttsSegments, ...ttsFutureSegments]; - if (allSegments.length === 0) return; - const newPrev = ttsPrevPageSegments.slice(0, safeIdx); - const nextText = allSegments - .map((s) => s.text) - .join(" ") - .trim(); - const nextCfi = allSegments[0]?.cfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(allSegments); - setTtsFutureSegments([]); + const jump = ttsReadingSessionRef.current.jump(offsetFromCurrent); + if (jump.kind === "outside") { + const snapshot = jump.snapshot; + const nextText = snapshot.current.map((item) => item.text).join(" ").trim(); + commitTTSReadingSnapshot(snapshot); setTtsLastText(nextText); - ttsSegmentsRef.current = allSegments; + ttsSegmentsRef.current = snapshot.current; ttsLastTextRef.current = nextText; - ttsFutureSegmentsRef.current = []; - if (nextCfi) { - ttsSetCurrentLocation(nextCfi); - goToCFISafely(nextCfi); + ttsFutureSegmentsRef.current = snapshot.future; + if (jump.target.cfi) { + ttsSetCurrentLocation(jump.target.cfi); + goToCFISafely(jump.target.cfi); } - ttsPlay(allSegments.map((s) => s.text)); + ttsPlay(snapshot.current.map((item) => item.text)); return; } - // ── Jumping to a future segment (data already in memory) ── - const futureOffset = offsetFromCurrent - ttsSegments.length; - if (futureOffset >= 0 && futureOffset < ttsFutureSegments.length) { - const consumedCurrent = ttsSegments; - const skippedFuture = ttsFutureSegments.slice(0, futureOffset); - const remainingFuture = ttsFutureSegments.slice(futureOffset); - const newPrev = [...ttsPrevPageSegments, ...consumedCurrent, ...skippedFuture]; - const nextText = remainingFuture - .map((s) => s.text) - .join(" ") - .trim(); - const nextCfi = remainingFuture[0]?.cfi; - setTtsPrevPageSegments(newPrev); - setTtsSegments(remainingFuture); - setTtsFutureSegments([]); - setTtsLastText(nextText); - ttsSegmentsRef.current = remainingFuture; - ttsLastTextRef.current = nextText; - ttsFutureSegmentsRef.current = []; - if (nextCfi) { - ttsSetCurrentLocation(nextCfi); - goToCFISafely(nextCfi); - } - ttsPlay(remainingFuture.map((s) => s.text)); - void primeDesktopTTSLyricContext( - remainingFuture[remainingFuture.length - 1]?.cfi || remainingFuture[0]?.cfi || nextCfi, - ); - return; + // The lyric window can be ahead of the in-memory context. Resolve the + // requested CFI after Foliate relocates, without replaying the label text + // as a synthetic segment. + if (jump.kind === "none" && segment.cfi && ttsSourceKind === "page") { + void startPageTTSFromCfi(segment.cfi); } - // ── Data not loaded yet — fall back ── - if (segment.cfi) { - void startPageTTSFromCfi(segment.cfi, segment.text); - } }, [ - primeDesktopTTSLyricContext, + commitTTSReadingSnapshot, startPageTTSFromCfi, ttsJumpToChunk, ttsPlay, - ttsPrevPageSegments, ttsSegments, - ttsFutureSegments, + ttsSourceKind, ttsSetCurrentLocation, goToCFISafely, ], @@ -2618,13 +2721,14 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ttsSegments, ); if (incoming.length > 0) { - setTtsPrevPageSegments((prev) => mergeUniqueTTSSegments(prev, incoming, "prepend")); + const snapshot = ttsReadingSessionRef.current.appendPrevious(incoming); + commitTTSReadingSnapshot(snapshot); } } } finally { ttsLoadMoreAboveRef.current = null; } - }, [filterDistinctTTSSegments, mergeUniqueTTSSegments, ttsPrevPageSegments, ttsSegments]); + }, [commitTTSReadingSnapshot, filterDistinctTTSSegments, ttsPrevPageSegments, ttsSegments]); const handleLoadMoreBelowTTSLyrics = useCallback(async () => { const anchorCfi = @@ -2643,11 +2747,8 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ttsFutureSegments, ); if (incoming.length > 0) { - setTtsFutureSegments((prev) => { - const next = mergeUniqueTTSSegments(prev, incoming, "append"); - ttsFutureSegmentsRef.current = next; - return next; - }); + const snapshot = ttsReadingSessionRef.current.appendFuture(incoming); + commitTTSReadingSnapshot(snapshot); } } } finally { @@ -2655,7 +2756,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { } }, [ filterDistinctTTSSegments, - mergeUniqueTTSSegments, + commitTTSReadingSnapshot, ttsFutureSegments, ttsPrevPageSegments, ttsSegments, @@ -3122,6 +3223,9 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { continuousEnabled={ttsContinuousEnabled} narrationSegments={[...ttsNarrationSegments, ...ttsFutureSegments]} prevNarrationSegments={ttsPrevPageSegments} + lyricSegments={ttsLyricSegments} + currentSegmentCfi={currentTTSSegment?.cfi || ttsCurrentLocationCfi} + currentSegmentText={currentTTSSegment?.text || ttsCurrentText} currentChunkIndex={ttsCurrentChunkIndex} totalChunks={ttsTotalChunks} onClose={() => setShowTTS(false)} @@ -3131,8 +3235,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { onStop={() => { ttsContinuousRef.current = false; ttsSetOnEnd(null); - setTtsFutureSegments([]); - ttsFutureSegmentsRef.current = []; + commitTTSReadingSnapshot(ttsReadingSessionRef.current.clearFuture()); ttsStop(); }} onAdjustRate={handleAdjustTTSRate} diff --git a/packages/app/src/components/reader/TTSPage.tsx b/packages/app/src/components/reader/TTSPage.tsx index 4c319dff2..c12b5637f 100644 --- a/packages/app/src/components/reader/TTSPage.tsx +++ b/packages/app/src/components/reader/TTSPage.tsx @@ -4,6 +4,7 @@ import { EDGE_TTS_VOICES, getLocaleDisplayLabel, getTTSVoiceLabel, + findTTSLyricIndex, groupEdgeTTSVoices, type TTSConfig, type TTSPlayState, @@ -39,6 +40,10 @@ interface TTSLyricSegment { cfi?: string | null; } +interface TTSLyricItem extends TTSLyricSegment { + id: string; +} + interface TTSPageProps { visible: boolean; bookTitle: string; @@ -54,6 +59,10 @@ interface TTSPageProps { continuousEnabled: boolean; narrationSegments?: TTSLyricSegment[]; prevNarrationSegments?: TTSLyricSegment[]; + /** Persisted lyric list, independent from the audio synthesis cache. */ + lyricSegments?: TTSLyricSegment[]; + currentSegmentCfi?: string | null; + currentSegmentText?: string | null; currentChunkIndex?: number; totalChunks?: number; onClose: () => void; @@ -95,6 +104,9 @@ export function TTSPage({ continuousEnabled, narrationSegments, prevNarrationSegments, + lyricSegments: persistedLyricSegments, + currentSegmentCfi, + currentSegmentText, currentChunkIndex = 0, totalChunks = 0, onClose, @@ -157,7 +169,7 @@ export function TTSPage({ ); const prevCount = prevNarrationSegments?.filter((segment) => segment.text.trim().length > 0).length ?? 0; - const lyricSegments = useMemo(() => { + const fallbackLyricSegments = useMemo(() => { const keyCounts = new Map(); const toLyricItem = (prefix: "prev" | "curr", segment: TTSLyricSegment, index: number) => { const fallbackKey = segment.text.trim().slice(0, 32) || `line-${index}`; @@ -186,8 +198,22 @@ export function TTSPage({ return currentText ? [{ id: "fallback:current-text", text: currentText, cfi: null }] : []; }, [currentText, narrationSegments, prevNarrationSegments]); + const lyricSegments: TTSLyricItem[] = persistedLyricSegments?.length + ? persistedLyricSegments.map((segment, index) => ({ + ...segment, + id: `persistent:${segment.cfi || `${segment.text}:${index}`}`, + })) + : fallbackLyricSegments; const safeChunkIndex = lyricSegments.length - ? Math.max(0, Math.min(prevCount + currentChunkIndex, lyricSegments.length - 1)) + ? currentSegmentCfi || currentSegmentText + ? Math.max( + 0, + findTTSLyricIndex(lyricSegments, { + cfi: currentSegmentCfi, + text: currentSegmentText, + }), + ) + : Math.max(0, Math.min(prevCount + currentChunkIndex, lyricSegments.length - 1)) : 0; const currentExcerpt = lyricSegments[safeChunkIndex]?.text || fallbackPreview.currentExcerpt; const nextExcerpt = lyricSegments[safeChunkIndex + 1]?.text || fallbackPreview.nextExcerpt; @@ -215,14 +241,14 @@ export function TTSPage({ const handleLyricPress = useCallback( (segment: { text: string; cfi?: string | null }, index: number) => { - const offsetFromCurrent = index - prevCount; + const offsetFromCurrent = persistedLyricSegments?.length ? index : index - prevCount; if (onJumpToLyricSegment) { onJumpToLyricSegment(segment, offsetFromCurrent); return; } onJumpToSegment?.(offsetFromCurrent); }, - [onJumpToLyricSegment, onJumpToSegment, prevCount], + [onJumpToLyricSegment, onJumpToSegment, persistedLyricSegments, prevCount], ); const triggerLoadMoreAbove = useCallback(() => { diff --git a/packages/core/src/hooks/reader/useFoliateView.ts b/packages/core/src/hooks/reader/useFoliateView.ts index 1722adc1e..5f218aec2 100644 --- a/packages/core/src/hooks/reader/useFoliateView.ts +++ b/packages/core/src/hooks/reader/useFoliateView.ts @@ -48,8 +48,9 @@ export interface FoliateView extends HTMLElement { // TTS initTTS( granularity?: string, + nodeFilter?: (node: Node) => number, highlight?: (range: Range) => void, - filterFunc?: (node: Node) => number, + getCfi?: (range: Range) => string, ): Promise; // biome-ignore lint: foliate-js TTS object tts: any; diff --git a/packages/core/src/i18n/locales/en/tts.json b/packages/core/src/i18n/locales/en/tts.json index ba063c7bf..c081fd2ec 100644 --- a/packages/core/src/i18n/locales/en/tts.json +++ b/packages/core/src/i18n/locales/en/tts.json @@ -67,7 +67,7 @@ "apiKeyPlaceholder": "Enter DashScope API Key", "apiKeyHint": "Get API key from Alibaba Cloud, use qwen3-tts-flash model", "getApiKey": "Get API Key", - "speakSelection": "Speak Selection", + "speakSelection": "Read from Selection", "loading": "Loading", "playing": "Reading", "paused": "Paused", diff --git a/packages/core/src/i18n/locales/es/tts.json b/packages/core/src/i18n/locales/es/tts.json index f4b260e6a..86bd82fc2 100644 --- a/packages/core/src/i18n/locales/es/tts.json +++ b/packages/core/src/i18n/locales/es/tts.json @@ -64,7 +64,7 @@ "apiKeyPlaceholder": "Ingresa la clave API de DashScope", "apiKeyHint": "Obtén la clave API de Alibaba Cloud, usa el modelo qwen3-tts-flash", "getApiKey": "Obtener clave API", - "speakSelection": "Leer selección", + "speakSelection": "Leer desde la selección", "loading": "Cargando", "playing": "Leyendo", "paused": "Pausado", diff --git a/packages/core/src/i18n/locales/fr/tts.json b/packages/core/src/i18n/locales/fr/tts.json index bf6667fca..291b7b99f 100644 --- a/packages/core/src/i18n/locales/fr/tts.json +++ b/packages/core/src/i18n/locales/fr/tts.json @@ -64,7 +64,7 @@ "apiKeyPlaceholder": "Entrer la clé API DashScope", "apiKeyHint": "Obtenez la clé API depuis Alibaba Cloud, utilisez le modèle qwen3-tts-flash", "getApiKey": "Obtenir la clé API", - "speakSelection": "Lire la sélection", + "speakSelection": "Lire à partir de la sélection", "loading": "Chargement", "playing": "Lecture en cours", "paused": "En pause", diff --git a/packages/core/src/i18n/locales/ja/tts.json b/packages/core/src/i18n/locales/ja/tts.json index d32a45ca6..11f14cf1b 100644 --- a/packages/core/src/i18n/locales/ja/tts.json +++ b/packages/core/src/i18n/locales/ja/tts.json @@ -64,7 +64,7 @@ "apiKeyPlaceholder": "DashScope APIキーを入力", "apiKeyHint": "Alibaba CloudからAPIキーを取得し、qwen3-tts-flashモデルを使用", "getApiKey": "APIキーを取得", - "speakSelection": "選択テキストを読み上げ", + "speakSelection": "選択位置から読み上げ", "loading": "読み込み中", "playing": "読み上げ中", "paused": "一時停止", diff --git a/packages/core/src/i18n/locales/ko/tts.json b/packages/core/src/i18n/locales/ko/tts.json index 6879e47fd..4d786602a 100644 --- a/packages/core/src/i18n/locales/ko/tts.json +++ b/packages/core/src/i18n/locales/ko/tts.json @@ -64,7 +64,7 @@ "apiKeyPlaceholder": "DashScope API 키 입력", "apiKeyHint": "알리바바 클라우드에서 API 키를 받으세요, qwen3-tts-flash 모델 사용", "getApiKey": "API 키 받기", - "speakSelection": "선택 영역 읽기", + "speakSelection": "선택한 위치부터 읽기", "loading": "로딩 중", "playing": "낭독 중", "paused": "일시정지", diff --git a/packages/core/src/i18n/locales/zh-TW/tts.json b/packages/core/src/i18n/locales/zh-TW/tts.json index a7240b0d9..67893d496 100644 --- a/packages/core/src/i18n/locales/zh-TW/tts.json +++ b/packages/core/src/i18n/locales/zh-TW/tts.json @@ -67,7 +67,7 @@ "apiKeyPlaceholder": "輸入 DashScope API 密鑰", "apiKeyHint": "從阿里百煉平台取得 API 密鑰,使用 qwen3-tts-flash 模型", "getApiKey": "取得 API Key", - "speakSelection": "朗讀選取文字", + "speakSelection": "從選取位置開始朗讀", "loading": "載入中", "playing": "正在朗讀", "paused": "已暫停", diff --git a/packages/core/src/i18n/locales/zh/tts.json b/packages/core/src/i18n/locales/zh/tts.json index 7464993c6..71b4430bd 100644 --- a/packages/core/src/i18n/locales/zh/tts.json +++ b/packages/core/src/i18n/locales/zh/tts.json @@ -67,7 +67,7 @@ "apiKeyPlaceholder": "输入 DashScope API 密钥", "apiKeyHint": "从阿里百炼平台获取 API 密钥,使用 qwen3-tts-flash 模型", "getApiKey": "获取 API Key", - "speakSelection": "朗读选中文本", + "speakSelection": "从选中位置开始朗读", "loading": "加载中", "playing": "正在朗读", "paused": "已暂停", diff --git a/packages/core/src/stores/tts-store.ts b/packages/core/src/stores/tts-store.ts index 20a442132..c0e861297 100644 --- a/packages/core/src/stores/tts-store.ts +++ b/packages/core/src/stores/tts-store.ts @@ -11,6 +11,10 @@ * (e.g. React Native) can override via `setTTSPlayerFactories()`. */ import { create } from "zustand"; +import { preloadTTSChunks } from "../tts/cloud-tts"; +import { mergeTTSConfigUpdates } from "../tts/configuration"; +import { splitNarrationText } from "../tts/display"; +import { TTSPlaybackController } from "../tts/playback-controller"; import { VOICE_RESPEAK_DEBOUNCE_MS, isActivePlay, @@ -23,7 +27,7 @@ import { OpenAICompatibleTTSPlayer, XiaomiTTSPlayer, } from "../tts/tts-players"; -import type { ITTSPlayer, TTSConfig, TTSProfile } from "../tts/types"; +import type { ITTSPlayer, TTSConfig } from "../tts/types"; import { DEFAULT_TTS_CONFIG, normalizeTTSConfig } from "../tts/types"; import { withPersist } from "./persist"; @@ -55,15 +59,11 @@ let _factories: TTSPlayerFactories = defaultFactories; * Override TTS player factories for platforms that cannot use Web Audio APIs. * Call this at app startup before any TTS playback. * - * Example (React Native): - * setTTSPlayerFactories({ - * createSystemTTS: () => new ExpoSpeechTTSPlayer(), - * createEdgeTTS: () => new ExpoAVEdgeTTSPlayer(), - * createDashScopeTTS: () => new ExpoAVDashScopeTTSPlayer(), - * }); + * React Native injects its Track Player-backed implementations at startup. */ export function setTTSPlayerFactories(factories: Partial): void { _factories = { ...defaultFactories, ...factories }; + _controller?.clear(); // Reset cached instances so new factories take effect _systemTTS = null; _edgeTTS = null; @@ -78,15 +78,8 @@ let _edgeTTS: ITTSPlayer | null = null; let _dashscopeTTS: ITTSPlayer | null = null; let _xiaomiTTS: ITTSPlayer | null = null; let _openAICompatibleTTS: ITTSPlayer | null = null; -let _activeTTS: ITTSPlayer | null = null; -let _sessionSegments: string[] = []; -let _sessionCurrentIndex = 0; -/** Generation counter — incremented on every play/jumpToChunk to invalidate stale callbacks */ -let _sessionGeneration = 0; +let _controller: TTSPlaybackController | null = null; let _sleepTimerHandle: ReturnType | null = null; -/** Voice the active DashScope run is synthesizing with; lets resume() decide whether - * it can true-resume (voice unchanged) or must re-speak (voice changed). */ -let _dashscopeActiveVoice: string | undefined; function getSystemTTS(): ITTSPlayer { if (!_systemTTS) _systemTTS = _factories.createSystemTTS(); @@ -137,81 +130,16 @@ function scheduleRespeak(): void { _respeakTimer = null; const { playState, jumpToChunk } = useTTSStore.getState(); if (isActivePlay(playState)) { - jumpToChunk(_sessionCurrentIndex); + jumpToChunk(useTTSStore.getState().currentChunkIndex); } }, VOICE_RESPEAK_DEBOUNCE_MS); } -function syncProfileUpdatesFromLegacyFields( - previousConfig: TTSConfig, - updates: Partial, -): Partial { - const targetProvider = updates.engine ?? previousConfig.engine; - const requestedProfileId = updates.activeProfileId ?? previousConfig.activeProfileId; - const profileUpdates: Partial = {}; - - if (targetProvider === "edge" && updates.edgeVoice !== undefined) { - profileUpdates.voice = updates.edgeVoice; - } else if (targetProvider === "system" && updates.voiceName !== undefined) { - profileUpdates.voice = updates.voiceName; - } else if (targetProvider === "dashscope") { - if (updates.dashscopeApiKey !== undefined) profileUpdates.apiKey = updates.dashscopeApiKey; - if (updates.dashscopeVoice !== undefined) profileUpdates.voice = updates.dashscopeVoice; - } else if (targetProvider === "xiaomi") { - if (updates.xiaomiBaseUrl !== undefined) profileUpdates.baseUrl = updates.xiaomiBaseUrl; - if (updates.xiaomiApiKey !== undefined) profileUpdates.apiKey = updates.xiaomiApiKey; - if (updates.xiaomiVoice !== undefined) profileUpdates.voice = updates.xiaomiVoice; - if (updates.xiaomiStylePrompt !== undefined) { - profileUpdates.stylePrompt = updates.xiaomiStylePrompt; - } - } else if (targetProvider === "openai-compatible") { - if (updates.openaiTtsBaseUrl !== undefined) profileUpdates.baseUrl = updates.openaiTtsBaseUrl; - if (updates.openaiTtsApiKey !== undefined) profileUpdates.apiKey = updates.openaiTtsApiKey; - if (updates.openaiTtsEndpoint !== undefined) profileUpdates.endpoint = updates.openaiTtsEndpoint; - if (updates.openaiTtsModel !== undefined) profileUpdates.model = updates.openaiTtsModel; - if (updates.openaiTtsVoice !== undefined) profileUpdates.voice = updates.openaiTtsVoice; - if (updates.openaiTtsFormat !== undefined) profileUpdates.format = updates.openaiTtsFormat; - if (updates.openaiTtsStylePrompt !== undefined) { - profileUpdates.stylePrompt = updates.openaiTtsStylePrompt; - } - } - - if (Object.keys(profileUpdates).length === 0) return updates; - - const sourceProfiles = updates.profiles ?? previousConfig.profiles; - const requestedProfile = sourceProfiles.find((profile) => profile.id === requestedProfileId); - const targetProfileId = - requestedProfile?.provider === targetProvider - ? requestedProfile.id - : sourceProfiles.find((profile) => profile.provider === targetProvider)?.id; - if (!targetProfileId) return updates; - - const profiles = sourceProfiles.map((profile) => - profile.id === targetProfileId ? { ...profile, ...profileUpdates } : profile, - ); - - return { ...updates, profiles }; -} - -function detachAndStopPlayer(player: ITTSPlayer | null): void { - if (!player) return; - player.onStateChange = undefined; - player.onChunkChange = undefined; - player.onEnd = undefined; - try { - player.stop(); - } catch (err) { - console.warn("[TTS] Failed to stop player:", err); - } -} - -function detachAndStopAllPlayers(): void { - _activeTTS = null; - detachAndStopPlayer(_systemTTS); - detachAndStopPlayer(_edgeTTS); - detachAndStopPlayer(_dashscopeTTS); - detachAndStopPlayer(_xiaomiTTS); - detachAndStopPlayer(_openAICompatibleTTS); +function normalizeSegments(text: string | string[]): string[] { + if (Array.isArray(text)) return text.map((item) => item.trim()).filter(Boolean); + return splitNarrationText(text) + .map((item) => item.trim()) + .filter(Boolean); } function getPlayerForConfig(config: TTSConfig): ITTSPlayer { @@ -230,70 +158,6 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer { return getSystemTTS(); } -function startPlayback( - segments: string[], - config: TTSConfig, - startIndex: number, - set: (partial: Partial) => void, - get: () => TTSState, -): void { - const player = getPlayerForConfig(config); - const gen = _sessionGeneration; - let isStarting = true; - _activeTTS = player; - - player.onStateChange = (playState) => { - if (gen !== _sessionGeneration) return; - if (isStarting && playState === "stopped") return; - if (playState === "stopped") { - _activeTTS = null; - } - set({ playState }); - }; - - player.onChunkChange = (chunkIndex, total) => { - if (gen !== _sessionGeneration) return; - const absoluteIndex = startIndex + chunkIndex; - _sessionCurrentIndex = absoluteIndex; - set({ - currentChunkIndex: absoluteIndex, - totalChunks: Math.max(_sessionSegments.length, total), - }); - }; - - player.onEnd = () => { - if (gen !== _sessionGeneration) return; - _activeTTS = null; - const lastIndex = Math.max(0, _sessionSegments.length - 1); - _sessionCurrentIndex = lastIndex; - set({ - playState: "stopped", - currentChunkIndex: lastIndex, - totalChunks: _sessionSegments.length, - }); - get().onEnd?.(); - }; - - let playback: void | Promise; - try { - playback = player.speak(segments, config); - } catch (error) { - isStarting = false; - if (gen !== _sessionGeneration) return; - console.error("[TTS] play failed:", error); - _activeTTS = null; - set({ playState: "stopped" }); - return; - } - isStarting = false; - void Promise.resolve(playback).catch((error) => { - if (gen !== _sessionGeneration) return; - console.error("[TTS] play failed:", error); - _activeTTS = null; - set({ playState: "stopped" }); - }); -} - export interface TTSState { /** Current playback state */ playState: TTSPlayState; @@ -322,6 +186,7 @@ export interface TTSState { // Actions play: (text: string | string[]) => void; + preload: (text: string | string[]) => boolean; pause: () => void; resume: () => void; stop: () => void; @@ -341,224 +206,187 @@ export interface TTSState { export const useTTSStore = create()( withPersist( "tts", - (set, get) => ({ - playState: "stopped", - currentText: "", - config: DEFAULT_TTS_CONFIG, - onEnd: null, - currentChunkIndex: 0, - totalChunks: 0, - currentBookTitle: "", - currentChapterTitle: "", - currentBookId: "", - currentLocationCfi: "", - sleepTimerEndsAt: null, - sleepTimerDurationMinutes: null, + (set, get) => { + const controller = new TTSPlaybackController( + getPlayerForConfig, + { + onStateChange: (playState) => set({ playState }), + onSegmentChange: (index, total, text) => { + console.log("[TTSStore][segment]", { + index, + total, + text: text.slice(0, 160), + }); + set({ currentChunkIndex: index, totalChunks: total }); + }, + onEnd: () => { + console.log("[TTSStore][end]", { + currentChunkIndex: get().currentChunkIndex, + totalChunks: get().totalChunks, + currentText: get().currentText.slice(0, 160), + }); + get().onEnd?.(); + }, + onError: (error) => console.error("[TTS] playback failed", error), + }, + preloadTTSChunks, + ); + _controller = controller; + return { + playState: "stopped", + currentText: "", + config: DEFAULT_TTS_CONFIG, + onEnd: null, + currentChunkIndex: 0, + totalChunks: 0, + currentBookTitle: "", + currentChapterTitle: "", + currentBookId: "", + currentLocationCfi: "", + sleepTimerEndsAt: null, + sleepTimerDurationMinutes: null, + + play: (text: string | string[]) => { + clearRespeakTimer(); + const config = normalizeTTSConfig(get().config); + const sessionSegments = normalizeSegments(text); + if (sessionSegments.length === 0) return; + set({ + playState: "loading", + currentText: sessionSegments.join(" "), + currentChunkIndex: 0, + totalChunks: sessionSegments.length, + }); + controller.play(sessionSegments, config); + }, - play: (text: string | string[]) => { - clearRespeakTimer(); - const config = normalizeTTSConfig(get().config); - _dashscopeActiveVoice = config.dashscopeVoice; - const segments = Array.isArray(text) - ? text.map((item) => item.trim()).filter(Boolean) - : [text.trim()].filter(Boolean); - const sessionSegments = - segments.length > 0 - ? segments - : [Array.isArray(text) ? text.join(" ").trim() : text.trim()].filter(Boolean); - _sessionSegments = sessionSegments; - _sessionCurrentIndex = 0; - _sessionGeneration += 1; - detachAndStopAllPlayers(); - set({ - playState: "loading", - currentText: sessionSegments.join(" "), - currentChunkIndex: 0, - totalChunks: sessionSegments.length, - }); - - startPlayback(sessionSegments, config, 0, set, get); - }, - - pause: () => { - clearRespeakTimer(); - const { playState } = get(); - if (playState !== "playing" && playState !== "loading") return; - _activeTTS?.pause(); - set({ playState: "paused" }); - }, - - resume: () => { - const config = normalizeTTSConfig(get().config); - const { playState } = get(); - if (playState !== "paused") return; - - // DashScope supports true suspend/resume and derives progress from the audio - // clock (#358), so if it is actually suspended, continue exactly where paused — - // no re-synthesis, no API re-call, no jump. Do NOT bump generation or rebind - // callbacks; the original speak()'s callbacks keep driving progress. - // Edge is intentionally NOT true-resumed here: its highlight notifications are - // wall-clock timers cleared on pause and not rescheduled on resume, so a true - // resume would skip highlights — it stays on the re-speak path below (its main behavior). - if (config.engine === "dashscope" && config.dashscopeApiKey) { - const player = getDashScopeTTS(); - if (player.paused && config.dashscopeVoice === _dashscopeActiveVoice) { - player.resume(); - set({ playState: "playing" }); - return; + preload: (text: string | string[]) => + controller.preload(text, normalizeTTSConfig(get().config)), + + pause: () => { + clearRespeakTimer(); + const { playState } = get(); + if (playState !== "playing" && playState !== "loading") return; + controller.pause(); + }, + + resume: () => { + const { playState } = get(); + if (playState !== "paused") return; + controller.resume(normalizeTTSConfig(get().config)); + }, + + stop: () => { + clearSleepTimerHandle(); + clearRespeakTimer(); + controller.clear(); + set({ + playState: "stopped", + currentText: "", + onEnd: null, + currentChunkIndex: 0, + totalChunks: 0, + currentBookTitle: "", + currentChapterTitle: "", + currentBookId: "", + currentLocationCfi: "", + sleepTimerEndsAt: null, + sleepTimerDurationMinutes: null, + }); + }, + + toggle: (text?: string) => { + const { playState, currentText, play, pause, resume } = get(); + if (playState === "playing" || playState === "loading") { + pause(); + } else if (playState === "paused") { + resume(); + } else if (text) { + play(text); + } else if (currentText) { + play(currentText); } - } - - if (_sessionSegments.length > 0) { - const nextIndex = Math.max( - 0, - Math.min(_sessionCurrentIndex, _sessionSegments.length - 1), - ); - const remainingSegments = _sessionSegments.slice(nextIndex); - if (remainingSegments.length > 0) { - _sessionGeneration += 1; - detachAndStopAllPlayers(); - _sessionCurrentIndex = nextIndex; - _dashscopeActiveVoice = config.dashscopeVoice; - set({ - playState: "loading", - currentChunkIndex: nextIndex, - totalChunks: _sessionSegments.length, - }); - startPlayback(remainingSegments, config, nextIndex, set, get); + }, + + updateConfig: (updates) => { + const previousConfig = normalizeTTSConfig(get().config); + const normalizedUpdates = mergeTTSConfigUpdates(previousConfig, updates); + const nextConfig = normalizeTTSConfig({ ...previousConfig, ...normalizedUpdates }); + const engineChanged = + updates.engine !== undefined && nextConfig.engine !== previousConfig.engine; + const wasPlaying = isActivePlay(get().playState); + set({ config: nextConfig }); + + if (engineChanged && wasPlaying) { + clearRespeakTimer(); + controller.stop(); + set({ playState: "stopped" }); return; } - } - set({ playState: "stopped" }); - }, - - stop: () => { - clearSleepTimerHandle(); - clearRespeakTimer(); - _sessionGeneration += 1; - detachAndStopAllPlayers(); - _sessionSegments = []; - _sessionCurrentIndex = 0; - _dashscopeActiveVoice = undefined; - set({ - playState: "stopped", - currentText: "", - onEnd: null, - currentChunkIndex: 0, - totalChunks: 0, - currentBookTitle: "", - currentChapterTitle: "", - currentBookId: "", - currentLocationCfi: "", - sleepTimerEndsAt: null, - sleepTimerDurationMinutes: null, - }); - }, - - toggle: (text?: string) => { - const { playState, currentText, play, pause, resume } = get(); - if (playState === "playing" || playState === "loading") { - pause(); - } else if (playState === "paused") { - resume(); - } else if (text) { - play(text); - } else if (currentText) { - play(currentText); - } - }, - - updateConfig: (updates) => { - const previousConfig = normalizeTTSConfig(get().config); - const normalizedUpdates = syncProfileUpdatesFromLegacyFields(previousConfig, updates); - const nextConfig = normalizeTTSConfig({ ...previousConfig, ...normalizedUpdates }); - const engineChanged = - updates.engine !== undefined && nextConfig.engine !== previousConfig.engine; - const wasPlaying = isActivePlay(get().playState); - set({ config: nextConfig }); - - if (engineChanged && wasPlaying) { - clearRespeakTimer(); - _sessionGeneration += 1; - detachAndStopAllPlayers(); - _dashscopeActiveVoice = undefined; - set({ playState: "stopped" }); - return; - } - - if ( - shouldRespeakForSynthChange(previousConfig, nextConfig) && - isActivePlay(get().playState) - ) { - scheduleRespeak(); - } else { - // 非重读变更(切引擎、或改了当前引擎不关心的字段)必须取消上一次合成变更排下的 - // 待执行 respeak,否则陈旧防抖定时器会 fire 并强制重启播放。 + + if ( + shouldRespeakForSynthChange(previousConfig, nextConfig) && + isActivePlay(get().playState) + ) { + scheduleRespeak(); + } else { + // 非重读变更(切引擎、或改了当前引擎不关心的字段)必须取消上一次合成变更排下的 + // 待执行 respeak,否则陈旧防抖定时器会 fire 并强制重启播放。 + clearRespeakTimer(); + } + }, + + setPlayState: (playState) => set({ playState }), + + setOnEnd: (cb) => set({ onEnd: cb }), + + setCurrentBook: (title, chapter, bookId) => + set({ + currentBookTitle: title, + currentChapterTitle: chapter, + currentBookId: bookId ?? "", + }), + + setCurrentLocation: (cfi) => set({ currentLocationCfi: cfi ?? "" }), + + setChunkProgress: (index, total) => set({ currentChunkIndex: index, totalChunks: total }), + + jumpToChunk: (index: number) => { clearRespeakTimer(); - } - }, - - setPlayState: (playState) => set({ playState }), - - setOnEnd: (cb) => set({ onEnd: cb }), - - setCurrentBook: (title, chapter, bookId) => - set({ currentBookTitle: title, currentChapterTitle: chapter, currentBookId: bookId ?? "" }), - - setCurrentLocation: (cfi) => set({ currentLocationCfi: cfi ?? "" }), - - setChunkProgress: (index, total) => set({ currentChunkIndex: index, totalChunks: total }), - - jumpToChunk: (index: number) => { - clearRespeakTimer(); - if (index < 0 || index >= _sessionSegments.length) return; - const config = normalizeTTSConfig(get().config); - const remainingSegments = _sessionSegments.slice(index); - if (remainingSegments.length === 0) { - set({ playState: "stopped" }); - return; - } - - _sessionGeneration += 1; - detachAndStopAllPlayers(); - _dashscopeActiveVoice = config.dashscopeVoice; - _sessionCurrentIndex = index; - set({ - playState: "loading", - currentChunkIndex: index, - totalChunks: _sessionSegments.length, - }); - - startPlayback(remainingSegments, config, index, set, get); - }, - - setSleepTimer: (minutes: number) => { - const durationMinutes = Math.max(1, Math.round(minutes)); - const endsAt = Date.now() + durationMinutes * 60_000; - clearSleepTimerHandle(); - _sleepTimerHandle = setTimeout(() => { - _sleepTimerHandle = null; - if (get().sleepTimerEndsAt !== endsAt) return; + if (index < 0 || index >= controller.snapshot().segments.length) return; + const config = normalizeTTSConfig(get().config); + set({ playState: "loading", currentChunkIndex: index }); + controller.jumpTo(index, config); + }, + + setSleepTimer: (minutes: number) => { + const durationMinutes = Math.max(1, Math.round(minutes)); + const endsAt = Date.now() + durationMinutes * 60_000; + clearSleepTimerHandle(); + _sleepTimerHandle = setTimeout(() => { + _sleepTimerHandle = null; + if (get().sleepTimerEndsAt !== endsAt) return; + set({ + sleepTimerEndsAt: null, + sleepTimerDurationMinutes: null, + }); + get().pause(); + }, durationMinutes * 60_000); + set({ + sleepTimerEndsAt: endsAt, + sleepTimerDurationMinutes: durationMinutes, + }); + }, + + clearSleepTimer: () => { + clearSleepTimerHandle(); set({ sleepTimerEndsAt: null, sleepTimerDurationMinutes: null, }); - get().pause(); - }, durationMinutes * 60_000); - set({ - sleepTimerEndsAt: endsAt, - sleepTimerDurationMinutes: durationMinutes, - }); - }, - - clearSleepTimer: () => { - clearSleepTimerHandle(); - set({ - sleepTimerEndsAt: null, - sleepTimerDurationMinutes: null, - }); - }, - }), + }, + }; + }, { playState: "stopped" as const, currentText: "", diff --git a/packages/core/src/tts/cloud-tts.test.ts b/packages/core/src/tts/cloud-tts.test.ts index 20659880d..e1b680887 100644 --- a/packages/core/src/tts/cloud-tts.test.ts +++ b/packages/core/src/tts/cloud-tts.test.ts @@ -1,7 +1,60 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type IPlatformService, setPlatformService } from "../services/platform"; +import { + buildXiaomiTTSUrl, + fetchDashScopeTTSAudio, + getPreloadedTTSAudio, + isTTSAbortError, + preloadTTSChunks, +} from "./cloud-tts"; +import { DEFAULT_TTS_CONFIG, type TTSConfig, type TTSEngine } from "./types"; -import { buildXiaomiTTSUrl, isTTSAbortError } from "./cloud-tts"; -import { DEFAULT_TTS_CONFIG } from "./types"; +function configFor(engine: TTSEngine): TTSConfig { + return { + ...DEFAULT_TTS_CONFIG, + engine, + dashscopeApiKey: "dashscope-key", + xiaomiApiKey: "xiaomi-key", + openaiTtsApiKey: "openai-key", + }; +} + +function createPlatform() { + let socketMessage: ((data: string | ArrayBuffer) => void) | undefined; + let socketSendCount = 0; + const fetch = vi.fn(async (url: string) => { + if (url.includes("dashscope")) { + return Response.json({ output: { audio: { data: "AQID" } } }); + } + if (url.includes("xiaomimimo")) { + return Response.json({ choices: [{ message: { audio: { data: "BAUG" } } }] }); + } + return new Response(new Uint8Array([7, 8, 9]).buffer); + }); + const createWebSocket = vi.fn(async () => ({ + send: () => { + socketSendCount += 1; + if (socketSendCount !== 2) return; + queueMicrotask(() => { + socketMessage?.(new Uint8Array([0, 0, 10, 11, 12]).buffer); + socketMessage?.("Path:turn.end\r\n\r\n"); + }); + }, + close: vi.fn(), + onMessage: (handler: (data: string | ArrayBuffer) => void) => { + socketMessage = handler; + }, + onClose: vi.fn(), + onError: vi.fn(), + })); + + setPlatformService({ fetch, createWebSocket } as unknown as IPlatformService); + return { fetch, createWebSocket }; +} + +afterEach(() => { + setPlatformService(null as unknown as IPlatformService); +}); describe("buildXiaomiTTSUrl", () => { it("uses the default Xiaomi MiMo base URL", () => { @@ -21,9 +74,7 @@ describe("buildXiaomiTTSUrl", () => { describe("isTTSAbortError", () => { it("recognizes abort and cancellation errors from different runtimes", () => { - expect(isTTSAbortError(new DOMException("The operation was aborted", "AbortError"))).toBe( - true, - ); + expect(isTTSAbortError(new DOMException("The operation was aborted", "AbortError"))).toBe(true); expect(isTTSAbortError(new Error("Request cancelled"))).toBe(true); expect(isTTSAbortError(new Error("Request canceled"))).toBe(true); expect(isTTSAbortError({ code: "ERR_CANCELED", message: "canceled" })).toBe(true); @@ -33,3 +84,47 @@ describe("isTTSAbortError", () => { expect(isTTSAbortError(new Error("Xiaomi MiMo TTS failed: 400"))).toBe(false); }); }); + +describe("TTS synthesis preloading", () => { + it("preloads every synthesizable provider and skips system voices", async () => { + const platform = createPlatform(); + const engines: TTSEngine[] = ["edge", "dashscope", "xiaomi", "openai-compatible", "system"]; + + for (const engine of engines) { + const text = `preload-${engine}`; + const config = configFor(engine); + await preloadTTSChunks([text], config); + const cached = getPreloadedTTSAudio(text, config); + if (engine === "system") { + expect(cached).toBeNull(); + } else { + expect(await cached).toBeInstanceOf(Uint8Array); + } + } + + expect(platform.fetch).toHaveBeenCalledTimes(3); + expect(platform.createWebSocket).toHaveBeenCalledOnce(); + }); + + it("deduplicates playback requests against an in-flight preload", async () => { + const platform = createPlatform(); + const config = configFor("dashscope"); + const text = "shared-dashscope-request"; + + await Promise.all([preloadTTSChunks([text], config), fetchDashScopeTTSAudio(text, config)]); + + expect(platform.fetch).toHaveBeenCalledOnce(); + }); + + it("separates cached audio when provider credentials change", async () => { + const platform = createPlatform(); + const firstConfig = configFor("dashscope"); + const secondConfig = { ...firstConfig, dashscopeApiKey: "another-dashscope-key" }; + const text = "credential-specific-audio"; + + await fetchDashScopeTTSAudio(text, firstConfig); + await fetchDashScopeTTSAudio(text, secondConfig); + + expect(platform.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/tts/cloud-tts.ts b/packages/core/src/tts/cloud-tts.ts index 9bd638c77..fcb37897d 100644 --- a/packages/core/src/tts/cloud-tts.ts +++ b/packages/core/src/tts/cloud-tts.ts @@ -1,11 +1,16 @@ import { getPlatformService } from "../services/platform"; +import { fetchEdgeTTSAudio } from "./edge-tts"; import { - DEFAULT_XIAOMI_TTS_BASE_URL, - normalizeXiaomiTTSVoice, - type TTSConfig, -} from "./types"; + buildSynthesisCacheKey, + getCachedSynthesisAudio, + getOrCreateSynthesisAudio, +} from "./synthesis-cache"; +import { DEFAULT_XIAOMI_TTS_BASE_URL, type TTSConfig, normalizeXiaomiTTSVoice } from "./types"; export const CLOUD_TTS_PCM_SAMPLE_RATE = 24000; +const DASHSCOPE_TTS_URL = + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"; +const PRELOAD_CHUNK_LIMIT = 4; export function base64ToBytes(base64: string): Uint8Array { const binary = atob(base64); @@ -43,8 +48,7 @@ export function isTTSAbortError(error: unknown): boolean { const maybeError = error as { name?: unknown; code?: unknown; message?: unknown }; if (maybeError.name === "AbortError") return true; if (maybeError.code === "ERR_CANCELED" || maybeError.code === "ABORT_ERR") return true; - const message = - typeof maybeError.message === "string" ? maybeError.message : String(error); + const message = typeof maybeError.message === "string" ? maybeError.message : String(error); return /\b(aborted?|cancel(?:ed|led))\b/iu.test(message); } @@ -79,7 +83,54 @@ export function buildOpenAIChatTTSMessages(text: string, config: TTSConfig) { ]; } -export async function fetchXiaomiTTSWav(text: string, config: TTSConfig): Promise { +function credentialCachePartition(credential: string): string { + let hash = 2166136261; + for (let index = 0; index < credential.length; index++) { + hash ^= credential.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `${credential.length}:${(hash >>> 0).toString(16)}`; +} + +function getSynthesisKey(text: string, config: TTSConfig): string | null { + switch (config.engine) { + case "edge": { + const voice = config.edgeVoice || "zh-CN-XiaoxiaoNeural"; + const lang = voice.split("-").slice(0, 2).join("-"); + return buildSynthesisCacheKey("edge", text, [voice, lang, config.rate, config.pitch]); + } + case "dashscope": + return buildSynthesisCacheKey("dashscope", text, [ + "qwen3-tts-flash", + config.dashscopeVoice, + "mp3", + credentialCachePartition(config.dashscopeApiKey), + ]); + case "xiaomi": + return buildSynthesisCacheKey("xiaomi", text, [ + config.xiaomiBaseUrl || DEFAULT_XIAOMI_TTS_BASE_URL, + "mimo-v2.5-tts", + normalizeXiaomiTTSVoice(config.xiaomiVoice), + config.xiaomiStylePrompt, + "wav", + credentialCachePartition(config.xiaomiApiKey), + ]); + case "openai-compatible": + return buildSynthesisCacheKey("openai-compatible", text, [ + config.openaiTtsBaseUrl, + config.openaiTtsEndpoint, + config.openaiTtsModel, + config.openaiTtsVoice, + config.openaiTtsFormat, + config.openaiTtsStylePrompt, + credentialCachePartition(config.openaiTtsApiKey), + ]); + case "system": + return null; + } +} + +async function fetchXiaomiTTSWavUncached(text: string, config: TTSConfig): Promise { if (!config.xiaomiApiKey) throw new Error("Xiaomi MiMo API key is required"); const platform = getPlatformService(); @@ -111,7 +162,13 @@ export async function fetchXiaomiTTSWav(text: string, config: TTSConfig): Promis return base64ToBytes(audioData); } -export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Promise { +export function fetchXiaomiTTSWav(text: string, config: TTSConfig): Promise { + const key = getSynthesisKey(text, { ...config, engine: "xiaomi" }); + if (!key) throw new Error("Unable to cache Xiaomi MiMo TTS audio"); + return getOrCreateSynthesisAudio(key, () => fetchXiaomiTTSWavUncached(text, config)); +} + +async function fetchOpenAITTSAudioUncached(text: string, config: TTSConfig): Promise { if (!config.openaiTtsApiKey) throw new Error("OpenAI-compatible TTS API key is required"); const platform = getPlatformService(); @@ -158,3 +215,99 @@ export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Prom return new Uint8Array(await response.arrayBuffer()); } + +export function fetchOpenAITTSAudio(text: string, config: TTSConfig): Promise { + const key = getSynthesisKey(text, { ...config, engine: "openai-compatible" }); + if (!key) throw new Error("Unable to cache OpenAI-compatible TTS audio"); + return getOrCreateSynthesisAudio(key, () => fetchOpenAITTSAudioUncached(text, config)); +} + +async function fetchDashScopeTTSAudioUncached( + text: string, + config: TTSConfig, +): Promise { + if (!config.dashscopeApiKey) throw new Error("DashScope API key is required"); + + const response = await getPlatformService().fetch(DASHSCOPE_TTS_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.dashscopeApiKey}`, + }, + body: JSON.stringify({ + model: "qwen3-tts-flash", + input: { text, voice: config.dashscopeVoice }, + parameters: { response_format: "mp3" }, + }), + }); + + if (!response.ok) throw await buildTTSHttpError("DashScope TTS", response); + const result = (await response.json()) as { + output?: { audio?: { data?: string } }; + }; + const audioData = result.output?.audio?.data; + if (!audioData) throw new Error("No audio data in DashScope response"); + return base64ToBytes(audioData); +} + +export function fetchDashScopeTTSAudio(text: string, config: TTSConfig): Promise { + const key = getSynthesisKey(text, { ...config, engine: "dashscope" }); + if (!key) throw new Error("Unable to cache DashScope TTS audio"); + return getOrCreateSynthesisAudio(key, () => fetchDashScopeTTSAudioUncached(text, config)); +} + +export function getPreloadedTTSAudio(text: string, config: TTSConfig): Promise | null { + const key = getSynthesisKey(text, config); + return key ? getCachedSynthesisAudio(key) : null; +} + +async function synthesizeTTSAudio(text: string, config: TTSConfig): Promise { + switch (config.engine) { + case "edge": { + const voice = config.edgeVoice || "zh-CN-XiaoxiaoNeural"; + await fetchEdgeTTSAudio({ + text, + voice, + lang: voice.split("-").slice(0, 2).join("-"), + rate: config.rate, + pitch: config.pitch, + }); + return; + } + case "dashscope": + await fetchDashScopeTTSAudio(text, config); + return; + case "xiaomi": + await fetchXiaomiTTSWav(text, config); + return; + case "openai-compatible": + await fetchOpenAITTSAudio(text, config); + return; + case "system": + return; + } +} + +export async function preloadTTSChunks( + segments: string[], + config: TTSConfig, + limit = PRELOAD_CHUNK_LIMIT, +): Promise { + const candidates = segments + .map((text) => text.trim()) + .filter(Boolean) + .slice(0, limit); + await Promise.all( + candidates.map(async (text) => { + try { + await synthesizeTTSAudio(text, config); + } catch (error) { + console.warn("[TTS] preload failed", { + engine: config.engine, + text: text.slice(0, 160), + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); +} diff --git a/packages/core/src/tts/configuration.ts b/packages/core/src/tts/configuration.ts new file mode 100644 index 000000000..ee528549c --- /dev/null +++ b/packages/core/src/tts/configuration.ts @@ -0,0 +1,52 @@ +import type { TTSConfig, TTSProfile } from "./types"; + +/** Maps legacy flat settings updates into the active provider profile. */ +export function mergeTTSConfigUpdates( + previousConfig: TTSConfig, + updates: Partial, +): Partial { + const provider = updates.engine ?? previousConfig.engine; + const profileId = updates.activeProfileId ?? previousConfig.activeProfileId; + const profileUpdates: Partial = {}; + + if (provider === "edge" && updates.edgeVoice !== undefined) { + profileUpdates.voice = updates.edgeVoice; + } else if (provider === "system" && updates.voiceName !== undefined) { + profileUpdates.voice = updates.voiceName; + } else if (provider === "dashscope") { + if (updates.dashscopeApiKey !== undefined) profileUpdates.apiKey = updates.dashscopeApiKey; + if (updates.dashscopeVoice !== undefined) profileUpdates.voice = updates.dashscopeVoice; + } else if (provider === "xiaomi") { + if (updates.xiaomiBaseUrl !== undefined) profileUpdates.baseUrl = updates.xiaomiBaseUrl; + if (updates.xiaomiApiKey !== undefined) profileUpdates.apiKey = updates.xiaomiApiKey; + if (updates.xiaomiVoice !== undefined) profileUpdates.voice = updates.xiaomiVoice; + if (updates.xiaomiStylePrompt !== undefined) + profileUpdates.stylePrompt = updates.xiaomiStylePrompt; + } else if (provider === "openai-compatible") { + if (updates.openaiTtsBaseUrl !== undefined) profileUpdates.baseUrl = updates.openaiTtsBaseUrl; + if (updates.openaiTtsApiKey !== undefined) profileUpdates.apiKey = updates.openaiTtsApiKey; + if (updates.openaiTtsEndpoint !== undefined) + profileUpdates.endpoint = updates.openaiTtsEndpoint; + if (updates.openaiTtsModel !== undefined) profileUpdates.model = updates.openaiTtsModel; + if (updates.openaiTtsVoice !== undefined) profileUpdates.voice = updates.openaiTtsVoice; + if (updates.openaiTtsFormat !== undefined) profileUpdates.format = updates.openaiTtsFormat; + if (updates.openaiTtsStylePrompt !== undefined) + profileUpdates.stylePrompt = updates.openaiTtsStylePrompt; + } + + if (!Object.keys(profileUpdates).length) return updates; + const profiles = updates.profiles ?? previousConfig.profiles; + const requested = profiles.find((profile) => profile.id === profileId); + const targetId = + requested?.provider === provider + ? requested.id + : profiles.find((profile) => profile.provider === provider)?.id; + if (!targetId) return updates; + + return { + ...updates, + profiles: profiles.map((profile) => + profile.id === targetId ? { ...profile, ...profileUpdates } : profile, + ), + }; +} diff --git a/packages/core/src/tts/edge-tts.ts b/packages/core/src/tts/edge-tts.ts index a4bf5ec33..cf180bbbb 100644 --- a/packages/core/src/tts/edge-tts.ts +++ b/packages/core/src/tts/edge-tts.ts @@ -13,6 +13,7 @@ import { parseEdgeTTSMetadataBody, parseEdgeTTSTextFrame, } from "./edge-tts-metadata"; +import { buildSynthesisCacheKey, getOrCreateSynthesisAudio } from "./synthesis-cache"; // ── Constants ── const EDGE_SPEECH_URL = @@ -23,7 +24,6 @@ const CHROMIUM_MAJOR_VERSION = "143"; const WIN_EPOCH_OFFSET = 11644473600n; // BigInt const S_TO_NS = 1000000000n; // BigInt -const EDGE_TTS_AUDIO_CACHE_LIMIT = 48; // ── Voice list ── const EDGE_TTS_VOICE_MAP: Record = { @@ -232,28 +232,13 @@ function formatEdgeTTSError(error: unknown): string { return String(error); } -const edgeTtsAudioCache = new Map(); -const edgeTtsInflightCache = new Map>(); - function getEdgeTTSPayloadKey(payload: EdgeTTSPayload): string { - return JSON.stringify([payload.voice, payload.lang, payload.rate, payload.pitch, payload.text]); -} - -function cloneAudioBuffer(audioData: ArrayBuffer): ArrayBuffer { - return audioData.slice(0); -} - -function touchEdgeTTSAudioCache(key: string, audioData: ArrayBuffer) { - if (edgeTtsAudioCache.has(key)) { - edgeTtsAudioCache.delete(key); - } - edgeTtsAudioCache.set(key, audioData); - - while (edgeTtsAudioCache.size > EDGE_TTS_AUDIO_CACHE_LIMIT) { - const oldestKey = edgeTtsAudioCache.keys().next().value; - if (!oldestKey) break; - edgeTtsAudioCache.delete(oldestKey); - } + return buildSynthesisCacheKey("edge", payload.text, [ + payload.voice, + payload.lang, + payload.rate, + payload.pitch, + ]); } /** @@ -376,9 +361,7 @@ async function fetchEdgeTTSAudioCore( const responseInfo = lastResponseBody ? ` server response: ${lastResponseBody.slice(0, 200)}` : ""; - return new Error( - `Edge TTS returned no audio.${responseInfo} ssml="${textPreview}"`, - ); + return new Error(`Edge TTS returned no audio.${responseInfo} ssml="${textPreview}"`); }; ws.onMessage((data) => { @@ -486,29 +469,11 @@ async function fetchEdgeTTSAudioUncached(payload: EdgeTTSPayload): Promise { const cacheKey = getEdgeTTSPayloadKey(payload); - const cachedAudio = edgeTtsAudioCache.get(cacheKey); - if (cachedAudio) { - touchEdgeTTSAudioCache(cacheKey, cachedAudio); - return cloneAudioBuffer(cachedAudio); - } - - const inflight = edgeTtsInflightCache.get(cacheKey); - if (inflight) { - return cloneAudioBuffer(await inflight); - } - - const request = fetchEdgeTTSAudioUncached(payload) - .then((audioData) => { - const cachedCopy = audioData.slice(0); - touchEdgeTTSAudioCache(cacheKey, cachedCopy); - return cachedCopy; - }) - .finally(() => { - edgeTtsInflightCache.delete(cacheKey); - }); - - edgeTtsInflightCache.set(cacheKey, request); - return cloneAudioBuffer(await request); + const audio = await getOrCreateSynthesisAudio(cacheKey, async () => { + const synthesized = await fetchEdgeTTSAudioUncached(payload); + return new Uint8Array(synthesized.slice(0)); + }); + return audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength) as ArrayBuffer; } // ── Phase 1 spike: SSML with bookmarks + metadata-aware fetch ── diff --git a/packages/core/src/tts/index.ts b/packages/core/src/tts/index.ts index 8b0749492..1d46302ae 100644 --- a/packages/core/src/tts/index.ts +++ b/packages/core/src/tts/index.ts @@ -39,7 +39,33 @@ export { } from "./text-utils"; export { buildNarrationPreview, getTTSVoiceLabel, splitNarrationText } from "./display"; export { compareVoiceLanguage, getLocaleDisplayLabel, groupEdgeTTSVoices } from "./voice-groups"; - +export { SegmentPlanner } from "./segment-planner"; +export type { Segment, SegmentPosition, SegmentPlannerOptions } from "./segment-planner"; +export { TTSReadingSession } from "./reading-session"; +export type { + TTSReadingJump, + TTSReadingMode, + TTSReadingSegment, + TTSReadingSnapshot, +} from "./reading-session"; +export { TTSPlaybackController } from "./playback-controller"; +export type { + TTSControllerState, + TTSPlaybackCallbacks, + TTSPlaybackSnapshot, + TTSPlayerResolver, + TTSPreloader, +} from "./playback-controller"; +export { mergeTTSConfigUpdates } from "./configuration"; +export { + findTTSLyricIndex, + getTTSLyricKey, + mergeTTSLyricContext, + mergeTTSLyrics, + normalizeTTSLyricSegment, + normalizeTTSLyrics, +} from "./lyrics"; +export type { TTSLyricDirection, TTSLyricSegment } from "./lyrics"; // Edge TTS export { fetchEdgeTTSAudio, EDGE_TTS_VOICES } from "./edge-tts"; export type { EdgeTTSVoice, EdgeTTSPayload } from "./edge-tts"; @@ -58,8 +84,11 @@ export { buildXiaomiTTSUrl, buildXiaomiTTSMessages, fetchOpenAITTSAudio, + fetchDashScopeTTSAudio, fetchXiaomiTTSWav, + getPreloadedTTSAudio, isTTSAbortError, + preloadTTSChunks, } from "./cloud-tts"; // Re-speak on synthesis-param change (#370) diff --git a/packages/core/src/tts/lyrics.test.ts b/packages/core/src/tts/lyrics.test.ts new file mode 100644 index 000000000..5f63a8da7 --- /dev/null +++ b/packages/core/src/tts/lyrics.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + findTTSLyricIndex, + mergeTTSLyricContext, + mergeTTSLyrics, + normalizeTTSLyrics, +} from "./lyrics"; + +describe("TTS lyric model", () => { + it("normalizes text and preserves repeated selection lines", () => { + expect( + normalizeTTSLyrics([ + { text: " one two ", cfi: "a" }, + { text: "one two", cfi: "a" }, + { text: "same", cfi: null }, + { text: "same", cfi: null }, + ]), + ).toEqual([ + { text: "one two", cfi: "a" }, + { text: "same", cfi: null }, + { text: "same", cfi: null }, + ]); + }); + + it("appends and prepends discovered context without coupling to cache eviction", () => { + const initial = [{ text: "current", cfi: "b" }]; + expect(mergeTTSLyrics(initial, [{ text: "next", cfi: "c" }])).toEqual([ + { text: "current", cfi: "b" }, + { text: "next", cfi: "c" }, + ]); + expect(mergeTTSLyrics(initial, [{ text: "previous", cfi: "a" }], "above")).toEqual([ + { text: "previous", cfi: "a" }, + { text: "current", cfi: "b" }, + ]); + }); + + it("merges a context window in document order and locates by CFI first", () => { + const lyrics = mergeTTSLyricContext( + [], + [{ text: "before", cfi: "a" }], + [{ text: "current", cfi: "b" }], + [{ text: "after", cfi: "c" }], + ); + expect(lyrics.map((item) => item.text)).toEqual(["before", "current", "after"]); + expect(findTTSLyricIndex(lyrics, { cfi: "b", text: "wrong" })).toBe(1); + }); +}); diff --git a/packages/core/src/tts/lyrics.ts b/packages/core/src/tts/lyrics.ts new file mode 100644 index 000000000..c278046ec --- /dev/null +++ b/packages/core/src/tts/lyrics.ts @@ -0,0 +1,95 @@ +/** + * Persistent lyric data for the reader UI. + * + * Lyric discovery is independent from audio synthesis. A provider may evict + * an audio buffer from its LRU while the reader keeps the corresponding lyric + * entry and can request the audio again when the user taps it. + */ +export interface TTSLyricSegment { + text: string; + cfi: string | null; +} + +export type TTSLyricDirection = "above" | "below"; + +function normalizeText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +export function normalizeTTSLyricSegment(segment: { + text?: string | null; + cfi?: string | null; +}): TTSLyricSegment | null { + const text = normalizeText(segment.text || ""); + if (!text) return null; + return { text, cfi: segment.cfi || null }; +} + +export function getTTSLyricKey(segment: { text: string; cfi?: string | null }): string { + return `${segment.cfi || ""}::${normalizeText(segment.text)}`; +} + +export function normalizeTTSLyrics( + segments: Array<{ text?: string | null; cfi?: string | null }>, +): TTSLyricSegment[] { + const seen = new Set(); + const result: TTSLyricSegment[] = []; + for (const raw of segments) { + const segment = normalizeTTSLyricSegment(raw); + if (!segment) continue; + // Selection text has no stable CFI; repeated sentences are meaningful in + // that mode, so only deduplicate entries that have a document identity. + if (!segment.cfi) { + result.push(segment); + continue; + } + const key = getTTSLyricKey(segment); + if (seen.has(key)) continue; + seen.add(key); + result.push(segment); + } + return result; +} + +export function mergeTTSLyrics( + existing: TTSLyricSegment[], + incoming: Array<{ text?: string | null; cfi?: string | null }>, + direction: TTSLyricDirection = "below", +): TTSLyricSegment[] { + const next = normalizeTTSLyrics(incoming); + if (!next.length) return existing; + + const known = new Set(existing.filter((segment) => segment.cfi).map(getTTSLyricKey)); + const unique = next.filter((segment) => { + if (!segment.cfi) return true; + const key = getTTSLyricKey(segment); + if (known.has(key)) return false; + known.add(key); + return true; + }); + if (!unique.length) return existing; + return direction === "above" ? [...unique, ...existing] : [...existing, ...unique]; +} + +export function mergeTTSLyricContext( + existing: TTSLyricSegment[], + before: Array<{ text?: string | null; cfi?: string | null }>, + current: Array<{ text?: string | null; cfi?: string | null }>, + after: Array<{ text?: string | null; cfi?: string | null }>, +): TTSLyricSegment[] { + return mergeTTSLyrics(mergeTTSLyrics(mergeTTSLyrics(existing, before, "above"), current), after); +} + +export function findTTSLyricIndex( + lyrics: Array<{ text: string; cfi?: string | null }>, + target: { cfi?: string | null; text?: string | null }, +): number { + const cfi = target.cfi || null; + if (cfi) { + const byCfi = lyrics.findIndex((segment) => segment.cfi === cfi); + if (byCfi >= 0) return byCfi; + } + const text = normalizeText(target.text || ""); + if (!text) return -1; + return lyrics.findIndex((segment) => segment.text === text); +} diff --git a/packages/core/src/tts/playback-controller.test.ts b/packages/core/src/tts/playback-controller.test.ts new file mode 100644 index 000000000..12c16895c --- /dev/null +++ b/packages/core/src/tts/playback-controller.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { TTSPlaybackController } from "./playback-controller"; +import type { ITTSPlayer, TTSConfig } from "./types"; + +const config = { engine: "edge", rate: 1, pitch: 1 } as TTSConfig; + +function makePlayer() { + const player: ITTSPlayer = { + speak: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + stop: vi.fn(), + }; + return player; +} + +describe("TTSPlaybackController", () => { + it("owns queue progress and ignores callbacks from a stopped generation", () => { + const player = makePlayer(); + const onSegmentChange = vi.fn(); + const controller = new TTSPlaybackController(() => player, { onSegmentChange }); + + expect(controller.play(["one", "two"], config)).toBe(true); + player.onChunkChange?.(1, 2); + expect(controller.snapshot().currentIndex).toBe(1); + expect(onSegmentChange).toHaveBeenLastCalledWith(1, 2, "two"); + + const staleEnd = player.onEnd; + controller.stop(); + staleEnd?.(); + expect(controller.snapshot().state).toBe("stopped"); + expect(onSegmentChange).toHaveBeenCalledTimes(1); + }); + + it("jumps without changing the absolute queue index", () => { + const player = makePlayer(); + const controller = new TTSPlaybackController(() => player); + controller.play(["one", "two", "three"], config); + expect(controller.jumpTo(2, config)).toBe(true); + expect(player.speak).toHaveBeenLastCalledWith(["three"], config); + expect(controller.snapshot().currentIndex).toBe(2); + }); + + it("restarts the current segment instead of resuming mid-segment", () => { + const firstPlayer = makePlayer(); + const secondPlayer = makePlayer(); + const resolvePlayer = vi + .fn() + .mockReturnValueOnce(firstPlayer) + .mockReturnValueOnce(secondPlayer); + const controller = new TTSPlaybackController(resolvePlayer); + + controller.play(["one", "two", "three"], config); + firstPlayer.onChunkChange?.(1, 3); + controller.pause(); + controller.resume(config); + + expect(firstPlayer.pause).toHaveBeenCalledOnce(); + expect(firstPlayer.stop).toHaveBeenCalledOnce(); + expect(firstPlayer.resume).not.toHaveBeenCalled(); + expect(resolvePlayer).toHaveBeenCalledTimes(2); + expect(secondPlayer.speak).toHaveBeenCalledWith(["two", "three"], config); + expect(controller.snapshot().currentIndex).toBe(1); + }); + + it("appends only through a player that supports queue extension", () => { + const player = makePlayer(); + player.append = vi.fn(); + const controller = new TTSPlaybackController(() => player); + controller.play("one. two.", config); + expect(controller.append(["three"])).toBe(true); + expect(player.append).toHaveBeenCalledWith(["three"]); + expect(controller.snapshot().segments).toEqual(["one.", "two.", "three"]); + }); + + it("preloads without replacing the active queue", () => { + const player = makePlayer(); + const preload = vi.fn(); + const controller = new TTSPlaybackController(() => player, {}, preload); + controller.play(["current"], config); + + expect(controller.preload(["next one", "next two"], config)).toBe(true); + expect(preload).toHaveBeenCalledWith(["next one", "next two"], config); + expect(controller.snapshot().segments).toEqual(["current"]); + }); + + it("reports player construction failures without throwing from play", () => { + const error = new Error("provider unavailable"); + const onError = vi.fn(); + const controller = new TTSPlaybackController( + () => { + throw error; + }, + { onError }, + ); + + expect(controller.play("one", config)).toBe(true); + expect(controller.snapshot().state).toBe("stopped"); + expect(onError).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/core/src/tts/playback-controller.ts b/packages/core/src/tts/playback-controller.ts new file mode 100644 index 000000000..2b2b51284 --- /dev/null +++ b/packages/core/src/tts/playback-controller.ts @@ -0,0 +1,252 @@ +import { splitNarrationText } from "./display"; +import type { ITTSPlayer, TTSConfig } from "./types"; + +export type TTSControllerState = "playing" | "paused" | "stopped"; + +export interface TTSPlaybackSnapshot { + state: TTSControllerState; + segments: string[]; + currentIndex: number; +} + +export interface TTSPlaybackCallbacks { + onStateChange?: (state: TTSControllerState) => void; + onSegmentChange?: (index: number, total: number, text: string) => void; + onEnd?: () => void; + onError?: (error: Error) => void; + configurePlayer?: (player: ITTSPlayer) => void; +} + +export type TTSPlayerResolver = (config: TTSConfig) => ITTSPlayer; +export type TTSPreloader = (segments: string[], config: TTSConfig) => void | Promise; + +function normalizeSegments(input: string | string[]): string[] { + const values = Array.isArray(input) ? input : splitNarrationText(input); + return values.map((value) => value.replace(/\s+/g, " ").trim()).filter(Boolean); +} + +/** + * Platform-neutral playback application service. + * + * Foliate/document adapters decide which text belongs to a session. This + * service only owns the ordered narration queue and guards callbacks from + * stale players after stop, jump, or provider changes. + */ +export class TTSPlaybackController { + private player: ITTSPlayer | null = null; + private segments: string[] = []; + private currentIndex = 0; + private generation = 0; + private starting = false; + private state: TTSControllerState = "stopped"; + + constructor( + private readonly resolvePlayer: TTSPlayerResolver, + private readonly callbacks: TTSPlaybackCallbacks = {}, + private readonly preloadSegments?: TTSPreloader, + ) {} + + snapshot(): TTSPlaybackSnapshot { + return { + state: this.state, + segments: [...this.segments], + currentIndex: this.currentIndex, + }; + } + + play(input: string | string[], config: TTSConfig): boolean { + const segments = normalizeSegments(input); + if (!segments.length) return false; + console.log("[TTSPlaybackController][play] request", { + inputType: Array.isArray(input) ? "array" : "text", + count: segments.length, + segments: segments.map((text, index) => ({ index, text: text.slice(0, 160) })), + engine: config.engine, + }); + this.stop(); + this.segments = segments; + this.currentIndex = 0; + this.startFrom(0, config); + return true; + } + + append(input: string | string[]): boolean { + const segments = normalizeSegments(input); + if (!segments.length || !this.player?.append) return false; + try { + const result = this.player.append(segments); + void Promise.resolve(result).catch((error) => + this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error))), + ); + this.segments = [...this.segments, ...segments]; + return true; + } catch (error) { + this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error))); + return false; + } + } + + preload(input: string | string[], config: TTSConfig): boolean { + const segments = normalizeSegments(input); + if (!segments.length || !this.preloadSegments) return false; + console.log("[TTSPlaybackController][preload] request", { + count: segments.length, + segments: segments.map((text, index) => ({ index, text: text.slice(0, 160) })), + engine: config.engine, + }); + try { + void Promise.resolve(this.preloadSegments(segments, config)).catch((error) => + this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error))), + ); + return true; + } catch (error) { + this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error))); + return false; + } + } + + pause(): void { + // A cloud/native player may still be preparing its first audio chunk while + // the store is already in loading state. Pause must reach that player too. + if (!this.player || this.state === "paused" || this.segments.length === 0) return; + this.player.pause(); + this.setState("paused"); + } + + resume(config: TTSConfig): void { + if (!this.segments.length) return; + const index = Math.min(this.currentIndex, this.segments.length - 1); + + if (this.state === "paused" && this.player) { + this.detachPlayer(); + this.startFrom(index, config); + return; + } + if (this.state === "stopped") { + this.startFrom(index, config); + } + } + + jumpTo(index: number, config: TTSConfig): boolean { + if (!Number.isInteger(index) || index < 0 || index >= this.segments.length) return false; + this.stop(); + this.currentIndex = index; + this.startFrom(index, config); + return true; + } + + stop(): void { + this.generation += 1; + this.detachPlayer(); + this.state = "stopped"; + } + + clear(): void { + this.stop(); + this.segments = []; + this.currentIndex = 0; + } + + private startFrom(index: number, config: TTSConfig): void { + const generation = ++this.generation; + let player: ITTSPlayer; + try { + player = this.resolvePlayer(config); + } catch (error) { + this.fail(generation, error); + return; + } + this.player = player; + console.log("[TTSPlaybackController][start]", { + generation, + startIndex: index, + total: this.segments.length, + segments: this.segments.map((text, segmentIndex) => ({ + index: segmentIndex, + text: text.slice(0, 160), + })), + }); + this.callbacks.configurePlayer?.(player); + this.starting = true; + this.bindPlayer(player, generation, index); + try { + const result = player.speak(this.segments.slice(index), config); + this.starting = false; + void Promise.resolve(result).catch((error) => this.fail(generation, error)); + } catch (error) { + this.starting = false; + this.fail(generation, error); + } + } + + private bindPlayer(player: ITTSPlayer, generation: number, startIndex: number): void { + player.onStateChange = (state) => { + if (generation !== this.generation) return; + console.log("[TTSPlaybackController][state]", { generation, state }); + if (this.starting && state === "stopped") return; + this.setState(state); + }; + player.onChunkChange = (index) => { + if (generation !== this.generation) return; + this.currentIndex = startIndex + index; + console.log("[TTSPlaybackController][chunk]", { + generation, + playerIndex: index, + currentIndex: this.currentIndex, + total: this.segments.length, + text: this.segments[this.currentIndex]?.slice(0, 160) ?? "", + }); + this.callbacks.onSegmentChange?.( + this.currentIndex, + this.segments.length, + this.segments[this.currentIndex] ?? "", + ); + }; + player.onError = (error) => this.fail(generation, error); + player.onEnd = () => { + if (generation !== this.generation) return; + console.log("[TTSPlaybackController][end]", { + generation, + currentIndex: this.currentIndex, + total: this.segments.length, + currentText: this.segments[this.currentIndex]?.slice(0, 160) ?? "", + }); + this.currentIndex = Math.max(0, this.segments.length - 1); + this.detachPlayer(); + this.setState("stopped"); + this.callbacks.onSegmentChange?.( + this.currentIndex, + this.segments.length, + this.segments[this.currentIndex] ?? "", + ); + this.callbacks.onEnd?.(); + }; + } + + private fail(generation: number, error: unknown): void { + if (generation !== this.generation) return; + this.detachPlayer(); + this.setState("stopped"); + this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error))); + } + + private detachPlayer(): void { + if (!this.player) return; + const player = this.player; + this.player = null; + player.onStateChange = undefined; + player.onChunkChange = undefined; + player.onError = undefined; + player.onEnd = undefined; + try { + player.stop(); + } catch { + // A stale platform player must not break store cleanup. + } + } + + private setState(state: TTSControllerState): void { + this.state = state; + this.callbacks.onStateChange?.(state); + } +} diff --git a/packages/core/src/tts/reading-session.test.ts b/packages/core/src/tts/reading-session.test.ts new file mode 100644 index 000000000..74f99f9d6 --- /dev/null +++ b/packages/core/src/tts/reading-session.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { TTSReadingSession } from "./reading-session"; + +const segment = (text: string, cfi = text) => ({ text, cfi }); + +describe("TTSReadingSession", () => { + it("normalizes and deduplicates the active queue", () => { + const session = new TTSReadingSession(); + const snapshot = session.start("page", [ + segment(" one ", "one"), + segment("one", "one"), + segment("two"), + ]); + + expect(snapshot.current).toEqual([segment("one"), segment("two")]); + expect(snapshot.previous).toEqual([]); + expect(snapshot.future).toEqual([]); + }); + + it("keeps selection sessions non-continuous", () => { + const session = new TTSReadingSession(); + expect(session.start("selection", [segment("selected")], true).continuous).toBe(false); + }); + + it("keeps repeated selection sentences", () => { + const session = new TTSReadingSession(); + const snapshot = session.start("selection", [segment("repeat", ""), segment("repeat", "")]); + expect(snapshot.current.map((item) => item.text)).toEqual(["repeat", "repeat"]); + }); + + it("moves a previous lyric into the active queue", () => { + const session = new TTSReadingSession(); + session.start("page", [segment("current")]); + session.setContext([segment("previous")], [segment("future")]); + + const jump = session.jump(-1); + expect(jump.kind).toBe("outside"); + expect(session.snapshot().current.map((item) => item.text)).toEqual([ + "previous", + "current", + "future", + ]); + expect(session.snapshot().previous).toEqual([]); + }); + + it("moves a future lyric to the active queue without replaying consumed text", () => { + const session = new TTSReadingSession(); + session.start("page", [segment("current-1"), segment("current-2")]); + session.setContext([segment("previous")], [segment("future-1"), segment("future-2")]); + + const jump = session.jump(3); + expect(jump.kind).toBe("outside"); + expect(session.snapshot().current.map((item) => item.text)).toEqual(["future-2"]); + expect(session.snapshot().previous.map((item) => item.text)).toEqual([ + "previous", + "current-1", + "current-2", + "future-1", + ]); + }); +}); diff --git a/packages/core/src/tts/reading-session.ts b/packages/core/src/tts/reading-session.ts new file mode 100644 index 000000000..41904e394 --- /dev/null +++ b/packages/core/src/tts/reading-session.ts @@ -0,0 +1,172 @@ +export type TTSReadingMode = "page" | "selection"; + +export interface TTSReadingSegment { + text: string; + cfi: string | null; +} + +export interface TTSReadingSnapshot { + mode: TTSReadingMode; + continuous: boolean; + previous: TTSReadingSegment[]; + current: TTSReadingSegment[]; + future: TTSReadingSegment[]; +} + +export type TTSReadingJump = + | { kind: "current"; index: number } + | { kind: "outside"; target: TTSReadingSegment; snapshot: TTSReadingSnapshot } + | { kind: "none" }; + +function segmentIdentity(segment: TTSReadingSegment): string { + return `${segment.cfi || ""}::${segment.text.replace(/\s+/g, " ").trim()}`; +} + +function normalizeSegments(segments: TTSReadingSegment[]): TTSReadingSegment[] { + const seen = new Set(); + const result: TTSReadingSegment[] = []; + for (const segment of segments) { + const text = segment.text.replace(/\s+/g, " ").trim(); + if (!text) continue; + const normalized = { text, cfi: segment.cfi || null }; + // Selection segments do not have a CFI. Repeated sentences are valid + // there, so only use identity-based deduplication when a stable CFI exists. + if (!normalized.cfi) { + result.push(normalized); + continue; + } + const identity = segmentIdentity(normalized); + if (seen.has(identity)) continue; + seen.add(identity); + result.push(normalized); + } + return result; +} + +export class TTSReadingSession { + private state: TTSReadingSnapshot = { + mode: "page", + continuous: true, + previous: [], + current: [], + future: [], + }; + + snapshot(): TTSReadingSnapshot { + return { + mode: this.state.mode, + continuous: this.state.continuous, + previous: [...this.state.previous], + current: [...this.state.current], + future: [...this.state.future], + }; + } + + reset(): TTSReadingSnapshot { + this.state = { + mode: "page", + continuous: true, + previous: [], + current: [], + future: [], + }; + return this.snapshot(); + } + + start( + mode: TTSReadingMode, + current: TTSReadingSegment[], + continuous = mode === "page", + ): TTSReadingSnapshot { + this.state = { + mode, + continuous: mode === "page" && continuous, + previous: [], + current: normalizeSegments(current), + future: [], + }; + return this.snapshot(); + } + + replaceCurrent(current: TTSReadingSegment[]): TTSReadingSnapshot { + this.state = { + ...this.state, + current: normalizeSegments(current), + future: [], + }; + return this.snapshot(); + } + + setContinuous(continuous: boolean): TTSReadingSnapshot { + this.state = { + ...this.state, + continuous: this.state.mode === "page" && continuous, + }; + return this.snapshot(); + } + + setContext(previous: TTSReadingSegment[], future: TTSReadingSegment[]): TTSReadingSnapshot { + this.state = { + ...this.state, + previous: normalizeSegments(previous), + future: normalizeSegments(future), + }; + return this.snapshot(); + } + + appendPrevious(segments: TTSReadingSegment[]): TTSReadingSnapshot { + return this.setContext([...segments, ...this.state.previous], this.state.future); + } + + appendFuture(segments: TTSReadingSegment[]): TTSReadingSnapshot { + return this.setContext(this.state.previous, [...this.state.future, ...segments]); + } + + clearFuture(): TTSReadingSnapshot { + return this.setContext(this.state.previous, []); + } + + jump(offsetFromCurrent: number): TTSReadingJump { + if (!Number.isInteger(offsetFromCurrent)) return { kind: "none" }; + if (offsetFromCurrent >= 0 && offsetFromCurrent < this.state.current.length) { + return { kind: "current", index: offsetFromCurrent }; + } + + if (offsetFromCurrent < 0) { + const previousIndex = this.state.previous.length + offsetFromCurrent; + if (previousIndex < 0 || previousIndex >= this.state.previous.length) { + return { kind: "none" }; + } + const current = [ + ...this.state.previous.slice(previousIndex), + ...this.state.current, + ...this.state.future, + ]; + const target = current[0]; + if (!target) return { kind: "none" }; + this.state = { + ...this.state, + previous: this.state.previous.slice(0, previousIndex), + current: normalizeSegments(current), + future: [], + }; + return { kind: "outside", target, snapshot: this.snapshot() }; + } + + const futureIndex = offsetFromCurrent - this.state.current.length; + if (futureIndex < 0 || futureIndex >= this.state.future.length) { + return { kind: "none" }; + } + const current = this.state.future.slice(futureIndex); + const target = current[0]; + if (!target) return { kind: "none" }; + const consumed = [...this.state.current, ...this.state.future.slice(0, futureIndex)]; + this.state = { + ...this.state, + previous: normalizeSegments([...this.state.previous, ...consumed]), + current: normalizeSegments(current), + future: [], + }; + return { kind: "outside", target, snapshot: this.snapshot() }; + } +} diff --git a/packages/core/src/tts/segment-planner.test.ts b/packages/core/src/tts/segment-planner.test.ts new file mode 100644 index 000000000..c8ca66123 --- /dev/null +++ b/packages/core/src/tts/segment-planner.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { SegmentPlanner } from "./segment-planner"; + +describe("SegmentPlanner", () => { + it("splits text and maps offsets", () => { + const segments = new SegmentPlanner({ maxCharacters: 5 }).plan("abcdef"); + expect(segments.map((segment) => segment.text)).toEqual(["abcde", "f"]); + expect(segments[1].startOffset).toBe(5); + }); + + it("finds a segment by offset or CFI", () => { + const planner = new SegmentPlanner({ maxCharacters: 5 }); + const segments = planner.plan("abcdefghij"); + segments[1].cfi = "epubcfi(/6/2)"; + expect(planner.findStart(segments, { offset: 6 })).toBe(1); + expect(planner.findStart(segments, { cfi: "epubcfi(/6/2)" })).toBe(1); + }); +}); diff --git a/packages/core/src/tts/segment-planner.ts b/packages/core/src/tts/segment-planner.ts new file mode 100644 index 000000000..449943d50 --- /dev/null +++ b/packages/core/src/tts/segment-planner.ts @@ -0,0 +1,73 @@ +export interface Segment { + index: number; + text: string; + startOffset: number; + endOffset: number; + cfi?: string; + chapterIndex?: number; +} + +export interface SegmentPosition { + cfi?: string; + offset?: number; +} + +export interface SegmentPlannerOptions { + maxCharacters?: number; +} + +export class SegmentPlanner { + constructor(private readonly options: SegmentPlannerOptions = {}) {} + + plan(text: string | string[], options: SegmentPlannerOptions = {}): Segment[] { + const maxCharacters = options.maxCharacters ?? this.options.maxCharacters ?? 500; + if (Array.isArray(text)) { + return text + .map((value) => value.trim()) + .filter(Boolean) + .map((value, index) => ({ + index, + text: value, + startOffset: text.slice(0, index).join(" ").length, + endOffset: text.slice(0, index + 1).join(" ").length, + })); + } + const source = text.replace(/\s+/g, " ").trim(); + if (!source) return []; + const result: Segment[] = []; + let offset = 0; + for (const paragraph of source.split(/(?<=[.!?。!?;;])\s+/u)) { + let start = 0; + while (start < paragraph.length) { + const end = Math.min(start + maxCharacters, paragraph.length); + const value = paragraph.slice(start, end).trim(); + if (value) { + const startOffset = offset + start; + result.push({ + index: result.length, + text: value, + startOffset, + endOffset: startOffset + value.length, + }); + } + start = end; + } + offset += paragraph.length + 1; + } + return result; + } + + findStart(segments: Segment[], position: SegmentPosition): number { + if (!segments.length) return -1; + if (position.cfi) { + const match = segments.find((segment) => segment.cfi === position.cfi); + if (match) return match.index; + } + if (position.offset != null) { + const offset = position.offset; + const match = segments.find((segment) => offset < segment.endOffset); + if (match) return match.index; + } + return 0; + } +} diff --git a/packages/core/src/tts/synthesis-cache.test.ts b/packages/core/src/tts/synthesis-cache.test.ts new file mode 100644 index 000000000..6f5997efc --- /dev/null +++ b/packages/core/src/tts/synthesis-cache.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { AsyncLRUCache } from "./synthesis-cache"; + +describe("AsyncLRUCache", () => { + it("deduplicates concurrent synthesis and returns independent values", async () => { + const load = vi.fn(async () => new Uint8Array([1, 2, 3])); + const cache = new AsyncLRUCache(2, (value: Uint8Array) => value.slice()); + + const [first, second] = await Promise.all([ + cache.getOrCreate("same", load), + cache.getOrCreate("same", load), + ]); + + expect(load).toHaveBeenCalledOnce(); + expect(first).toEqual(second); + expect(first).not.toBe(second); + }); + + it("evicts the least recently used value", async () => { + const cache = new AsyncLRUCache(2, (value: string) => value); + await cache.getOrCreate("first", async () => "one"); + await cache.getOrCreate("second", async () => "two"); + await cache.getExisting("first"); + await cache.getOrCreate("third", async () => "three"); + + expect(cache.getExisting("first")).not.toBeNull(); + expect(cache.getExisting("second")).toBeNull(); + }); +}); diff --git a/packages/core/src/tts/synthesis-cache.ts b/packages/core/src/tts/synthesis-cache.ts new file mode 100644 index 000000000..264649bff --- /dev/null +++ b/packages/core/src/tts/synthesis-cache.ts @@ -0,0 +1,69 @@ +export class AsyncLRUCache { + private readonly values = new Map(); + private readonly pending = new Map>(); + + constructor( + private readonly capacity: number, + private readonly clone: (value: T) => T, + ) {} + + getExisting(key: string): Promise | null { + const value = this.values.get(key); + if (value !== undefined) { + this.touch(key, value); + return Promise.resolve(this.clone(value)); + } + const pending = this.pending.get(key); + return pending ? pending.then(this.clone) : null; + } + + getOrCreate(key: string, load: () => Promise): Promise { + const existing = this.getExisting(key); + if (existing) return existing; + + const request = load() + .then((value) => { + this.touch(key, this.clone(value)); + return value; + }) + .finally(() => { + this.pending.delete(key); + }); + this.pending.set(key, request); + return request.then(this.clone); + } + + private touch(key: string, value: T): void { + this.values.delete(key); + this.values.set(key, value); + while (this.values.size > this.capacity) { + const oldestKey = this.values.keys().next().value; + if (oldestKey === undefined) break; + this.values.delete(oldestKey); + } + } +} + +const SYNTHESIS_CACHE_CAPACITY = 48; +const synthesisAudioCache = new AsyncLRUCache(SYNTHESIS_CACHE_CAPACITY, (audio) => + audio.slice(), +); + +export function buildSynthesisCacheKey( + provider: string, + text: string, + settings: readonly unknown[], +): string { + return JSON.stringify([provider, ...settings, text]); +} + +export function getCachedSynthesisAudio(key: string): Promise | null { + return synthesisAudioCache.getExisting(key); +} + +export function getOrCreateSynthesisAudio( + key: string, + synthesize: () => Promise, +): Promise { + return synthesisAudioCache.getOrCreate(key, synthesize); +} diff --git a/packages/core/src/tts/tts-players.ts b/packages/core/src/tts/tts-players.ts index d6753ede0..18c6411e1 100644 --- a/packages/core/src/tts/tts-players.ts +++ b/packages/core/src/tts/tts-players.ts @@ -12,15 +12,36 @@ import { base64ToBytes, buildOpenAIChatTTSMessages, buildTTSHttpError, - buildXiaomiTTSUrl, buildXiaomiTTSMessages, + buildXiaomiTTSUrl, fetchOpenAITTSAudio, + getPreloadedTTSAudio, isTTSAbortError, } from "./cloud-tts"; import { fetchEdgeTTSAudio } from "./edge-tts"; import { type ChunkBoundary, resolveCurrentChunk } from "./playback-cursor"; import { splitIntoChunks } from "./text-utils"; -import { normalizeXiaomiTTSVoice, type ITTSPlayer, type TTSConfig } from "./types"; +import { type ITTSPlayer, type TTSConfig, normalizeXiaomiTTSVoice } from "./types"; + +function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +function scheduleAudioBuffer( + context: AudioContext, + destination: AudioNode, + audioBuffer: AudioBuffer, + scheduledEnd: number, +): { startAt: number; endAt: number } { + const source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(destination); + const startAt = Math.max(context.currentTime, scheduledEnd); + source.start(startAt); + return { startAt, endAt: startAt + audioBuffer.duration }; +} // ── Browser SpeechSynthesis ── @@ -243,6 +264,29 @@ export class DashScopeTTSPlayer implements ITTSPlayer { this.abortController = new AbortController(); this.pendingBytes = []; + const preloaded = getPreloadedTTSAudio(text, config); + if (preloaded && this.audioCtx && this.gainNode) { + try { + const bytes = await preloaded; + const audioBuffer = await this.audioCtx.decodeAudioData(bytesToArrayBuffer(bytes)); + if (!this._playing || myRun !== this.runId || !this.audioCtx || !this.gainNode) return; + const scheduled = scheduleAudioBuffer( + this.audioCtx, + this.gainNode, + audioBuffer, + this.scheduledEnd, + ); + this.scheduledEnd = scheduled.endAt; + this.hasAudioData = true; + this.chunkBoundaries.push({ index: this.currentStreamIndex, startAt: scheduled.startAt }); + this.boundaryRecorded = true; + if (isFirst) this.onStateChange?.("playing"); + return; + } catch (error) { + console.warn("[DashScope TTS] preloaded audio unavailable, using stream", error); + } + } + const response = await platform.fetch( "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", { @@ -529,20 +573,16 @@ abstract class PCMStreamingTTSPlayer implements ITTSPlayer { this.abortController = new AbortController(); let firstAudioReceived = false; try { - await this.streamChunkAudio( - chunks[i], - config, - this.abortController.signal, - (bytes) => { - if (!this._playing || myRun !== this.runId || !this.audioCtx) return; - this.pendingBytes.push(bytes); - if (!firstAudioReceived) { - firstAudioReceived = true; - if (i === 0) this.onStateChange?.("playing"); - } - this.scheduleFlush(); - }, - ); + if (await this.schedulePreloadedChunk(chunks[i], config, i, myRun)) continue; + await this.streamChunkAudio(chunks[i], config, this.abortController.signal, (bytes) => { + if (!this._playing || myRun !== this.runId || !this.audioCtx) return; + this.pendingBytes.push(bytes); + if (!firstAudioReceived) { + firstAudioReceived = true; + if (i === 0) this.onStateChange?.("playing"); + } + this.scheduleFlush(); + }); } catch (err) { if (!this._playing || myRun !== this.runId || isTTSAbortError(err)) return; console.error(`[${this.engineName} TTS] chunk error:`, err); @@ -557,6 +597,37 @@ abstract class PCMStreamingTTSPlayer implements ITTSPlayer { this.allChunksDone = true; } + private async schedulePreloadedChunk( + text: string, + config: TTSConfig, + index: number, + myRun: number, + ): Promise { + const preloaded = getPreloadedTTSAudio(text, config); + if (!preloaded || !this.audioCtx || !this.gainNode) return false; + + try { + const bytes = await preloaded; + const audioBuffer = await this.audioCtx.decodeAudioData(bytesToArrayBuffer(bytes)); + if (!this._playing || myRun !== this.runId || !this.audioCtx || !this.gainNode) return true; + const scheduled = scheduleAudioBuffer( + this.audioCtx, + this.gainNode, + audioBuffer, + this.scheduledEnd, + ); + this.scheduledEnd = scheduled.endAt; + this.hasAudioData = true; + this.chunkBoundaries.push({ index, startAt: scheduled.startAt }); + this.boundaryRecorded = true; + if (index === 0) this.onStateChange?.("playing"); + return true; + } catch (error) { + console.warn(`[${this.engineName} TTS] preloaded audio unavailable, using stream`, error); + return false; + } + } + private scheduleFlush() { if (this.decodeTimeout) return; this.decodeTimeout = setTimeout(() => { @@ -703,12 +774,6 @@ async function readChatAudioSSE( } } -function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - return copy.buffer; -} - export class XiaomiTTSPlayer extends PCMStreamingTTSPlayer { protected engineName = "Xiaomi MiMo"; @@ -808,14 +873,15 @@ class BufferedAudioTTSPlayer implements ITTSPlayer { if (!this._playing || myRun !== this.runId || !this.audioCtx || !this.gainNode) return; const audioBuffer = await this.audioCtx.decodeAudioData(bytesToArrayBuffer(bytes)); if (!this._playing || myRun !== this.runId || !this.audioCtx || !this.gainNode) return; - const source = this.audioCtx.createBufferSource(); - source.buffer = audioBuffer; - source.connect(this.gainNode); - const startAt = Math.max(this.audioCtx.currentTime, this.scheduledEnd); - source.start(startAt); - this.scheduledEnd = startAt + audioBuffer.duration; + const scheduled = scheduleAudioBuffer( + this.audioCtx, + this.gainNode, + audioBuffer, + this.scheduledEnd, + ); + this.scheduledEnd = scheduled.endAt; this.hasAudioData = true; - this.chunkBoundaries.push({ index: i, startAt }); + this.chunkBoundaries.push({ index: i, startAt: scheduled.startAt }); if (i === 0) this.onStateChange?.("playing"); } catch (err) { if (!this._playing || myRun !== this.runId || isTTSAbortError(err)) return; diff --git a/packages/core/src/tts/types.ts b/packages/core/src/tts/types.ts index 69915ab93..cbc94cdc5 100644 --- a/packages/core/src/tts/types.ts +++ b/packages/core/src/tts/types.ts @@ -204,8 +204,7 @@ export function normalizeTTSEngine(engine: LegacyTTSEngine | string | null | und export function getTTSProviderDefinition(provider: TTSProviderType): TTSProviderDefinition { return ( - TTS_PROVIDER_DEFINITIONS.find((item) => item.id === provider) ?? - TTS_PROVIDER_DEFINITIONS[0] + TTS_PROVIDER_DEFINITIONS.find((item) => item.id === provider) ?? TTS_PROVIDER_DEFINITIONS[0] ); } @@ -282,10 +281,7 @@ function normalizeProfiles(config: Partial): TTSProfile[] { ? { ...profile, voice: normalizeXiaomiTTSVoice(profile.voice) } : profile; const fallback = merged.get(profile.id); - merged.set( - profile.id, - fallback ? { ...fallback, ...normalizedProfile } : normalizedProfile, - ); + merged.set(profile.id, fallback ? { ...fallback, ...normalizedProfile } : normalizedProfile); } return Array.from(merged.values()).filter((profile) => { if (defaultIds.has(profile.id)) return true; @@ -339,7 +335,7 @@ export function normalizeTTSConfig(config: PersistedTTSConfig | null | undefined ...DEFAULT_TTS_CONFIG, ...config, engine: normalizedEngine, - activeProfileId: hasExplicitProfileId ? config.activeProfileId! : "", + activeProfileId: hasExplicitProfileId ? (config.activeProfileId ?? "") : "", voiceName: config?.voiceName ?? DEFAULT_TTS_CONFIG.voiceName, systemVoiceLabel: config?.systemVoiceLabel ?? DEFAULT_TTS_CONFIG.systemVoiceLabel, rate: typeof config?.rate === "number" ? config.rate : DEFAULT_TTS_CONFIG.rate, @@ -349,9 +345,7 @@ export function normalizeTTSConfig(config: PersistedTTSConfig | null | undefined dashscopeVoice: config?.dashscopeVoice ?? DEFAULT_TTS_CONFIG.dashscopeVoice, xiaomiApiKey: config?.xiaomiApiKey ?? DEFAULT_TTS_CONFIG.xiaomiApiKey, xiaomiBaseUrl: config?.xiaomiBaseUrl ?? DEFAULT_TTS_CONFIG.xiaomiBaseUrl, - xiaomiVoice: normalizeXiaomiTTSVoice( - config?.xiaomiVoice ?? DEFAULT_TTS_CONFIG.xiaomiVoice, - ), + xiaomiVoice: normalizeXiaomiTTSVoice(config?.xiaomiVoice ?? DEFAULT_TTS_CONFIG.xiaomiVoice), xiaomiStylePrompt: config?.xiaomiStylePrompt ?? DEFAULT_TTS_CONFIG.xiaomiStylePrompt, openaiTtsBaseUrl: config?.openaiTtsBaseUrl ?? DEFAULT_TTS_CONFIG.openaiTtsBaseUrl, openaiTtsApiKey: config?.openaiTtsApiKey ?? DEFAULT_TTS_CONFIG.openaiTtsApiKey, @@ -359,8 +353,7 @@ export function normalizeTTSConfig(config: PersistedTTSConfig | null | undefined openaiTtsModel: config?.openaiTtsModel ?? DEFAULT_TTS_CONFIG.openaiTtsModel, openaiTtsVoice: config?.openaiTtsVoice ?? DEFAULT_TTS_CONFIG.openaiTtsVoice, openaiTtsFormat: config?.openaiTtsFormat ?? DEFAULT_TTS_CONFIG.openaiTtsFormat, - openaiTtsStylePrompt: - config?.openaiTtsStylePrompt ?? DEFAULT_TTS_CONFIG.openaiTtsStylePrompt, + openaiTtsStylePrompt: config?.openaiTtsStylePrompt ?? DEFAULT_TTS_CONFIG.openaiTtsStylePrompt, profiles: normalizeProfiles(profileSource), }; diff --git a/packages/foliate-js/tts.js b/packages/foliate-js/tts.js index 21664a076..717bc86fc 100644 --- a/packages/foliate-js/tts.js +++ b/packages/foliate-js/tts.js @@ -3,6 +3,9 @@ const NS = { SSML: "http://www.w3.org/2001/10/synthesis", }; +const getAttributeNS = (node, namespace, name) => + typeof node?.getAttributeNS === "function" ? node.getAttributeNS(namespace, name) : null; + const blockTags = new Set([ "article", "aside", @@ -40,12 +43,12 @@ const blockTags = new Set([ ]); const getLang = (el) => { - const x = el.lang || el?.getAttributeNS?.(NS.XML, "lang"); + const x = el?.lang || getAttributeNS(el, NS.XML, "lang"); return x ? x : el.parentElement ? getLang(el.parentElement) : null; }; const getAlphabet = (el) => { - const x = el?.getAttributeNS?.(NS.XML, "lang"); + const x = getAttributeNS(el, NS.XML, "lang"); return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null; }; @@ -53,11 +56,29 @@ const getSegmenter = (lang = "en", granularity = "word") => { const segmenter = new Intl.Segmenter(lang, { granularity }); const granularityIsWord = granularity === "word"; return function* (strs, makeRange) { - const str = strs.join(""); + const str = strs.join("").replace(/\r\n/g, " ").replace(/\r/g, " ").replace(/\n/g, " "); let name = 0; let strIndex = -1; let sum = 0; - for (const { index, segment, isWordLike } of segmenter.segment(str)) { + const rawSegments = Array.from(segmenter.segment(str)); + const segments = []; + for (let i = 0; i < rawSegments.length; i++) { + const current = rawSegments[i]; + const next = rawSegments[i + 1]; + const endsWithAbbreviation = /(?:^|\s)([A-Z][a-z]{1,5})\.$/.test(current.segment.trim()); + const nextStartsWithCapital = /^[A-Z]/.test(next?.segment?.trim() || ""); + if (endsWithAbbreviation && nextStartsWithCapital) { + segments.push({ + index: current.index, + segment: current.segment + (next?.segment || ""), + isWordLike: true, + }); + i++; + } else { + segments.push(current); + } + } + for (const { index, segment, isWordLike } of segments) { if (granularityIsWord && !isWordLike) continue; while (sum <= index) sum += strs[++strIndex].length; const startIndex = strIndex; @@ -71,7 +92,7 @@ const getSegmenter = (lang = "en", granularity = "word") => { }; }; -const fragmentToSSML = (fragment, inherited) => { +const fragmentToSSML = (fragment, nodeFilter, inherited) => { const ssml = document.implementation.createDocument(NS.SSML, "speak"); const { lang } = inherited; if (lang) ssml.documentElement.setAttributeNS(NS.XML, "lang", lang); @@ -80,7 +101,8 @@ const fragmentToSSML = (fragment, inherited) => { if (!node) return; if (node.nodeType === 3) return ssml.createTextNode(node.textContent); if (node.nodeType === 4) return ssml.createCDATASection(node.textContent); - if (node.nodeType !== 1) return; + if (node.nodeType !== 1 && node.nodeType !== 11) return; + if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return; let el; const nodeName = node.nodeName.toLowerCase(); @@ -92,15 +114,15 @@ const fragmentToSSML = (fragment, inherited) => { else if (nodeName === "em" || nodeName === "strong") el = ssml.createElementNS(NS.SSML, "emphasis"); - const lang = node.lang || node.getAttributeNS(NS.XML, "lang"); + const lang = node.lang || getAttributeNS(node, NS.XML, "lang"); if (lang) { if (!el) el = ssml.createElementNS(NS.SSML, "lang"); el.setAttributeNS(NS.XML, "lang", lang); } - const alphabet = node.getAttributeNS(NS.SSML, "alphabet") || inheritedAlphabet; + const alphabet = getAttributeNS(node, NS.SSML, "alphabet") || inheritedAlphabet; if (!el) { - const ph = node.getAttributeNS(NS.SSML, "ph"); + const ph = getAttributeNS(node, NS.SSML, "ph"); if (ph) { el = ssml.createElementNS(NS.SSML, "phoneme"); if (alphabet) el.setAttribute("alphabet", alphabet); @@ -118,11 +140,11 @@ const fragmentToSSML = (fragment, inherited) => { } return el; }; - convert(fragment.firstChild, ssml.documentElement, inherited.alphabet); + convert(fragment, ssml.documentElement, inherited.alphabet); return ssml; }; -const getFragmentWithMarks = (range, textWalker, granularity, filterFunc) => { +const getFragmentWithMarks = (range, textWalker, nodeFilter, granularity) => { const lang = getLang(range.commonAncestorContainer); const alphabet = getAlphabet(range.commonAncestorContainer); @@ -132,15 +154,15 @@ const getFragmentWithMarks = (range, textWalker, granularity, filterFunc) => { // we need ranges on both the original document (for highlighting) // and the document fragment (for inserting marks) // so unfortunately need to do it twice, as you can't copy the ranges - const entries = [...textWalker(range, segmenter, filterFunc)]; - const fragmentEntries = [...textWalker(fragment, segmenter, filterFunc)]; + const entries = [...textWalker(range, segmenter, nodeFilter)]; + const fragmentEntries = [...textWalker(fragment, segmenter, nodeFilter)]; for (const [name, range] of fragmentEntries) { const mark = document.createElement("foliate-mark"); mark.dataset.name = name; range.insertNode(mark); } - const ssml = fragmentToSSML(fragment, { lang, alphabet }); + const ssml = fragmentToSSML(fragment, nodeFilter, { lang, alphabet }); return { entries, ssml }; }; @@ -169,10 +191,16 @@ const getRangeTextWithoutRuby = (range) => { const rangeIsEmpty = (range) => !getRangeTextWithoutRuby(range).trim(); const normalizeRangeText = (range) => getRangeTextWithoutRuby(range).replace(/\s+/g, " ").trim(); +const endsAfterStart = (candidate, target) => { + const end = candidate.cloneRange(); + end.collapse(false); + // A point exactly at the previous sentence's end belongs to the next sentence. + return end.comparePoint(target.startContainer, target.startOffset) < 0; +}; -function* getDetailRanges(doc, textWalker, filterFunc) { +function* getDetailRanges(doc, textWalker, nodeFilter) { for (const blockRange of getBlocks(doc)) { - const { entries } = getFragmentWithMarks(blockRange, textWalker, "sentence", filterFunc); + const { entries } = getFragmentWithMarks(blockRange, textWalker, nodeFilter, "sentence"); for (const [, range] of entries) { if (!rangeIsEmpty(range)) yield range; } @@ -304,49 +332,26 @@ class ListIterator { export class TTS { #list; #detailList; - #ranges; + #detailContext = null; + #ranges = new Map(); #lastMark; #lastRange; #getCfi; + #textWalker; + #nodeFilter; #serializer = new XMLSerializer(); - constructor( - doc, - textWalker, - maybeFilterOrHighlight, - maybeHighlightOrGetCfi, - maybeGetCfiOrGranularity, - maybeGranularity, - ) { + constructor(doc, textWalker, nodeFilter, highlight, granularity = "word", getCfi = null) { this.doc = doc; - let filterFunc = null; - let highlight = null; - let granularity = "word"; - - if (typeof maybeFilterOrHighlight === "function") { - highlight = maybeFilterOrHighlight; - if (typeof maybeHighlightOrGetCfi === "function") { - this.#getCfi = maybeHighlightOrGetCfi; - granularity = typeof maybeGetCfiOrGranularity === "string" ? maybeGetCfiOrGranularity : "word"; - } else { - granularity = typeof maybeHighlightOrGetCfi === "string" ? maybeHighlightOrGetCfi : "word"; - } - } else { - filterFunc = maybeFilterOrHighlight ?? null; - highlight = - typeof maybeHighlightOrGetCfi === "function" ? maybeHighlightOrGetCfi : null; - if (typeof maybeGetCfiOrGranularity === "function") { - this.#getCfi = maybeGetCfiOrGranularity; - } - granularity = typeof maybeGranularity === "string" ? maybeGranularity : "word"; - } - + this.#getCfi = getCfi; + this.#textWalker = textWalker; + this.#nodeFilter = nodeFilter; this.highlight = highlight || (() => null); this.#list = new ListIterator(getBlocks(doc), (range) => { - const { entries, ssml } = getFragmentWithMarks(range, textWalker, granularity, filterFunc); + const { entries, ssml } = getFragmentWithMarks(range, textWalker, nodeFilter, granularity); this.#ranges = new Map(entries); return [ssml, range]; }); - this.#detailList = new ListIterator(getDetailRanges(doc, textWalker, filterFunc), (range) => [ + this.#detailList = new ListIterator(getDetailRanges(doc, textWalker, nodeFilter), (range) => [ normalizeRangeText(range), range, ]); @@ -446,18 +451,88 @@ export class TTS { } from(range) { this.#lastMark = null; - const [doc] = this.#list.find( - (range_) => range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0, - ); - this.#detailList.find((detailRange) => range.compareBoundaryPoints(Range.END_TO_START, detailRange) <= 0); + const [doc] = this.#list.find((blockRange) => endsAfterStart(blockRange, range)) ?? []; + this.#detailList.find((detailRange) => endsAfterStart(detailRange, range)); let mark; for (const [name, range_] of this.#ranges.entries()) - if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0) { + if (endsAfterStart(range_, range)) { mark = name; break; } + this.#lastMark = mark ?? null; + console.log("[FoliateTTS][from]", { + requestedText: range?.toString?.().replace(/\s+/g, " ").trim().slice(0, 80) ?? "", + matchedBlock: !!doc, + mark, + blockSentenceCount: this.#ranges.size, + }); return this.#speak(doc, (ssml) => this.#getMarkElement(ssml, mark)); } + + currentBlockDetails(count = Number.POSITIVE_INFINITY) { + const entries = [...this.#ranges.entries()]; + const start = this.#lastMark + ? Math.max( + 0, + entries.findIndex(([name]) => name === this.#lastMark), + ) + : 0; + const details = []; + for (const [, range] of entries.slice(start < 0 ? 0 : start, start + count)) { + const text = normalizeRangeText(range); + if (!text || !this.#getCfi || !range.cloneRange) continue; + try { + const cfi = this.#getCfi(range.cloneRange()); + if (cfi) details.push({ text, cfi }); + } catch { + // A sentence without a stable CFI cannot be navigated from the UI. + } + } + return details; + } + nextBlockDetails(count = Number.POSITIVE_INFINITY) { + const before = this.currentBlockDetails(Number.POSITIVE_INFINITY); + const beforeBlock = { + count: before.length, + first: before[0] ?? null, + last: before[before.length - 1] ?? null, + lastMark: this.#lastMark, + }; + console.log("[FoliateTTS][next-block] before", beforeBlock); + const ssml = this.next(); + if (!ssml) { + console.log("[FoliateTTS][next-block] after", { + hasSsml: false, + before: beforeBlock, + count: 0, + first: null, + last: null, + lastMark: this.#lastMark, + }); + return []; + } + const details = this.currentBlockDetails(count); + console.log("[FoliateTTS][next-block] after", { + hasSsml: true, + before: beforeBlock, + count: details.length, + first: details[0] ?? null, + last: details[details.length - 1] ?? null, + lastMark: this.#lastMark, + }); + return details; + } + peekNextBlockDetails(count = Number.POSITIVE_INFINITY) { + let advanced = false; + try { + const ssml = this.next(); + if (!ssml) return []; + advanced = true; + return this.currentBlockDetails(count); + } finally { + if (advanced) this.prev(); + } + } setMark(mark) { const range = this.#ranges.get(mark); if (range) { @@ -486,17 +561,40 @@ export class TTS { } return details; } - #detailFromCfi(cfi, { highlight = false } = {}) { + contextDetailsByCfi(cfi, beforeCount = 0, afterCount = 0) { + if (!cfi || !this.#getCfi) return { before: [], after: [] }; + if (!this.#detailContext) { + this.#detailContext = []; + for (const range of getDetailRanges(this.doc, this.#textWalker, this.#nodeFilter)) { + const text = normalizeRangeText(range); + const detailCfi = range.cloneRange ? this.#getCfi(range.cloneRange()) : null; + if (text && detailCfi) this.#detailContext.push({ text, cfi: detailCfi }); + } + } + const index = this.#detailContext.findIndex((detail) => detail.cfi === cfi); + if (index < 0) return { before: [], after: [] }; + return { + before: this.#detailContext.slice(Math.max(0, index - beforeCount), index), + after: this.#detailContext.slice(index + 1, index + 1 + afterCount), + }; + } + #detailFromCfi(cfi, { highlight = false, moveCursor = true } = {}) { if (!cfi || !this.#getCfi) return null; const entry = this.#detailList.find((range) => { const candidate = this.#getCfi(range.cloneRange()); return candidate === cfi; }); + if (moveCursor && entry?.[1]?.cloneRange) this.from(entry[1].cloneRange()); return this.#detailResultFrom(entry, { highlight }); } alignCfi(cfi) { return this.#detailFromCfi(cfi, { highlight: false }); } + // Read-only CFI lookup for lyric/context consumers. Unlike alignCfi(), this + // must not move the ordered block cursor used by start()/next(). + peekCfi(cfi) { + return this.#detailFromCfi(cfi, { highlight: false, moveCursor: false }); + } highlightCfi(cfi) { return this.#detailFromCfi(cfi, { highlight: true }); } diff --git a/packages/foliate-js/view.js b/packages/foliate-js/view.js index 62739bdee..decf6a497 100644 --- a/packages/foliate-js/view.js +++ b/packages/foliate-js/view.js @@ -512,7 +512,7 @@ export class View extends HTMLElement { } } async goTo(target) { - target = decodeURIComponent(target); + if (typeof target === "string") target = decodeURIComponent(target); const resolved = this.resolveNavigation(target); try { await this.renderer.goTo(resolved); @@ -646,7 +646,7 @@ export class View extends HTMLElement { setSearchIndicator(type = "outline", options = {}) { this.#searchIndicatorConfig = { type, options }; } - async initTTS(granularity = "word", highlight, filterFunc) { + async initTTS(granularity = "word", nodeFilter = null, highlight = null, getCfi = null) { const contents = this.renderer.getContents(); const primaryIndex = this.renderer.primaryIndex; const current = contents.find((content) => content.index === primaryIndex) ?? contents[0]; @@ -661,10 +661,10 @@ export class View extends HTMLElement { this.tts = new TTS( doc, textWalker, - filterFunc || null, + nodeFilter || null, highlight || ((range) => this.renderer.scrollToAnchor(range, true)), - (range) => this.getCFI(index, range), granularity, + getCfi || ((range) => this.getCFI(index, range)), ); } startMediaOverlay() {