diff --git a/icon-src/feature-graphic.svg b/icon-src/feature-graphic.svg new file mode 100644 index 0000000..1057f74 --- /dev/null +++ b/icon-src/feature-graphic.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Travel in Time + 穿越時光.電子書閱讀器 + diff --git a/icon-src/icon-background.svg b/icon-src/icon-background.svg new file mode 100644 index 0000000..c6b8efc --- /dev/null +++ b/icon-src/icon-background.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/icon-src/icon-glyph.svg b/icon-src/icon-glyph.svg new file mode 100644 index 0000000..6e06a7d --- /dev/null +++ b/icon-src/icon-glyph.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon-src/icon-monochrome.svg b/icon-src/icon-monochrome.svg new file mode 100644 index 0000000..5f1efd0 --- /dev/null +++ b/icon-src/icon-monochrome.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon-src/icon-square.svg b/icon-src/icon-square.svg new file mode 100644 index 0000000..7bdbd0a --- /dev/null +++ b/icon-src/icon-square.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/app.json b/mobile/app.json index d82b0bb..57e3d97 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -9,17 +9,23 @@ "userInterfaceStyle": "light", "ios": { "supportsTablet": true, - "bundleIdentifier": "com.travelintime.app" + "bundleIdentifier": "com.travelintime.app", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "android": { "package": "com.travelintime.app", "adaptiveIcon": { - "backgroundColor": "#E6F4FE", + "backgroundColor": "#7a2e10", "foregroundImage": "./assets/android-icon-foreground.png", "backgroundImage": "./assets/android-icon-background.png", "monochromeImage": "./assets/android-icon-monochrome.png" }, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "blockedPermissions": [ + "android.permission.SYSTEM_ALERT_WINDOW" + ] }, "web": { "favicon": "./assets/favicon.png" @@ -30,6 +36,13 @@ ], "experiments": { "typedRoutes": true - } + }, + "extra": { + "router": {}, + "eas": { + "projectId": "016213f3-580c-4f9c-8f50-bdfd797e2f19" + } + }, + "owner": "retsnom" } } diff --git a/mobile/app/reader/[id].tsx b/mobile/app/reader/[id].tsx index 1b3041b..cb27122 100644 --- a/mobile/app/reader/[id].tsx +++ b/mobile/app/reader/[id].tsx @@ -57,7 +57,7 @@ const ReaderScreen = () => { const [listPanelTab, setListPanelTab] = useState<'bookmarks' | 'chapters' | 'bookinfo' | 'notes' | null>(null); const settingsLoadedRef = useRef(false); const hadSavedSettingsRef = useRef(false); - const chapterTextResolverRef = useRef<((text: string) => void) | null>(null); + const chapterTextResolverRef = useRef<((result: { text: string; startOffset: number }) => void) | null>(null); const chapterTextTimeoutRef = useRef | null>(null); const relocatedResolverRef = useRef<(() => void) | null>(null); // 供 advanceToNextChapter 判斷「是否已經跨到新章節」/「是否已到書尾」,用 ref 而非 @@ -205,7 +205,7 @@ const ReaderScreen = () => { clearTimeout(chapterTextTimeoutRef.current); chapterTextTimeoutRef.current = null; } - chapterTextResolverRef.current?.(msg.text); + chapterTextResolverRef.current?.({ text: msg.text, startOffset: msg.startOffset }); chapterTextResolverRef.current = null; return; } @@ -258,14 +258,14 @@ const ReaderScreen = () => { // 若已有一個 getChapterText 請求在飛行中,新請求直接回空字串,避免蓋掉前一個 // resolver 導致前一個呼叫永遠 resolve 不到正確結果;並用逾時保護 WebView 沒回應時 // 呼叫端不會卡死。 - const requestChapterText = useCallback((): Promise => { - if (chapterTextResolverRef.current) return Promise.resolve(''); + const requestChapterText = useCallback((): Promise<{ text: string; startOffset: number }> => { + if (chapterTextResolverRef.current) return Promise.resolve({ text: '', startOffset: 0 }); return new Promise((resolve) => { chapterTextResolverRef.current = resolve; chapterTextTimeoutRef.current = setTimeout(() => { chapterTextResolverRef.current = null; chapterTextTimeoutRef.current = null; - resolve(''); + resolve({ text: '', startOffset: 0 }); }, 5000); webviewRef.current?.postMessage(JSON.stringify({ type: 'getChapterText' })); }); @@ -312,19 +312,25 @@ const ReaderScreen = () => { }, [waitForRelocated]); const continueReadingRef = useRef<() => void>(() => {}); - const readNextAndContinue = useCallback(async () => { - const advanced = await advanceToNextChapter(); - if (!advanced) return; - const text = await requestChapterText(); - if (!text.trim()) return; + + const startSpeaking = useCallback((text: string, startOffset: number) => { 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 })) + (charIndex) => + webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsBoundary', charIndex: charIndex + startOffset })) ); - }, [advanceToNextChapter, requestChapterText, tts]); + }, [tts]); + + 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]); useEffect(() => { continueReadingRef.current = readNextAndContinue; }, [readNextAndContinue]); const handleTTSPlay = useCallback(async () => { @@ -333,16 +339,10 @@ const ReaderScreen = () => { webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStart' })); return; } - const text = await requestChapterText(); + const { text, startOffset } = await requestChapterText(); if (!text.trim()) return; - 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]); + startSpeaking(text, startOffset); + }, [tts, requestChapterText, startSpeaking]); const handleTTSPause = useCallback(() => { tts.pause(); diff --git a/mobile/assets/android-icon-background.png b/mobile/assets/android-icon-background.png index 5ffefc5..b65559e 100644 Binary files a/mobile/assets/android-icon-background.png and b/mobile/assets/android-icon-background.png differ diff --git a/mobile/assets/android-icon-foreground.png b/mobile/assets/android-icon-foreground.png index 3a9e501..94e66e8 100644 Binary files a/mobile/assets/android-icon-foreground.png and b/mobile/assets/android-icon-foreground.png differ diff --git a/mobile/assets/android-icon-monochrome.png b/mobile/assets/android-icon-monochrome.png index 77484eb..2c2e293 100644 Binary files a/mobile/assets/android-icon-monochrome.png and b/mobile/assets/android-icon-monochrome.png differ diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png index 408bd74..b655642 100644 Binary files a/mobile/assets/favicon.png and b/mobile/assets/favicon.png differ diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png index 7165a53..f7de284 100644 Binary files a/mobile/assets/icon.png and b/mobile/assets/icon.png differ diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png index 03d6f6b..94e66e8 100644 Binary files a/mobile/assets/splash-icon.png and b/mobile/assets/splash-icon.png differ diff --git a/mobile/lib/readerHtml.generated.ts b/mobile/lib/readerHtml.generated.ts index c73ef92..37913f2 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 acff1b6..2e9e882 100644 --- a/mobile/lib/readerMessages.ts +++ b/mobile/lib/readerMessages.ts @@ -35,7 +35,7 @@ export type OutboundMessage = | { type: 'error'; message: string } | { type: 'metaExtracted'; title: string; author: string; coverBase64: string | null; coverMediaType: string | null } | { type: 'metaError'; message: string } - | { type: 'chapterText'; text: string } + | { type: 'chapterText'; text: string; startOffset: number } | { type: 'bookLanguageDetected'; baseScript: Script } | { type: 'tocLoaded'; toc: TocItem[] } | { type: 'textSelected'; cfi: string; text: string } diff --git a/mobile/reader-web/index.ts b/mobile/reader-web/index.ts index 68b7b31..9fe7806 100644 --- a/mobile/reader-web/index.ts +++ b/mobile/reader-web/index.ts @@ -1053,15 +1053,25 @@ const loadBook = async (base64: string, cfi: string | null, initialAnnotations: // TTS 朗讀文字來源:目前顯示中章節的 iframe document.body 全文(paginated 模式下, // 一個章節的完整內容是渲染在同一份 document 裡用 CSS 分欄呈現,body.textContent 涵蓋整章, -// 不只是目前可見的那一頁),一律從章節開頭朗讀。改用 getTextIndex() 而不是先前的 -// cloneNode+regex 正規化空白,是因為朗讀跟讀高亮需要拿 expo-speech 回報的 charIndex -// (相對於這段文字的絕對位移)反查回真正的 DOM 文字節點畫底線——如果先把文字正規化 -// (空白合併、trim),字元位移就會跟 getTextIndex() 量出來的原始 DOM 位移對不上,畫底線 -// 的位置也會跟著錯位。跟網頁版 Reader.tsx 的 speakCurrentPage 一樣直接用原始節點文字。 -const getChapterText = (): string => { +// 不只是目前可見的那一頁)。改用 getTextIndex() 而不是先前的 cloneNode+regex 正規化空白, +// 是因為朗讀跟讀高亮需要拿 expo-speech 回報的 charIndex(相對於這段文字的絕對位移)反查 +// 回真正的 DOM 文字節點畫底線——如果先把文字正規化(空白合併、trim),字元位移就會跟 +// getTextIndex() 量出來的原始 DOM 位移對不上,畫底線的位置也會跟著錯位。跟網頁版 +// Reader.tsx 的 speakCurrentPage 一樣直接用原始節點文字。 +// +// startOffset:目前可見那一頁在整章文字裡的起始字元位移,沿用 measurePageEdgeOffset +// (原本只用來偵測「快讀到頁尾要自動翻頁」)算出目前頁面的起點 CFI 對應的位移,讓朗讀 +// 從使用者正在看的這一頁開始念,而不是永遠從章節第 0 個字開始;量不出來(例如還沒有 +// currentLocation)就退回 0,等同原本「從章節開頭」的行為。回傳的 text 已經是切過的 +// 「從當前頁開始」文字,RN 端呼叫 tts.speak() 時 expo-speech 回報的 charIndex 是相對於 +// 這段切過的文字,所以要送回 startOffset 讓呼叫端把 charIndex 加回去,才能對應到這裡 +// getTextIndex() 量出來、相對於整章文字的絕對位移(handleTTSBoundary 用的就是這個絕對值)。 +const getChapterText = (): { text: string; startOffset: number } => { const doc = getVisibleDoc(); - if (!doc?.body) return ''; - return getTextIndex(doc)?.text ?? ''; + if (!doc?.body) return { text: '', startOffset: 0 }; + const fullText = getTextIndex(doc)?.text ?? ''; + const startOffset = measurePageEdgeOffset(doc, 'start') ?? 0; + return { text: fullText.slice(startOffset), startOffset }; }; const handleMessage = (event: MessageEvent) => { @@ -1081,7 +1091,10 @@ const handleMessage = (event: MessageEvent) => { const { type: _type, ...settings } = msg; setTypography(settings); } - if (msg.type === 'getChapterText') post({ type: 'chapterText', text: getChapterText() }); + if (msg.type === 'getChapterText') { + const { text, startOffset } = getChapterText(); + post({ type: 'chapterText', text, startOffset }); + } if (msg.type === 'setAnnotations') applyAnnotations(msg.annotations); if (msg.type === 'clearSelection') { debugLog('[clearSelection] 收到訊息'); diff --git a/store-assets/google-play/app-icon-512x512.png b/store-assets/google-play/app-icon-512x512.png new file mode 100644 index 0000000..3cdd2e2 Binary files /dev/null and b/store-assets/google-play/app-icon-512x512.png differ diff --git a/store-assets/google-play/feature-graphic-1024x500.png b/store-assets/google-play/feature-graphic-1024x500.png new file mode 100644 index 0000000..3adb1f6 Binary files /dev/null and b/store-assets/google-play/feature-graphic-1024x500.png differ