diff --git a/mobile/app/reader/[id].tsx b/mobile/app/reader/[id].tsx index ea76c00..1b3041b 100644 --- a/mobile/app/reader/[id].tsx +++ b/mobile/app/reader/[id].tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Linking, Pressable, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from 'react-native-webview'; -import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconNotes, IconSettings } from '../../components/icons'; +import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconNotes, IconPause, IconPlay, IconReset, IconSettings, IconSleepTimer } from '../../components/icons'; import ListPanel from '../../components/ListPanel'; import SelectionBar from '../../components/SelectionBar'; import SettingsPanel from '../../components/SettingsPanel'; @@ -60,6 +60,10 @@ const ReaderScreen = () => { const chapterTextResolverRef = useRef<((text: string) => void) | null>(null); const chapterTextTimeoutRef = useRef | null>(null); const relocatedResolverRef = useRef<(() => void) | null>(null); + // 供 advanceToNextChapter 判斷「是否已經跨到新章節」/「是否已到書尾」,用 ref 而非 + // state 是因為要在同一個非同步迴圈裡讀到最新值,不能等 re-render 後的閉包。 + const currentHrefRef = useRef(''); + const atEndRef = useRef(false); const tts = useTTS(); useFocusEffect( @@ -96,9 +100,12 @@ const ReaderScreen = () => { useEffect(() => { tts.reset(); + webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStop' })); setToc([]); setCurrentCfi(''); setCurrentHref(''); + currentHrefRef.current = ''; + atEndRef.current = false; setCurrentChapterTitle(''); setPageInfo(null); setListPanelTab(null); @@ -173,6 +180,8 @@ const ReaderScreen = () => { setLoading(false); setCurrentCfi(msg.cfi); setCurrentHref(msg.href); + currentHrefRef.current = msg.href; + atEndRef.current = msg.atEnd; setCurrentChapterTitle(msg.chapterTitle); setPageInfo(msg.page !== null && msg.total !== null ? { page: msg.page, total: msg.total, percentage: msg.percentage } : null); relocatedResolverRef.current?.(); @@ -276,29 +285,85 @@ const ReaderScreen = () => { }); }, []); - // 朗讀完目前章節後,翻到下一頁/章節再繼續朗讀;已翻到書尾(抓不到文字)就自然停止。 - // 這是簡化版的自動接續章節,沒有網頁版 useTTS.ts 那套精確字元位移與高亮同步(見 - // RN_SETUP_GUIDE.md 第十二輪紀錄),只保證朗讀完一頁會自動接著念下一頁。 + // 記住「這次 tts.speak() 開始朗讀時,畫面所在的章節 href」,advanceToNextChapter 用這個 + // 當基準比對,而不是每次都重新取 currentHrefRef 的當下值——這個朗讀 session 期間畫面 + // 可能已經被 WebView 端的自動跟讀翻頁(見下方註解)推進到新章節了,用「呼叫當下」的 + // href 當基準會讓下面的迴圈誤判成「還沒跨到新章節」而多翻一次頁。 + const readingHrefRef = useRef(''); + + // 朗讀進行中,WebView 端(reader-web/index.ts 的 handleTTSBoundary)會自己在快讀到目前 + // 頁面底部時呼叫 turnPage('next') 把畫面翻到下一頁,不需要 RN 端介入——一個章節在 + // paginated 模式下是同一份 iframe document 用 CSS 分欄呈現好幾頁,翻頁不會觸發新的 + // chapterText,朗讀本身也不會中斷。這裡的 readNextAndContinue 只在「整章文字真的念完」 + // 時才呼叫,負責跨到下一章:多數情況 WebView 端的自動跟讀翻頁早已把畫面翻到這章最後 + // 一頁並跨過章節邊界(下面迴圈第一次檢查就會發現 href 已經變了,直接返回,不重複翻頁); + // 只有在自動跟讀翻頁還沒來得及跟上(節流/提前量測誤差造成的些微落後)時才需要额外 + // 呼叫 next() 補上,避免漏翻的頁面被跳過、也避免因為還停在同一章就重新抓到「同一份」 + // 章節文字,變成整章從頭重念一次的無窮迴圈。 + const advanceToNextChapter = useCallback(async (): Promise => { + const startHref = readingHrefRef.current; + for (let i = 0; i < 50; i++) { + if (currentHrefRef.current !== startHref) return true; + if (atEndRef.current) return false; + webviewRef.current?.postMessage(JSON.stringify({ type: 'next' })); + await waitForRelocated(); + } + return currentHrefRef.current !== startHref; + }, [waitForRelocated]); + const continueReadingRef = useRef<() => void>(() => {}); const readNextAndContinue = useCallback(async () => { - webviewRef.current?.postMessage(JSON.stringify({ type: 'next' })); - await waitForRelocated(); + const advanced = await advanceToNextChapter(); + if (!advanced) return; const text = await requestChapterText(); if (!text.trim()) return; - tts.speak(text, () => continueReadingRef.current()); - }, [requestChapterText, tts, waitForRelocated]); + readingHrefRef.current = currentHrefRef.current; + webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStart' })); + tts.speak( + text, + () => continueReadingRef.current(), + (charIndex) => webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsBoundary', charIndex })) + ); + }, [advanceToNextChapter, requestChapterText, tts]); useEffect(() => { continueReadingRef.current = readNextAndContinue; }, [readNextAndContinue]); const handleTTSPlay = useCallback(async () => { if (tts.paused) { tts.resume(); + webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStart' })); return; } const text = await requestChapterText(); if (!text.trim()) return; - tts.speak(text, () => continueReadingRef.current()); + readingHrefRef.current = currentHrefRef.current; + webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStart' })); + tts.speak( + text, + () => continueReadingRef.current(), + (charIndex) => webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsBoundary', charIndex })) + ); }, [tts, requestChapterText]); + const handleTTSPause = useCallback(() => { + tts.pause(); + webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStop' })); + }, [tts]); + + const handleTTSReset = useCallback(() => { + tts.reset(); + webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStop' })); + }, [tts]); + + // 朗讀控制列上的睡眠計時是快速切換用途(跟設定面板裡完整的分段選單共用同一份 + // tts.sleepMinutes/onSleepChange 狀態,不是獨立的第二套計時),每點一次照固定選項 + // 循環切換,不需要另外彈出選單。 + const SLEEP_CYCLE_OPTIONS = [0, 15, 30, 45, 60] as const; + const handleCycleSleep = useCallback(() => { + const idx = SLEEP_CYCLE_OPTIONS.indexOf(tts.sleepMinutes as (typeof SLEEP_CYCLE_OPTIONS)[number]); + const next = SLEEP_CYCLE_OPTIONS[(idx + 1) % SLEEP_CYCLE_OPTIONS.length]; + tts.onSleepChange(next); + }, [tts]); + const handleShouldStartLoadWithRequest = useCallback((request: { url: string }) => { const { url } = request; if (url.startsWith('about:') || url.startsWith('data:') || url.startsWith('blob:')) return true; @@ -573,8 +638,8 @@ const ReaderScreen = () => { ttsPlaying={tts.playing} ttsPaused={tts.paused} onTTSPlay={handleTTSPlay} - onTTSPause={tts.pause} - onTTSReset={tts.reset} + onTTSPause={handleTTSPause} + onTTSReset={handleTTSReset} ttsVoices={tts.voices} ttsSelectedVoice={tts.selectedVoice} onTTSVoiceChange={tts.setSelectedVoice} @@ -618,6 +683,71 @@ const ReaderScreen = () => { onDelete={() => handleDeleteAnnotation(editingAnnotationId)} /> )} + {/* 朗讀控制列:跟下面的頁碼列一樣固定高度、一律渲染(不論是否正在朗讀),放在內容 + 與底部頁碼列之間,屬於一般排版流(不是蓋在 WebView 上的 overlay),才不會遮到 + 正在閱讀的文字;也因為高度固定不隨播放狀態變動,不會觸發 WebView resize 讓 + epub.js 重新分頁(見下面頁碼列註解,同一個理由)。 */} + + + + + + {tts.playing ? : } + + + 0 ? colors.paperBg2 : 'transparent', + borderWidth: 1, borderColor: colors.borderColor, + }} + > + 0 ? colors.progressFill : colors.ink2} /> + + {tts.sleepMinutes > 0 && ( + + {tts.sleepRemaining !== null + ? `${String(Math.floor(tts.sleepRemaining / 60)).padStart(2, '0')}:${String(tts.sleepRemaining % 60).padStart(2, '0')}` + : `${tts.sleepMinutes}分`} + + )} + + {/* 固定高度、一律渲染(即使 pageInfo 還是 null 也只是內容留白):避免 pageInfo 從 null 變有值時這塊區域才冒出來,導致 WebView 版面高度跟著變動——epub.js 的分頁是 依照初次拿到的 viewer 尺寸算的,事後才緊縮 WebView 高度容易讓已渲染好的那一頁內容 diff --git a/mobile/components/icons.tsx b/mobile/components/icons.tsx index b14b370..1266c31 100644 --- a/mobile/components/icons.tsx +++ b/mobile/components/icons.tsx @@ -116,6 +116,15 @@ export const IconReset = ({ size = 15, color = '#000' }: IconProps) => ( ); +// 朗讀睡眠計時按鈕用的時鐘圖示。原本用月亮圖示,但月亮在這個 App(以及一般 UI 慣例) +// 太容易被誤認成「深色模式切換」,改用時鐘造型避免跟主題色切換混淆。 +export const IconSleepTimer = ({ size = 16, color = '#000' }: IconProps) => ( + + + + +); + export const IconCopy = ({ size = 14, color = '#000' }: IconProps) => ( diff --git a/mobile/lib/readerHtml.generated.ts b/mobile/lib/readerHtml.generated.ts index 62a36e7..c73ef92 100644 --- a/mobile/lib/readerHtml.generated.ts +++ b/mobile/lib/readerHtml.generated.ts @@ -1,3 +1,3 @@ // 此檔案由 `yarn build:reader`(scripts/build-reader-html.js)自動產生,請勿手動編輯。 // 原始碼位於 mobile/reader-web/index.ts。 -export const READER_HTML = "\n\n\n\n\n\n\n\n
\n
\n
\n
\n
\n\n\n"; +export const READER_HTML = "\n\n\n\n\n\n\n\n
\n
\n
\n
\n
\n\n\n"; diff --git a/mobile/lib/readerMessages.ts b/mobile/lib/readerMessages.ts index 2197757..acff1b6 100644 --- a/mobile/lib/readerMessages.ts +++ b/mobile/lib/readerMessages.ts @@ -24,7 +24,10 @@ export type InboundMessage = | { type: 'getChapterText' } | { type: 'setAnnotations'; annotations: AnnotationMark[] } | { type: 'clearSelection' } - | { type: 'setAnnotationMode'; enabled: boolean }; + | { type: 'setAnnotationMode'; enabled: boolean } + | { type: 'ttsStart' } + | { type: 'ttsBoundary'; charIndex: number } + | { type: 'ttsStop' }; export type OutboundMessage = | { type: 'ready' } diff --git a/mobile/lib/tts.ts b/mobile/lib/tts.ts index cc355b4..d100bea 100644 --- a/mobile/lib/tts.ts +++ b/mobile/lib/tts.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState } from 'react-native'; import * as Speech from 'expo-speech'; // 手機系統 TTS 引擎對單次 utterance 的文字長度可能有限制(比照網頁版 useTTS.ts 的 @@ -73,10 +74,28 @@ export const useTTS = () => { const rateRef = useRef(1.0); const selectedVoiceRef = useRef(null); + // fullTextRef 是這次 speak() 傳入的完整文字(不會因為切成 chunk 或暫停/恢復而改變); + // chunksRef 則是「目前這一輪」實際拿去餵給 Speech.speak() 的切段結果,恢復播放時會用 + // fullTextRef 從暫停位置重新切一輪新的 chunksRef(見 resume() 的說明),兩者不可混用。 + const fullTextRef = useRef(''); const chunksRef = useRef([]); const chunkIndexRef = useRef(0); + // baseOffsetRef:目前這輪 chunksRef 在 fullTextRef 裡的起始位移。一般從頭朗讀時是 0; + // 從暫停位置恢復播放時,chunksRef 是從暫停處重新切出來的,這裡就會是暫停時的絕對位移, + // 讓 onBoundary 回報出去的字元位移永遠是相對於 fullTextRef(也就是呼叫端認知的「整章 + // 文字」)的絕對位置,不會因為中途暫停過一次就整個位移錯位。 + const baseOffsetRef = useRef(0); + // chunkStartOffsetRef/lastBoundaryCharIndexRef:分別記錄「目前這個 chunk 在 chunksRef + // 裡的起始位移」與「最近一次 onBoundary 回報、相對於目前 chunk 的字元位移」,兩者相加 + // 再加上 baseOffsetRef,就是暫停當下真正朗讀到的絕對位置——這是修正「暫停後再播放會 + // 整個 chunk 重講一次」的關鍵:舊版 resume() 是直接重新呼叫 speakChunk() 重講同一個 + // chunk,expo-speech 的 Speech.speak() 只能從文字開頭朗讀,沒有「從第 N 個字繼續」的 + // API,所以整個 chunk(最長可能到 3000 字)都會從頭重念一次。 + const chunkStartOffsetRef = useRef(0); + const lastBoundaryCharIndexRef = useRef(0); const generationRef = useRef(0); const onAllDoneRef = useRef<(() => void) | undefined>(undefined); + const onBoundaryRef = useRef<((charIndex: number) => void) | undefined>(undefined); const sleepTimerRef = useRef | null>(null); useEffect(() => { rateRef.current = rate; }, [rate]); @@ -110,11 +129,21 @@ export const useTTS = () => { }); }, []); + // 睡眠計時的到期時間點(絕對時間戳,非倒數秒數):iOS 上 App 進入背景(例如使用者鎖螢幕、 + // 或切到其他 App 讓朗讀在背景繼續播放——這正是睡眠計時最常見的使用情境)時,RN 的 + // setInterval 會被系統節流甚至完全暫停好幾分鐘才補跑一次,如果倒數邏輯是「每次 tick + // 扣 1 秒」,被節流期間流逝的真實時間並不會被扣掉,導致「設定 15 分鐘,回到前景時卻早就 + // 超過 15 分鐘還在播放」。改成每次 tick 都用 Date.now() 跟這個絕對到期時間比較,不管 + // tick 被延後多久,只要真的醒來執行一次就會偵測到「已經過期」並立刻停止,不會因為少 + // 跑了幾次 tick 就把到期時間往後推。 + const sleepDeadlineAtRef = useRef(null); + const clearSleepTimer = useCallback(() => { if (sleepTimerRef.current !== null) { clearInterval(sleepTimerRef.current); sleepTimerRef.current = null; } + sleepDeadlineAtRef.current = null; setSleepRemaining(null); }, []); @@ -132,23 +161,39 @@ export const useTTS = () => { clearSleepTimer(); setSleepMinutes(0); onAllDoneRef.current = undefined; + onBoundaryRef.current = undefined; }, [stop, clearSleepTimer]); + // 檢查是否已經超過到期時間,超過就停止播放並清掉計時器;供下面的 interval tick 跟 + // AppState 回到前景時共用同一份判斷邏輯,避免兩處各自實作出不一致的行為。 + const checkSleepDeadline = useCallback(() => { + const deadline = sleepDeadlineAtRef.current; + if (deadline === null) return; + const msLeft = deadline - Date.now(); + if (msLeft <= 0) { + clearSleepTimer(); + stop(); + return; + } + setSleepRemaining(Math.ceil(msLeft / 1000)); + }, [clearSleepTimer, stop]); + const startSleepTimer = useCallback((minutes: number) => { clearSleepTimer(); if (minutes <= 0) return; - let remaining = minutes * 60; - setSleepRemaining(remaining); - sleepTimerRef.current = setInterval(() => { - remaining -= 1; - if (remaining <= 0) { - clearSleepTimer(); - stop(); - return; - } - setSleepRemaining(remaining); - }, 1000); - }, [clearSleepTimer, stop]); + sleepDeadlineAtRef.current = Date.now() + minutes * 60_000; + setSleepRemaining(minutes * 60); + sleepTimerRef.current = setInterval(checkSleepDeadline, 1000); + }, [clearSleepTimer, checkSleepDeadline]); + + // App 從背景回到前景時立刻校正一次:不用等下一次 1 秒 tick,避免使用者鎖螢幕朗讀一段 + // 時間後解鎖,畫面短暫還顯示著早已過期的舊倒數數字/仍在播放的朗讀音訊。 + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') checkSleepDeadline(); + }); + return () => subscription.remove(); + }, [checkSleepDeadline]); const speakChunk = useCallback((generation: number) => { const chunk = chunksRef.current[chunkIndexRef.current]; @@ -158,6 +203,14 @@ export const useTTS = () => { onAllDoneRef.current?.(); return; } + // chunk 只是因應 MAX_UTTERANCE_LENGTH 切出來的其中一段,onBoundary 回報的 charIndex + // 是相對於「這個 chunk」的位移;呼叫端(reader 頁)需要的是相對於整段朗讀文字(一整章) + // 的絕對位移,才能拿去比對 WebView 內畫底線/頁面邊界用的字元索引,因此這裡要加上 + // 前面所有已念完 chunk 的長度總和,再加上這一輪 chunksRef 相對於 fullTextRef 的起始位移。 + let chunkStartOffset = 0; + for (let i = 0; i < chunkIndexRef.current; i++) chunkStartOffset += chunksRef.current[i].length; + chunkStartOffsetRef.current = chunkStartOffset; + lastBoundaryCharIndexRef.current = 0; Speech.speak(chunk, { voice: selectedVoiceRef.current?.identifier, language: selectedVoiceRef.current?.language ?? 'zh-TW', @@ -175,19 +228,28 @@ export const useTTS = () => { setPlaying(false); setPaused(false); }, + onBoundary: (ev: { charIndex: number }) => { + if (generationRef.current !== generation) return; + lastBoundaryCharIndexRef.current = ev.charIndex; + onBoundaryRef.current?.(baseOffsetRef.current + chunkStartOffset + ev.charIndex); + }, }); }, []); - const speak = useCallback((text: string, onAllDone?: () => void) => { + const speak = useCallback((text: string, onAllDone?: () => void, onBoundary?: (charIndex: number) => void) => { if (!text.trim()) return; const generation = ++generationRef.current; + fullTextRef.current = text; + baseOffsetRef.current = 0; chunksRef.current = splitTextByLength(text); chunkIndexRef.current = 0; onAllDoneRef.current = onAllDone; + onBoundaryRef.current = onBoundary; setPlaying(true); setPaused(false); - // 使用者可能在按下播放前就先選好睡眠計時,這裡補上啟動,避免預先選的分鐘數被忽略。 - if (sleepMinutes > 0) startSleepTimer(sleepMinutes); + // 使用者可能在按下播放前就先選好睡眠計時,這裡補上啟動,避免預先選的分鐘數被忽略; + // 但如果計時已經在跑(例如跨章節自動接續朗讀),不能重新啟動,否則倒數會被每一章重置。 + if (sleepMinutes > 0 && sleepDeadlineAtRef.current === null) startSleepTimer(sleepMinutes); speakChunk(generation); }, [speakChunk, sleepMinutes, startSleepTimer]); @@ -199,9 +261,25 @@ export const useTTS = () => { setPaused(true); }, [playing]); + // 恢復播放:expo-speech 的 Speech.speak() 沒有「從第 N 個字繼續朗讀」的 API,只能重新 + // 給一段文字從頭念。因此不能像舊版那樣直接重講暫停當下的整個 chunk(可能長達 3000 字), + // 而是要用暫停時記下的絕對位移,從 fullTextRef 裡切出「還沒念的部分」重新分段、從頭 + // 這一小段開始念——對使用者來說就是「接續播放」,聽感上跟真正的逐字元續播是一致的 + // (最多差在最近一次 onBoundary 到暫停指令之間那一小段,通常是零到一個詞的長度)。 const resume = useCallback(() => { if (!paused) return; + const resumeFrom = baseOffsetRef.current + chunkStartOffsetRef.current + lastBoundaryCharIndexRef.current; + const remaining = fullTextRef.current.slice(resumeFrom); + if (!remaining.trim()) { + setPlaying(false); + setPaused(false); + onAllDoneRef.current?.(); + return; + } const generation = ++generationRef.current; + baseOffsetRef.current = resumeFrom; + chunksRef.current = splitTextByLength(remaining); + chunkIndexRef.current = 0; setPlaying(true); setPaused(false); speakChunk(generation); diff --git a/mobile/reader-web/index.ts b/mobile/reader-web/index.ts index 007e122..68b7b31 100644 --- a/mobile/reader-web/index.ts +++ b/mobile/reader-web/index.ts @@ -3,6 +3,15 @@ import type { Book, Rendition } from 'epubjs'; import * as OpenCC from 'opencc-js'; import type { AnnotationMark, InboundMessage, OutboundMessage, TocItem } from '../lib/readerMessages'; import { DEFAULT_TYPOGRAPHY, normalizeFontFamily, type TypographySettings } from '../lib/readerSettings'; +import { + clearTTSHighlight, + createRangeFromTextOffset, + ensureTTSHighlightStyle, + getBoundaryOffsetFromRange, + getTextIndex, + invalidateTextIndex, + paintTTSHighlightOverlay, +} from './ttsHighlight'; declare global { interface Window { @@ -153,6 +162,7 @@ const applyScriptToDoc = (doc: Document) => { } else { convertDoc(doc, typography.script === 'sc' ? getToSC() : getToTC()); } + invalidateTextIndex(doc); }; // 比照 renderer/src/components/Reader/readerStyles.ts 的字體/行距/字距覆寫邏輯, @@ -348,6 +358,100 @@ const turnPage = (direction: 'prev' | 'next') => { .catch(() => unlockNav()); }; +// 朗讀跟讀高亮/精確頁面邊界自動翻頁:比照網頁版 Reader.tsx 的 updateTTSHighlight/ +// followTTSRange。RN 端只負責把 expo-speech 的 onBoundary charIndex(相對於整章文字的 +// 絕對位移)轉送過來,實際的「畫底線標記在朗讀中的句子下方」與「快讀到頁尾就翻頁」都在 +// 這裡完成——因為只有這裡才有 epub.js 的 rendition/DOM 可以用。 +const getVisibleDoc = (): Document | null => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contents = (rendition as any)?.getContents?.() as { document: Document }[] | undefined; + return contents?.[0]?.document ?? null; +}; + +let ttsDoc: Document | null = null; +let ttsPageStartOffset: number | null = null; +let ttsPageEndOffset: number | null = null; +let ttsAutoFollowBusy = false; +let ttsAutoFollowLastAt = 0; +let ttsAutoFollowFallbackTimer: ReturnType | null = null; +// 觸發翻頁的位移量比頁尾實際字元位移提前一點點,讓翻頁動作跟朗讀到頁尾幾乎同時完成, +// 而不是朗讀完頁尾最後一個字才觸發(會感覺翻頁慢半拍)。 +const TTS_PAGE_END_LEAD = 8; +const TTS_AUTO_FOLLOW_THROTTLE = 650; + +const measurePageEdgeOffset = (doc: Document, edge: 'start' | 'end'): number | null => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loc = (rendition as any)?.currentLocation?.(); + const cfi = loc?.[edge]?.cfi as string | undefined; + if (!cfi || !rendition) return null; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const range = (rendition as any).getRange?.(cfi) as Range | null | undefined; + return getBoundaryOffsetFromRange(doc, range, edge); + } catch { + return null; + } +}; + +const refreshTTSPageBounds = () => { + if (!ttsDoc) return; + ttsPageStartOffset = measurePageEdgeOffset(ttsDoc, 'start'); + ttsPageEndOffset = measurePageEdgeOffset(ttsDoc, 'end'); +}; + +// RN 端每次開始朗讀新的一章(或從暫停恢復)都會送一次 ttsStart:以目前可見的 iframe +// document 為朗讀對象,記住它並量出目前頁面的字元邊界,供後續 boundary 事件比對。 +const startTTSTracking = () => { + ttsDoc = getVisibleDoc(); + ttsAutoFollowBusy = false; + ttsAutoFollowLastAt = 0; + refreshTTSPageBounds(); +}; + +const stopTTSTracking = () => { + if (ttsDoc) clearTTSHighlight(ttsDoc); + ttsDoc = null; + ttsPageStartOffset = null; + ttsPageEndOffset = null; + ttsAutoFollowBusy = false; + if (ttsAutoFollowFallbackTimer) { + clearTimeout(ttsAutoFollowFallbackTimer); + ttsAutoFollowFallbackTimer = null; + } +}; + +const handleTTSBoundary = (charIndex: number) => { + // ttsDoc.defaultView 在 iframe 從 DOM 移除後會變成 null(比照 pruneStaleContentDocs + // 的判斷)——章節朗讀完後舊 iframe 可能已經被 epub.js 卸載,這裡的殘餘 boundary 事件 + // 直接略過,不強制存取已卸載文件。 + if (!ttsDoc || !ttsDoc.defaultView) return; + ensureTTSHighlightStyle(ttsDoc); + const range = createRangeFromTextOffset(ttsDoc, charIndex); + if (range) paintTTSHighlightOverlay(ttsDoc, range); + + // 使用者若在朗讀中手動跳去別的章節,目前可見的 document 已經不是朗讀中的 ttsDoc, + // 不應該再自動翻頁去追朗讀進度(那樣會把使用者剛跳去的畫面搶走)。 + if (getVisibleDoc() !== ttsDoc) return; + if (ttsAutoFollowBusy) return; + if (ttsPageEndOffset === null) return; + const turnAt = Math.max(ttsPageStartOffset ?? 0, ttsPageEndOffset - TTS_PAGE_END_LEAD); + if (charIndex < turnAt) return; + + const now = Date.now(); + if (now - ttsAutoFollowLastAt < TTS_AUTO_FOLLOW_THROTTLE) return; + ttsAutoFollowLastAt = now; + ttsAutoFollowBusy = true; + turnPage('next'); + // 正常情況下下面 rendition.on('relocated') 的處理會在翻頁完成後立刻解鎖並重新量測頁面 + // 邊界;這裡的逾時只是保險,避免 relocated 因故沒有觸發(例如已經翻到全書最後一頁) + // 導致自動翻頁永久卡死。 + if (ttsAutoFollowFallbackTimer) clearTimeout(ttsAutoFollowFallbackTimer); + ttsAutoFollowFallbackTimer = setTimeout(() => { + ttsAutoFollowBusy = false; + refreshTTSPageBounds(); + }, 2000); +}; + // 章節目錄的 href 格式常常跟 spine item 的 href 對不上:epub.js 的 Navigation 解析器完全不會 // 正規化 nav/ncx 檔案裡寫的 href(見 node_modules/epubjs/src/navigation.js,沒有傳入 resolver), // 而 nav.xhtml/toc.ncx 常常跟內容檔案放在不同資料夾,導致目錄裡的 href 是相對於 nav 檔案自己的 @@ -746,6 +850,7 @@ const loadBook = async (base64: string, cfi: string | null, initialAnnotations: lastRelocatedLoc = null; lastLinearSpineIndex = null; renderedAnnotations = new Map(); + stopTTSTracking(); try { book = ePub(base64ToArrayBuffer(base64)); @@ -867,6 +972,22 @@ const loadBook = async (base64: string, cfi: string | null, initialAnnotations: const previousCfi = (lastRelocatedLoc as any)?.start?.cfi ?? null; lastRelocatedLoc = loc; postRelocated(loc, previousCfi); + // 朗讀中的章節如果還是目前可見章節,翻頁完成後重新量測新頁面的字元邊界,並解除 + // 自動翻頁的忙碌鎖(不論這次翻頁是自動跟讀觸發還是使用者手動翻頁都要重新量測, + // 因為兩種情況下「目前頁面」都變了);如果朗讀中的章節已經不是目前可見章節 + // (使用者手動跳走),只清掉底線標記,不再嘗試量測。 + if (ttsDoc) { + if (getVisibleDoc() === ttsDoc) { + refreshTTSPageBounds(); + ttsAutoFollowBusy = false; + if (ttsAutoFollowFallbackTimer) { + clearTimeout(ttsAutoFollowFallbackTimer); + ttsAutoFollowFallbackTimer = null; + } + } else { + clearTTSHighlight(ttsDoc); + } + } }); // 文字選取 → 劃線註記的第一步:epub.js 已經幫忙把選取範圍換算成 CFI 字串, @@ -932,16 +1053,15 @@ const loadBook = async (base64: string, cfi: string | null, initialAnnotations: // TTS 朗讀文字來源:目前顯示中章節的 iframe document.body 全文(paginated 模式下, // 一個章節的完整內容是渲染在同一份 document 裡用 CSS 分欄呈現,body.textContent 涵蓋整章, -// 不只是目前可見的那一頁)。這是簡化版實作,不像網頁版那樣從目前頁面的精確字元位移開始, -// 一律從章節開頭朗讀;也還沒有 CFI/高亮同步,僅供 mobile 第一版朗讀功能使用。 +// 不只是目前可見的那一頁),一律從章節開頭朗讀。改用 getTextIndex() 而不是先前的 +// cloneNode+regex 正規化空白,是因為朗讀跟讀高亮需要拿 expo-speech 回報的 charIndex +// (相對於這段文字的絕對位移)反查回真正的 DOM 文字節點畫底線——如果先把文字正規化 +// (空白合併、trim),字元位移就會跟 getTextIndex() 量出來的原始 DOM 位移對不上,畫底線 +// 的位置也會跟著錯位。跟網頁版 Reader.tsx 的 speakCurrentPage 一樣直接用原始節點文字。 const getChapterText = (): string => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const contents = (rendition as any)?.getContents?.() as { document: Document }[] | undefined; - const doc = contents?.[0]?.document; + const doc = getVisibleDoc(); if (!doc?.body) return ''; - const clone = doc.body.cloneNode(true) as HTMLElement; - clone.querySelectorAll('script, style').forEach((el) => el.remove()); - return (clone.textContent ?? '').replace(/\s+/g, ' ').trim(); + return getTextIndex(doc)?.text ?? ''; }; const handleMessage = (event: MessageEvent) => { @@ -968,6 +1088,9 @@ const handleMessage = (event: MessageEvent) => { clearNativeSelection(); } if (msg.type === 'setAnnotationMode') setAnnotationMode(msg.enabled); + if (msg.type === 'ttsStart') startTTSTracking(); + if (msg.type === 'ttsBoundary') handleTTSBoundary(msg.charIndex); + if (msg.type === 'ttsStop') stopTTSTracking(); }; // RN WebView 在 Android 觸發 document 的 message 事件,iOS 觸發 window 的,兩者都要監聽 diff --git a/mobile/reader-web/ttsHighlight.ts b/mobile/reader-web/ttsHighlight.ts new file mode 100644 index 0000000..19e05fe --- /dev/null +++ b/mobile/reader-web/ttsHighlight.ts @@ -0,0 +1,230 @@ +// 朗讀跟讀高亮/頁面邊界量測:整套演算法照抄網頁版 +// pwa/src/components/Reader/ttsHighlight.ts(文字節點索引、CSS Custom Highlight API + +// DOM overlay 降級、CFI→字元位移換算),這裡是給 mobile 的 reader-web bundle(跑在 +// RN WebView 內的 epub.js 環境)用的獨立版本,因為 mobile 沒有 React,且訊息協定跟網頁版 +// 完全不同,無法直接共用檔案。 + +const TTS_HIGHLIGHT_ID = 'tit-tts-progress'; +const TTS_HIGHLIGHT_STYLE_ID = 'tit-tts-progress-style'; +const TTS_HIGHLIGHT_OVERLAY_ID = 'tit-tts-progress-overlay'; +const TTS_HIGHLIGHT_LENGTH = 4; + +type TextIndex = { nodes: Text[]; starts: number[]; total: number; text: string }; +const ttsTextIndexCache = new WeakMap(); + +const injectStyleTag = (doc: Document, id: string, css: string) => { + let el = doc.getElementById(id) as HTMLStyleElement | null; + if (!el) { + el = doc.createElement('style'); + el.id = id; + doc.head?.appendChild(el); + } + el.textContent = css; +}; + +export const ensureTTSHighlightStyle = (doc: Document) => { + injectStyleTag(doc, TTS_HIGHLIGHT_STYLE_ID, ` + ::highlight(${TTS_HIGHLIGHT_ID}) { + background-color: rgba(245, 158, 11, 0.32); + color: inherit; + } + `); +}; + +export const clearTTSHighlight = (doc: Document | null | undefined) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const highlights = (doc?.defaultView as any)?.CSS?.highlights; + highlights?.delete?.(TTS_HIGHLIGHT_ID); + doc?.getElementById(TTS_HIGHLIGHT_OVERLAY_ID)?.remove(); +}; + +// script/style 標籤底下的文字節點不算進朗讀文字(跟舊版 getChapterText 的 +// cloneNode+querySelectorAll('script, style').remove() 是同一個過濾意圖,只是這裡改成 +// 在即時 DOM 上用 TreeWalker 過濾,而不是先複製整份文件——因為畫底線用的 Range 必須指向 +// 真正渲染中的節點,複製出來的 clone 節點無法拿來畫底線。 +const isInScriptOrStyle = (node: Node): boolean => { + let el = node.parentElement; + while (el) { + const tag = el.tagName; + if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return true; + el = el.parentElement; + } + return false; +}; + +// 簡繁轉換等會直接改寫 Text.nodeValue 的操作要呼叫這個清掉快取, +// 否則 getTextIndex().text 會回傳轉換前的舊文字。 +export const invalidateTextIndex = (doc: Document) => { + ttsTextIndexCache.delete(doc); +}; + +export const getTextIndex = (doc: Document): TextIndex | null => { + const cached = ttsTextIndexCache.get(doc); + if (cached) return cached; + const body = doc.body; + if (!body) return null; + + const nodes: Text[] = []; + const starts: number[] = []; + const pieces: string[] = []; + const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT); + let node: Node | null; + let total = 0; + while ((node = walker.nextNode())) { + if (isInScriptOrStyle(node)) continue; + const textNode = node as Text; + const len = (textNode.nodeValue ?? '').length; + if (len === 0) continue; + nodes.push(textNode); + starts.push(total); + pieces.push(textNode.nodeValue ?? ''); + total += len; + } + + const index = { nodes, starts, total, text: pieces.join('') }; + ttsTextIndexCache.set(doc, index); + return index; +}; + +const findTextNodeAt = (index: TextIndex, offset: number) => { + if (index.nodes.length === 0) return null; + const safeOffset = Math.max(0, Math.min(offset, Math.max(index.total - 1, 0))); + let lo = 0; + let hi = index.starts.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const start = index.starts[mid]; + const end = start + (index.nodes[mid].nodeValue ?? '').length; + if (safeOffset < start) hi = mid - 1; + else if (safeOffset >= end) lo = mid + 1; + else return { node: index.nodes[mid], offset: safeOffset - start }; + } + const last = index.nodes.length - 1; + return { node: index.nodes[last], offset: Math.max(0, (index.nodes[last].nodeValue ?? '').length - 1) }; +}; + +const getTextOffsetFromRange = (doc: Document, range: Range | null | undefined, edge: 'start' | 'end'): number | null => { + if (!range) return null; + const index = getTextIndex(doc); + if (!index) return null; + + const container = edge === 'end' ? range.endContainer : range.startContainer; + const offset = edge === 'end' ? range.endOffset : range.startOffset; + + if (container.nodeType === Node.TEXT_NODE) { + const textIdx = index.nodes.indexOf(container as Text); + if (textIdx < 0) return null; + const nodeLength = (container.nodeValue ?? '').length; + return index.starts[textIdx] + Math.max(0, Math.min(offset, nodeLength)); + } + + const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const textNode = (edge === 'end' ? null : walker.nextNode()) as Text | null; + if (textNode) { + const textIdx = index.nodes.indexOf(textNode); + return textIdx >= 0 ? index.starts[textIdx] : null; + } + + let lastText: Text | null = null; + let node: Node | null; + while ((node = walker.nextNode())) lastText = node as Text; + if (lastText) { + const textIdx = index.nodes.indexOf(lastText); + return textIdx >= 0 ? index.starts[textIdx] + (lastText.nodeValue ?? '').length : null; + } + + return null; +}; + +// epub.js 的 getRange(cfi) 對頁面邊界 CFI 可能展開成一整個元素的 range,這種情況下 +// range.end 常常會指到段落/元素結尾,讓「頁尾」的判斷變得太晚;統一取 range 起點當邊界 +// 位置比較準(跟網頁版 getBoundaryOffsetFromRange 邏輯一致)。 +export const getBoundaryOffsetFromRange = (doc: Document, range: Range | null | undefined, edge: 'start' | 'end'): number | null => { + if (!range) return null; + const startOffset = getTextOffsetFromRange(doc, range, 'start'); + const endOffset = getTextOffsetFromRange(doc, range, 'end'); + if (startOffset === null) return endOffset; + if (endOffset === null) return startOffset; + return edge === 'end' ? Math.min(startOffset, endOffset) : startOffset; +}; + +export const createRangeFromTextOffset = (doc: Document, start: number, length = TTS_HIGHLIGHT_LENGTH): Range | null => { + const index = getTextIndex(doc); + if (!index || index.total === 0) return null; + + const safeStart = Math.max(0, Math.min(start, index.total - 1)); + const startMatch = findTextNodeAt(index, safeStart); + if (!startMatch) return null; + + const startNode = startMatch.node; + const startOffset = Math.max(0, Math.min((startNode.nodeValue ?? '').length, startMatch.offset)); + const nodeText = startNode.nodeValue ?? ''; + let endOffset = Math.min(nodeText.length, startOffset + Math.max(1, length)); + const localText = nodeText.slice(startOffset, endOffset); + const breakAt = localText.search(/[\n\r。!?!?;;,,、]/); + if (breakAt > 0) endOffset = startOffset + breakAt; + if (endOffset <= startOffset) endOffset = Math.min(nodeText.length, startOffset + 1); + if (endOffset <= startOffset) return null; + + const range = doc.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(startNode, endOffset); + return range; +}; + +export const paintTTSHighlightOverlay = (doc: Document, range: Range): boolean => { + // 優先用 CSS Custom Highlight API:完全不動 DOM,避免觸發 epub.js 掛在 iframe + // documentElement 上的 ResizeObserver(改 DOM 結構容易造成量測迴圈)。 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = doc.defaultView as (Window & { CSS?: any; Highlight?: new (...ranges: Range[]) => unknown }) | null; + if (win?.CSS?.highlights !== undefined && typeof win.Highlight === 'function') { + try { + const highlight = new win.Highlight(range); + (win.CSS.highlights as Map).set(TTS_HIGHLIGHT_ID, highlight); + return true; + } catch { + /* 降級到 DOM overlay */ + } + } + + const body = doc.body; + if (!body) return false; + + let overlay = doc.getElementById(TTS_HIGHLIGHT_OVERLAY_ID); + if (!overlay) { + overlay = doc.createElement('div'); + overlay.id = TTS_HIGHLIGHT_OVERLAY_ID; + Object.assign(overlay.style, { + position: 'fixed', + inset: '0', + pointerEvents: 'none', + zIndex: '2147483647', + overflow: 'hidden', + }); + body.appendChild(overlay); + } + + const viewportWidth = doc.documentElement.clientWidth || doc.defaultView?.innerWidth || 0; + const viewportHeight = doc.documentElement.clientHeight || doc.defaultView?.innerHeight || 0; + const rects = Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= viewportHeight && rect.right >= 0 && rect.left <= viewportWidth + ); + + overlay.replaceChildren(); + for (const rect of rects) { + const mark = doc.createElement('div'); + Object.assign(mark.style, { + position: 'fixed', + left: `${Math.max(0, rect.left)}px`, + top: `${Math.max(0, rect.top)}px`, + width: `${Math.max(1, Math.min(rect.width, viewportWidth - Math.max(0, rect.left)))}px`, + height: `${Math.max(1, Math.min(rect.height, viewportHeight - Math.max(0, rect.top)))}px`, + background: 'rgba(245, 158, 11, 0.32)', + borderRadius: '2px', + pointerEvents: 'none', + }); + overlay.appendChild(mark); + } + + return rects.length > 0; +};