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 @@
+
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";
+export const READER_HTML = "\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