Skip to content

Commit aa2a933

Browse files
committed
fix(pwa): stabilize TTS auto page follow
1 parent b222470 commit aa2a933

2 files changed

Lines changed: 50 additions & 33 deletions

File tree

pwa/src/components/Reader.tsx

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,31 +1487,33 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
14871487
return
14881488
}
14891489

1490-
const highlights = (doc.defaultView as any)?.CSS?.highlights
1491-
const HighlightCtor = (doc.defaultView as any)?.Highlight
1492-
if (!highlights || !HighlightCtor) {
1493-
console.log('[TTS:follow] skip highlight: Custom Highlight API unavailable', { absoluteOffset })
1494-
return
1495-
}
1496-
14971490
ensureTTSHighlightStyle(doc)
14981491
const range = createRangeFromTextOffset(doc, absoluteOffset)
14991492
if (!range) {
15001493
console.log('[TTS:follow] skip highlight: range not found', { absoluteOffset })
1494+
followTTSRange(null, doc, absoluteOffset, allowAutoFollow)
15011495
return
15021496
}
1503-
highlights.set(TTS_HIGHLIGHT_ID, new HighlightCtor(range))
1497+
1498+
const highlights = (doc.defaultView as any)?.CSS?.highlights
1499+
const HighlightCtor = (doc.defaultView as any)?.Highlight
1500+
if (highlights && HighlightCtor) {
1501+
highlights.set(TTS_HIGHLIGHT_ID, new HighlightCtor(range))
1502+
} else {
1503+
console.log('[TTS:follow] Custom Highlight API unavailable, only auto-follow by progress', { absoluteOffset })
1504+
}
15041505
followTTSRange(range, doc, absoluteOffset, allowAutoFollow)
15051506
}
15061507

