Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions mobile/hooks/reader/useTTSReading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,22 @@ export const useTTSReading = ({ postToWebView, currentHrefRef, atEndRef, request
// 翻頁還沒來得及跟上時才需要额外呼叫 next() 補上。
const advanceToNextChapter = useCallback(async (): Promise<boolean> => {
const startHref = readingHrefRef.current;
if (__DEV__) console.log('[tts-reading] advanceToNextChapter 開始', { startHref, currentHref: currentHrefRef.current, atEnd: atEndRef.current });
for (let i = 0; i < 50; i++) {
if (currentHrefRef.current !== startHref) return true;
if (atEndRef.current) return false;
if (currentHrefRef.current !== startHref) {
if (__DEV__) console.log('[tts-reading] advanceToNextChapter: href 已改變,跨章節成功', { i, newHref: currentHrefRef.current });
return true;
}
if (atEndRef.current) {
if (__DEV__) console.log('[tts-reading] advanceToNextChapter: 已到全書結尾,停止', { i });
return false;
}
postToWebView({ type: 'next' });
await waitForRelocated();
}
return currentHrefRef.current !== startHref;
const advanced = currentHrefRef.current !== startHref;
if (__DEV__) console.log('[tts-reading] advanceToNextChapter: 50 次重試後仍未跨過章節', { advanced, startHref, currentHref: currentHrefRef.current, atEnd: atEndRef.current });
return advanced;
}, [currentHrefRef, atEndRef, postToWebView, waitForRelocated]);

const continueReadingRef = useRef<() => void>(() => {});
Expand All @@ -63,11 +72,40 @@ export const useTTSReading = ({ postToWebView, currentHrefRef, atEndRef, request

const readNextAndContinue = useCallback(async () => {
const advanced = await advanceToNextChapter();
if (!advanced) return;
const { text, startOffset } = await requestChapterText();
if (!text.trim()) return;
startSpeaking(text, startOffset);
}, [advanceToNextChapter, requestChapterText, startSpeaking]);
if (!advanced) {
if (__DEV__) console.log('[tts-reading] readNextAndContinue: advanceToNextChapter 回報 false,朗讀在此停止');
return;
}
// 實測抓到的真正情況(見對話紀錄的 log):跨完章節後 getChapterText() 拿到空字串,不是
// WebView 渲染還沒完成的暫時性競態(重試 300ms 仍是空字串)——是這個 spine 項目本身就
// 沒有文字內容(常見於純標題頁/純圖片頁,例如 xxxt.xhtml 這種章節標題圖片頁)。這種頁面
// 要繼續往後翻頁找下一個真的有文字的頁面,而不是原地重試或直接放棄朗讀。
for (let hop = 0; hop < 20; hop++) {
const { text, startOffset } = await requestChapterText();
if (text.trim()) {
startSpeaking(text, startOffset);
return;
}
if (__DEV__) console.log('[tts-reading] readNextAndContinue: 目前頁面沒有可念文字,往後翻頁找下一頁', { hop, href: currentHrefRef.current });
if (atEndRef.current) {
if (__DEV__) console.log('[tts-reading] readNextAndContinue: 已到全書結尾仍沒有可念文字,朗讀在此停止');
return;
}
postToWebView({ type: 'next' });
await waitForRelocated();
if (hop === 19) {
// 第 20 次翻頁後的目的地也要檢查一次,不能翻完就直接放棄,否則剛好翻到的
// 那一頁若本身有文字會被平白錯過(見上面 for 迴圈:check 在 navigate 之前,
// 最後一次 navigate 完迴圈就結束,若不在這裡補一次 check,這個目的地永遠不會被檢查)。
const { text: finalText, startOffset: finalOffset } = await requestChapterText();
if (finalText.trim()) {
startSpeaking(finalText, finalOffset);
return;
}
}
}
if (__DEV__) console.log('[tts-reading] readNextAndContinue: 連續 20 次翻頁都沒有可念文字,放棄');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, [advanceToNextChapter, requestChapterText, startSpeaking, atEndRef, currentHrefRef, postToWebView, waitForRelocated]);
useEffect(() => {
continueReadingRef.current = readNextAndContinue;
}, [readNextAndContinue]);
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/readerHtml.generated.ts

Large diffs are not rendered by default.

18 changes: 16 additions & 2 deletions mobile/lib/tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export const useTTS = () => {
const speakChunk = useCallback((generation: number) => {
const chunk = chunksRef.current[chunkIndexRef.current];
if (chunk === undefined) {
if (__DEV__) console.log('[tts] speakChunk: 這輪 chunksRef 全部念完,呼叫 onAllDone', { generation });
setPlaying(false);
setPaused(false);
onAllDoneRef.current?.();
Expand All @@ -159,19 +160,32 @@ export const useTTS = () => {
for (let i = 0; i < chunkIndexRef.current; i++) chunkStartOffset += chunksRef.current[i].length;
chunkStartOffsetRef.current = chunkStartOffset;
lastBoundaryCharIndexRef.current = 0;
if (__DEV__) {
console.log('[tts] speakChunk: 開始念', {
generation,
chunkIndex: chunkIndexRef.current,
totalChunks: chunksRef.current.length,
chunkLength: chunk.length,
});
}
Speech.speak(chunk, {
voice: selectedVoiceRef.current?.identifier,
language: selectedVoiceRef.current?.language ?? 'zh-TW',
rate: rateRef.current,
onDone: () => {
if (generationRef.current !== generation) return;
if (generationRef.current !== generation) {
if (__DEV__) console.log('[tts] onDone: generation 已過期,略過', { generation, current: generationRef.current });
return;
}
chunkIndexRef.current += 1;
speakChunk(generation);
},
onStopped: () => {
if (__DEV__) console.log('[tts] onStopped: 使用者手動 pause/stop 或系統中斷', { generation });
/* 使用者手動 pause/stop 觸發,狀態已由呼叫端更新,這裡不用重複處理 */
},
onError: () => {
onError: (error) => {
if (__DEV__) console.log('[tts] onError: 這個 chunk 朗讀失敗,朗讀鏈就此中止', { generation, current: generationRef.current, error: error?.message ?? String(error) });
if (generationRef.current !== generation) return;
setPlaying(false);
setPaused(false);
Expand Down
35 changes: 35 additions & 0 deletions mobile/reader-web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,24 @@ const unlockNavSoon = () => {
// 見 progressCalculations.ts 內 stuck-CFI 校正的說明。
let lastNavDirection: 'prev' | 'next' | null = null;

// 連續幾次呼叫 next() 但 CFI 完全沒變的次數。progressCalculations.ts 的 stuck-CFI 校正只處理
// 「全書最後一章」的頁碼顯示;但同一種 epub.js 分頁量測誤差(章節結尾有極短/空白內容,
// next() 實際上翻不過去)理論上任何一章都可能發生,不是只有最後一章。朗讀跟讀跨章節時
// (RN 端 useTTSReading.ts 的 advanceToNextChapter)完全依賴 next() 真的能翻過章節邊界,
// 一旦卡在這種「翻不動的最後一頁」,href 永遠不會變、也不是真正的全書結尾(atEnd 不會是
// true),會導致朗讀在這一章結束後直接無聲停住。這裡偵測到連續兩次真的卡住(排除偶發的
// 單次重複 CFI,那多半下一次 next() 就正常前進,見 progressCalculations.ts 的說明),就直接
// 用跟書籤/目錄跳轉相同的 rendition.display() 跳過去下一個 linear 章節,繞過卡住的分頁翻頁。
let consecutiveStuckNext = 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const findNextLinearHref = (afterIndex: number): string | null => {
if (!book) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spineItems = ((book.spine as any)?.items ?? []) as any[];
const next = spineItems.find((item) => item.index > afterIndex && item.linear !== 'no');
return next?.href ?? null;
};

const turnPage = (direction: 'prev' | 'next') => {
if (navBusy || !rendition) return;
lockNav();
Expand Down Expand Up @@ -464,6 +482,7 @@ const loadBook = async (base64: string, cfi: string | null, initialAnnotations:
locationsReady = false;
lastRelocatedLoc = null;
lastLinearSpineIndex = null;
consecutiveStuckNext = 0;
renderedAnnotations = new Map();
stopTTSTracking();

Expand Down Expand Up @@ -590,8 +609,24 @@ const loadBook = async (base64: string, cfi: string | null, initialAnnotations:
unlockNavSoon();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const previousCfi = (lastRelocatedLoc as any)?.start?.cfi ?? null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const locAny = loc as any;
const stuckOnNext = lastNavDirection === 'next' && previousCfi !== null && locAny?.start?.cfi === previousCfi;
lastRelocatedLoc = loc;
postRelocated(loc, previousCfi);
if (stuckOnNext) {
consecutiveStuckNext += 1;
if (consecutiveStuckNext >= 2 && !locAny?.atEnd) {
consecutiveStuckNext = 0;
const nextHref = findNextLinearHref(locAny?.start?.index ?? -1);
if (nextHref) {
debugLog('[relocated] next() 連續卡住同一個 CFI,直接跳到下一章', nextHref);
gotoTarget(nextHref);
}
}
} else {
consecutiveStuckNext = 0;
}
// 從書籤/目錄/註記清單等其他畫面跳轉(gotoTarget → rendition.display(cfi))時,
// 若目標落在目前已經渲染過、epub.js 判斷不需要重建 iframe 內容的章節(例如相鄰的
// 預先渲染 view),hooks.content 就不會再觸發,上面掛在 hooks.content 裡的
Expand Down
Loading