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