1507-
const followTTSRange = (range: Range, doc: Document, absoluteOffset: number, allowAutoFollow: boolean) => {
1508+
const followTTSRange = (range: Range | null, doc: Document, absoluteOffset: number, allowAutoFollow: boolean) => {
15081509
const loc = (renditionRef.current as any)?.currentLocation?.()
15091510
const displayed = loc?.start?.displayed as { page: number; total: number } | undefined
15101511
const currentPage = displayed?.page ?? 1
15111512
const totalPages = Math.max(displayed?.total ?? ttsChapterPageTotalRef.current, 1)
15121513
const textLength = Math.max(ttsChapterTextLengthRef.current, 1)
15131514
const continuousPage = absoluteOffset / textLength * totalPages + 1
15141515
const estimatedPage = Math.min(totalPages, Math.max(1, Math.floor(continuousPage)))
1516+
const progressShouldAdvance = continuousPage >= currentPage + 0.9
15151517

15161518
if (!allowAutoFollow) {
15171519
console.log('[TTS:follow] skip next: 初始 highlight 不觸發翻頁', { absoluteOffset, currentPage, estimatedPage, totalPages })
@@ -1533,26 +1535,19 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
15331535
return
15341536
}
15351537

1536-
const rect = range.getBoundingClientRect()
1537-
if (rect.width === 0 && rect.height === 0) {
1538-
console.log('[TTS:follow] skip next: empty rect')
1539-
return
1540-
}
1541-
15421538
const viewportWidth = doc.documentElement.clientWidth || doc.defaultView?.innerWidth || 0
15431539
const viewportHeight = doc.documentElement.clientHeight || doc.defaultView?.innerHeight || 0
1544-
if (viewportWidth <= 0 || viewportHeight <= 0) {
1545-
console.log('[TTS:follow] skip next: invalid viewport', { viewportWidth, viewportHeight })
1546-
return
1547-
}
1540+
const rect = range?.getBoundingClientRect()
1541+
const hasUsableRect = !!rect && !(rect.width === 0 && rect.height === 0) && viewportWidth > 0 && viewportHeight > 0
15481542

1549-
const rectOutside =
1543+
const rectOutside = hasUsableRect && !!rect && (
15501544
rect.left > viewportWidth * 0.82 ||
15511545
rect.right > viewportWidth * 1.02 ||
15521546
rect.top > viewportHeight * 0.92 ||
15531547
rect.bottom > viewportHeight * 1.08
1548+
)
15541549

1555-
const shouldAdvance = rectOutside && continuousPage >= currentPage + 0.82
1550+
const shouldAdvance = progressShouldAdvance || (rectOutside && continuousPage >= currentPage + 0.82)
15561551

15571552
console.log('[TTS:follow] check', {
15581553
displayed,
@@ -1561,9 +1556,11 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
15611556
continuousPage: Number(continuousPage.toFixed(2)),
15621557
currentPage,
15631558
totalPages,
1564-
rect: { left: Math.round(rect.left), right: Math.round(rect.right), top: Math.round(rect.top), bottom: Math.round(rect.bottom) },
1559+
rect: rect ? { left: Math.round(rect.left), right: Math.round(rect.right), top: Math.round(rect.top), bottom: Math.round(rect.bottom) } : null,
15651560
viewport: { width: viewportWidth, height: viewportHeight },
1561+
hasUsableRect,
15661562
rectOutside,
1563+
progressShouldAdvance,
15671564
shouldAdvance,
15681565
})
15691566

pwa/src/hooks/useTTS.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,24 @@ const useTTS = () => {
143143
}
144144
utterance.onend = () => {
145145
if (generationRef.current !== generation) return
146-
const readChars = textOffsetRef.current + charIndexRef.current
147146
const totalChars = currentTextRef.current.length
148-
const isTruncated = readChars < totalChars - 10 // 若距終點超過10字,視為提前截斷
147+
const utteranceEnd = textOffsetRef.current + text.length
148+
const boundaryEnd = textOffsetRef.current + charIndexRef.current
149+
const readChars = charIndexRef.current > 0 && boundaryEnd < utteranceEnd - 10
150+
? boundaryEnd
151+
: utteranceEnd
152+
const hasMoreText = readChars < totalChars - 10
153+
const isTruncated = charIndexRef.current > 0 && boundaryEnd < utteranceEnd - 10
149154
console.log(
150-
isTruncated ? '[TTS] onend ⚠️ 疑似 iOS 截斷' : '[TTS] onend(正常結束)',
155+
hasMoreText ? (isTruncated ? '[TTS] onend ⚠️ 疑似 iOS 截斷,繼續剩餘文字' : '[TTS] onend(區塊結束,繼續下一段)') : '[TTS] onend(正常結束)',
151156
{ generation, charIndex: charIndexRef.current, offset: textOffsetRef.current, readChars, totalChars, remaining: totalChars - readChars }
152157
)
158+
if (hasMoreText) {
159+
textOffsetRef.current = readChars
160+
charIndexRef.current = 0
161+
playFromOffset(readChars)
162+
return
163+
}
153164
stopKeepalive()
154165
playingRef.current = false
155166
setPlaying(false)
@@ -174,7 +185,7 @@ const useTTS = () => {
174185
// generation 檢查並呼叫 onEndRef,否則會觸發下一章、再被 recovery 覆蓋造成重複朗讀
175186
const recoveryGen = ++generationRef.current
176187
setTimeout(() => {
177-
if (playingRef.current && generationRef.current === recoveryGen) createAndPlay(remaining)
188+
if (playingRef.current && generationRef.current === recoveryGen) playFromOffset(absolutePos)
178189
}, 300)
179190
return
180191
}
@@ -197,6 +208,19 @@ const useTTS = () => {
197208
startKeepalive()
198209
}
199210

211+
const playFromOffset = (offset: number) => {
212+
const safeOffset = Math.max(0, Math.min(offset, currentTextRef.current.length))
213+
const remaining = currentTextRef.current.slice(safeOffset)
214+
if (!remaining.trim()) {
215+
stop()
216+
return
217+
}
218+
const [chunk] = splitTextByLength(remaining)
219+
textOffsetRef.current = safeOffset
220+
charIndexRef.current = 0
221+
createAndPlay(chunk)
222+
}
223+
200224
const stop = () => {
201225
console.log('[TTS] stop() 被呼叫', { generation: generationRef.current })
202226
generationRef.current++ // 令所有舊 callback 失效
@@ -260,7 +284,7 @@ const useTTS = () => {
260284

261285
textOffsetRef.current = absolutePos
262286
charIndexRef.current = 0
263-
createAndPlay(remaining)
287+
playFromOffset(absolutePos)
264288
}
265289

266290
// 將文本分割為適合 utterance 的區塊(某些行動浏覽器對文字長度有限制)
@@ -310,15 +334,11 @@ const useTTS = () => {
310334
setPlaying(true)
311335
setPaused(false)
312336

313-
// 檢查文本長度,必要時分割
314337
const chunks = splitTextByLength(text)
315338
if (chunks.length > 1) {
316339
console.log('[TTS] 文本過長,已分割為', chunks.length, '個區塊', { totalLength: text.length, maxLength: MAX_UTTERANCE_LENGTH })
317-
// 只播放第一個區塊,onEnd 時處理下一個區塊
318-
createAndPlay(chunks[0])
319-
} else {
320-
createAndPlay(text)
321340
}
341+
playFromOffset(0)
322342
}
323343

324344
// 語速變更:若正在朗讀,從當前位置重啟(不觸發 onEnd、不重置 onBoundary)
@@ -335,7 +355,7 @@ const useTTS = () => {
335355
textOffsetRef.current = absolutePos
336356
charIndexRef.current = 0
337357
// playing 狀態維持 true,直接重建 utterance
338-
createAndPlay(remaining)
358+
playFromOffset(absolutePos)
339359
}
340360

341361
const getProgress = () => textOffsetRef.current + charIndexRef.current

0 commit comments

Comments
 (0)