diff --git a/RN_SETUP_GUIDE.md b/RN_SETUP_GUIDE.md index b4f96f9..576d04c 100644 --- a/RN_SETUP_GUIDE.md +++ b/RN_SETUP_GUIDE.md @@ -291,8 +291,8 @@ Monorepo 常見誤區:使用者回報「手機上」的問題,實際上可 1. ~~**epub 渲染方案選型與實作**~~ **MVP 已完成**(2026-07-03):`react-native-webview` + `mobile/reader-web/index.ts`(自帶 epub.js,esbuild 打包成 `mobile/lib/readerHtml.generated.ts`),已接上 `mobile/lib/library.ts` 的 `getBookBase64`/`saveReadingCfi`/`loadReadingCfi`/`updateProgress`。**尚未做**:翻頁手勢滑動(目前只有 tap zone)、深色模式/字體設定套用進 WebView 內容、精確全書頁碼(目前用單章節 page/total 概算)。**尚未實機測試**,下一步優先做這個。 - 已放棄的候選:`@epubjs-react-native/core`(npm 最後發布 2025-01,與 RN 0.86 相容性未知,風險較高)。 -2. ~~**書籍儲存與檔案存取**~~ **已完成**:`mobile/lib/library.ts`,見上方「已完成」清單。epub metadata(書名/作者/封面)擷取仍未做,先用檔名當書名。 -3. ~~**書櫃頁(`(tabs)/index.tsx`)實作**~~ **已完成基本版,加入書籍流程已在 iOS/Android 模擬器實測成功**:清單、加入、長按刪除、點擊進入 `reader/[id]`。**未做**:封面縮圖(因尚無 metadata 擷取)、格狀版面(目前是清單);刪除書籍、進度顯示尚未逐一實測。 +2. ~~**書籍儲存與檔案存取**~~ **已完成**:`mobile/lib/library.ts`,見上方「已完成」清單。~~epub metadata(書名/作者/封面)擷取仍未做~~ **已完成**(2026-07-04,見上方「第四輪」段落),借用 WebView 內的 epub.js 擷取,**尚未實機/模擬器測試**。 +3. ~~**書櫃頁(`(tabs)/index.tsx`)實作**~~ **已完成基本版,加入書籍流程已在 iOS/Android 模擬器實測成功**:清單、加入、長按刪除、點擊進入 `reader/[id]`。~~未做:封面縮圖(因尚無 metadata 擷取)~~ **封面縮圖已實作**(2026-07-04,尚未實機測試);格狀版面仍未做(目前是清單);刪除書籍、進度顯示尚未逐一實測。 4. ~~**閱讀頁(`reader/[id].tsx`)實作**~~ **MVP 已完成**(併入第 1 點):接上 epub 渲染方案、tap 翻頁、閱讀進度回存。**下一步待驗證**:實機/模擬器實測 → 再補翻頁滑動手勢、閱讀設定(字體/深色)套用進 WebView 內容。 5. **TTS 朗讀功能**:評估 `expo-speech`(朗讀)+ 需自行實作高亮同步邏輯(web 版原本綁定 epub.js 的 CFI,RN 需視渲染方案重新設計事件橋接)。 6. **主題/深色模式**:比照 Electron 版 `darkMode` 狀態,搬到設定頁骨架,需決定用 RN 的 `useColorScheme` 自動跟隨系統,或維持手動切換 + 持久化(`AsyncStorage`)。 @@ -301,4 +301,39 @@ Monorepo 常見誤區:使用者回報「手機上」的問題,實際上可 9. **iOS 26 原生 header 問題**:目前 `reader/[id].tsx` 已預先採用自繪 header,其餘頁面若未來改用原生 header 且有返回按鈕,需留意第 7 節的白色圓形背景問題。 10. **實機/模擬器測試**:待功能有基本可視內容後,盡早進行 `expo run:android`/`expo run:ios` 或 Dev Client 實機測試,不要等到功能全部做完才測(累積的整合風險會變大)。 +**第四輪:epub metadata(書名/作者/封面)擷取**(2026-07-04): +- 對應「後續重構任務」第 2 項的未完成部分。做法:不引入 `jszip`/`fast-xml-parser` 額外解析 epub 內部結構,而是借用既有的 `reader-web/index.ts`(已含 `epubjs`,在 WebView 這個真的有 DOM 的環境跑)新增一個 `extractMeta` 訊息類型:用 `ePub(buffer)` + `book.ready` 讀 `book.package.metadata`(title/creator)、`book.coverUrl()` 取封面 blob 轉 base64,不呼叫 `renderTo()`,跑完後 `metaBook.destroy()`。做法邏輯照抄自 `pwa/src/utils/epubMetadata.ts` 的既有實作。 +- `mobile/app/(tabs)/index.tsx`(書櫃頁)新增一個**隱藏的 `WebView`**(`opacity:0`、移到畫面外、`pointerEvents="none"`),載入同一份 `READER_HTML`,專門用來收發 `extractMeta`/`metaExtracted` 訊息,不會顯示在畫面上。`handleAddBook` 流程:`addBook()` 建立好暫時記錄(檔名當書名)→ 立即 `refresh()` 讓使用者先看到書已加入 → 再 `postMessage` 送出 base64 給隱藏 WebView 擷取 metadata → 收到 `metaExtracted` 後用 `updateBookMeta()` 補上正確書名/作者,封面圖用新增的 `saveCoverImage()`(`mobile/lib/library.ts`)解碼 base64 寫進沙盒 `covers/{id}.` 檔案(不是存 base64 進 AsyncStorage,避免肥資料),存檔案路徑到 `BookRecord.coverUri`。有 10 秒逾時保險(`META_EXTRACT_TIMEOUT_MS`),逾時或擷取失敗就維持檔名當書名、無封面,不會卡住加入書籍流程。 +- 書櫃列表(`(tabs)/index.tsx`)改成有封面縮圖(40x56,無封面時顯示書名字首當佔位)+書名+作者。 +- `mobile/lib/library.ts` 新增 `updateBookMeta`、`saveCoverImage`、`base64ToBytes`(純 JS base64 解碼,Hermes 沒有全域 `atob` 所以自己寫,不引入額外套件);`removeBook` 同步刪除封面檔案。 +- `mobile/lib/readerMessages.ts` 新增 `extractMeta`(inbound)與 `metaExtracted`/`metaError`(outbound)訊息型別。 +- **驗證狀態**:`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform android` 皆通過,純打包/型別驗證。**尚未實機/模擬器測試**:加入書籍後書名/作者是否正確擷取、封面圖是否正確顯示、逾時保護是否真的不會卡住 UI,都需要你在模擬器上重新測「+ 加入書籍」流程確認。另外沒有處理沒有封面圖的 epub(純文字小說常見)以外的例外格式,理論上會落回檔名當書名,但沒有實際 epub 樣本測過涵蓋率。 + +**第五輪:書櫃改格狀版面 + 修一個可能吃掉書名/作者更新的 bug**(2026-07-04,使用者實測回報「封面沒顯示、看不到作者」後): +- 使用者提供的模擬器截圖顯示:透過新加入書籍流程加入的書,書名有出現但**作者、封面完全沒有**;同時要求書櫃版面比照網頁版(`renderer/src/page/Library.tsx` + `BookCard.tsx`)改成格狀卡片+封面圖+書名+作者+進度條,而非目前的單欄清單。 +- **檢查程式碼發現一個真正的邏輯漏洞**(尚未實機驗證是否為唯一根因,但邏輯上確實會導致「有封面的書連書名/作者都不更新」):`mobile/app/(tabs)/index.tsx` 的 `handleExtractorMessage` 原本依序執行「組 patch → 若有封面就呼叫 `saveCoverImage()` 寫檔 → 呼叫 `updateBookMeta()` 存檔 → `pending.resolve()`」。如果 `saveCoverImage()`(同步的檔案寫入)丟出例外,整個函式會直接中斷,導致**書名/作者的 patch 也一起沒被存到**(因為 `updateBookMeta` 呼叫在它後面),而且 `pending.resolve()` 也不會執行,變成要等滿 10 秒逾時才會恢復——症狀會是「有封面圖的書,書名/作者都沒更新到;沒封面圖的書,書名/作者才會正常更新」,跟回報的現象(書名有出現、作者跟封面都沒有)大致吻合,但因為沒辦法在這個環境重現 `saveCoverImage()` 實際丟出的錯誤訊息,**不能 100% 確定這就是唯一根因**。 + - 修正:把 `saveCoverImage()` 包進獨立的 `try/catch`,封面寫檔失敗只跳過封面欄位、不影響書名/作者的 patch 照常套用;並加上 `if (__DEV__)` 的 `console.log('[library] metaExtracted', ...)` / `console.warn('[library] saveCoverImage failed', ...)` 診斷 log,方便下次測試時直接看 Metro terminal 輸出,不用再用猜的。 +- **格狀版面重構**:新增 `mobile/lib/coverStyles.ts`(仿照 `renderer/src/components/Library/coverStyles.ts` 用 id 雜湊出固定色卡的邏輯,但把網頁版的 `oklch()` 色彩字串換成 RN 認得的 hex 值——**RN 的樣式引擎不支援 `oklch()`/`hsl()` 等 CSS 色彩函式,只能吃 hex/rgb/具名色**,這是這次順帶學到的限制)與 `mobile/components/BookCard.tsx`(2:3 封面比例、無封面時顯示色卡+書名+作者當佔位、封面下方書名兩行截斷+作者+細進度條)。`(tabs)/index.tsx` 的 `FlatList` 改成 `numColumns={2}` 搭配 `columnWrapperStyle`/`contentContainerStyle` 的 `gap`。 +- **驗證狀態**:`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform android` 皆通過。**這輪修正完全沒有實機/模擬器驗證**:格狀版面的視覺排版、`saveCoverImage` 的 try/catch 是否真的解決了封面/作者遺失的問題,都需要你重新測「+ 加入書籍」並貼一下 Metro terminal 印出的 `[library] metaExtracted` 那行 log 內容(`hasCover` 是 true 還是 false、`author` 是不是空字串),才能確認是不是真的是這個 bug,還是另有其他原因(例如 `book.package.metadata` 對某些 epub 本來就沒有 creator 欄位、或 `coverUrl()` 在 WebView 環境真的抓不到)。 + +**第六輪:iOS 書櫃卡片過大,改用固定像素尺寸取代 flex + aspectRatio**(2026-07-04): +- 加了 `key="library-grid-2col"` 後,使用者回報「重新 build 後 iOS 模擬器書本卡片還是超大(幾乎全螢幕),Android 正常」。原本 `BookCard` 用 `flex: 1` + `aspectRatio: 2/3` 讓卡片寬度交給 `FlatList` 的 `numColumns`/`columnWrapperStyle` 去分配欄寬——這個做法**依賴 `numColumns` 真的有生效**,一旦 iOS 那個 JS instance 因為 Fast Refresh 沒有完整重新掛載(`numColumns` 停留在預設值 1),`flex:1` 就會讓卡片撐滿整列(=撐滿整個螢幕寬度),乘上 `aspectRatio 2/3` 後高度也跟著爆炸,正是回報看到的症狀。 +- **改法**:不再依賴 FlatList 分配欄寬,改成呼叫端(`(tabs)/index.tsx`)用 `useWindowDimensions()` 自己算出固定的卡片寬度 `cardWidth = (windowWidth - H_PADDING*2 - GRID_GAP*(COLUMNS-1)) / COLUMNS`,往下傳給 `BookCard` 當 `width` prop;`BookCard`(`mobile/components/BookCard.tsx`)內部封面容器改用明確的 `width`/`height`(`height = width * 1.5` 算出 2:3 比例)取代 `flex:1`/`aspectRatio`。這樣即使未來又遇到 `numColumns` 因為某種重新整理時機沒套用成功,卡片也只會維持固定尺寸、最多是排列變成一整欄堆疊(仍然是正常大小),不會再出現撐滿全螢幕的爆版問題。 +- **驗證狀態**:`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform ios` 皆通過。**尚未實機/模擬器驗證**這次改法是否真的解決 iOS 卡片過大的問題,請重新完整關閉重開 App(不要只等 Fast Refresh)後再測一次。 +- 封面圖持續無法顯示的問題(見上一輪)**仍未確認根因**,還在等使用者提供 Metro 的 `[library] metaExtracted` log 內容(`hasCover`/`author` 的值),懷疑是來源 epub(檔名含「Z-Library」字樣)本身 metadata 不完整、非標準的封面標記造成 `book.coverUrl()` 正常回傳 `null`,但未證實。 + +**第七輪:封面擷取確認修好 + 加上右上角刪除按鈕**(2026-07-04): +- 使用者實測確認:**新加入的書籍封面成功顯示**。回頭看,真正根因應該是第六輪提到的「隱藏 WebView 用 1x1 近乎歸零尺寸,iOS WKWebView 對這種視圖可能整個不執行內容 JS」,改成 100x100(仍用 `opacity:0` 隱藏)後解決;「來源 epub metadata 不完整」的猜測是錯的,不用再往那個方向查。舊資料(改動前就已加入的書)沒有封面是預期行為,MVP 目前只在加入當下觸發一次擷取,沒有針對既有書籍補跑的機制(如果之後要補,可以在書櫃畫面對缺封面的書自動或手動觸發一次 `extractMetaFor`)。 +- 新增書櫃卡片右上角的圓形 ✕ 刪除按鈕(比照 `renderer/src/components/Library/BookCard.tsx` 的設計),取代原本「只能長按刪除」在手機上不夠明顯的問題;長按仍保留當備援手勢。`mobile/components/BookCard.tsx` 的 `onLongPress` prop 改名為 `onDelete`,外層容器從 `Pressable` 改成 `View`(內部兩個獨立的 `Pressable`:一個包住封面+觸發開啟閱讀器,另一個是絕對定位在右上角、疊在封面上層的刪除按鈕,靠 JSX 中宣告順序讓刪除按鈕在觸控命中判定上位於上層)。 +- **驗證狀態**:`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform android` 皆通過。**刪除按鈕本身尚未實機測試**:需要確認點擊右上角 ✕ 是否正確觸發刪除確認框、且不會誤觸到底下的「開啟閱讀器」(兩個 Pressable 疊在一起的觸控命中判定,理論上 RN 會讓後宣告、疊在上層的那個接住觸控,但沒有實機驗證過)。 + +**第八輪:書櫃排序控制項**(2026-07-04):新增 `mobile/components/SortControl.tsx`,比照 `renderer/src/components/Library/SortControl.tsx` 的分段控制項設計(灰底圓角容器+選中項目白底+文字色深淺區分),提供「最近閱讀/書名/進度」三種排序,狀態放在 `(tabs)/index.tsx` 的 `sort` state,用 `useMemo` 排序後的 `shown` 陣列餵給 `FlatList`(`recent` 用 `lastOpenedAt` 遞減、`title` 用 `localeCompare('zh-Hant')`、`progress` 用 `progress` 遞減,邏輯照抄網頁版 `Library.tsx` 的 `shown` 計算)。`tsc`、`expo-doctor`、`expo export` 皆通過,**尚未實機測試**三種排序切換後的實際排序結果與畫面更新是否正確。 + +**第九輪:修正進度百分比誤判「讀畢」**(2026-07-04,使用者回報 Android 書還沒看完就顯示「讀畢」後): +- **根因**:`reader-web/index.ts` 原本用 epub.js `relocated` 事件的 `l.start.displayed.page/total` 算進度,但這兩個數字**只是目前這一章(spine item)內部的頁碼**,不是全書頁碼。書翻到任何一章的結尾時 `page` 就會等於 `total`,於是每一章結尾都會被 RN 端誤判成「這本書讀完了」存成 100%,畫面就顯示「讀畢」——這是文件裡本來就記錄過的已知限制(第一輪「未做」清單提過「不是精確全書頁碼」),這次終於補上。 +- **修法**:改用 epub.js 內建的 `book.locations`:在 `rendition.display()` 之後(不 `await`,避免拖慢開書速度)背景呼叫 `book.locations.generate(1024)`,跑完後 `relocated` 事件的 `l.start.percentage` 才會是精確的全書百分比(原理:`book.locations.percentageFromLocation` 在 locations 沒產生前永遠回傳 `undefined`/不存在,這也是先前完全沒被用到的原因)。在 `book.locations.generate()` 完成前的過渡期,改用「章節索引 + 章內頁碼比例」概算一個粗略值(`(spineIndex + (page-1)/total) / book.spine.length`)頂著,避免完全沒有進度顯示;locations 產生完成後之後的 `relocated` 事件就會自動換成精確值,不需要額外通知。 +- `mobile/lib/readerMessages.ts` 的 `relocated` 訊息新增 `percentage: number` 欄位;`mobile/app/reader/[id].tsx` 改用 `updateProgress(id, msg.percentage)` 取代原本的 `msg.page / msg.total`。 +- `book.spine` 的 TypeScript 型別定義(`node_modules/epubjs/types/spine.d.ts`)沒有列出 `length` 欄位(但執行期 `unpack()` 時確實有設定),只能用 `(book.spine as any).length` 繞過型別檢查。 +- **驗證狀態**:`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform android` 皆通過。**尚未實機測試**:`book.locations.generate()` 對長篇小說可能要跑幾百毫秒到幾秒(視章節數與字數而定,這次沒有機會實測真實耗時),需要確認 (1) 這段背景運算會不會讓翻頁或介面卡頓、(2) 產生完成前後的百分比切換會不會讓進度條看起來跳動、(3) 大部頭 epub 是否會讓 `generate()` 耗時多到影響體驗(若真的太慢,可能要考慮改成第一次背景預先算好存起來,或用更粗的取樣粒度)。 + **Why 記錄這段**:mobile/ 的建置細節分散在多次對話中,若不集中記錄,下次對話容易重複「已經做過的初始化」或忘記 epub.js 在 RN 上不能直接用這個關鍵限制。 diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 5d5e935..05c93b2 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,12 +1,42 @@ import { router, useFocusEffect } from 'expo-router'; -import { useCallback, useState } from 'react'; -import { Alert, FlatList, Pressable, Text, View } from 'react-native'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Alert, FlatList, Pressable, Text, View, useWindowDimensions } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { addBook, type BookRecord, listBooks, removeBook } from '../../lib/library'; +import { WebView } from 'react-native-webview'; +import BookCard from '../../components/BookCard'; +import SortControl, { type SortKey } from '../../components/SortControl'; +import { + addBook, + type BookRecord, + getBookBase64, + listBooks, + removeBook, + saveCoverImage, + updateBookMeta, +} from '../../lib/library'; +import { READER_HTML } from '../../lib/readerHtml.generated'; +import type { OutboundMessage } from '../../lib/readerMessages'; +import { INK3_COLOR, INK_COLOR, PAPER_BG } from '../../lib/theme'; + +const GRID_GAP = 16; +const H_PADDING = 16; +const COLUMNS = 2; + +const META_EXTRACT_TIMEOUT_MS = 10_000; const LibraryScreen = () => { + const { width: windowWidth } = useWindowDimensions(); + const cardWidth = (windowWidth - H_PADDING * 2 - GRID_GAP * (COLUMNS - 1)) / COLUMNS; const [books, setBooks] = useState([]); const [loading, setLoading] = useState(true); + const [sort, setSort] = useState('recent'); + const extractorRef = useRef(null); + const extractorReadyRef = useRef(false); + const addingRef = useRef(false); + const pendingExtractionRef = useRef<{ + id: string; + resolve: () => void; + } | null>(null); const refresh = useCallback(async () => { setBooks(await listBooks()); @@ -19,12 +49,112 @@ const LibraryScreen = () => { }, [refresh]) ); + // 書名/作者/封面擷取借用閱讀器同一份 epub.js WebView bundle(見 reader-web/index.ts 的 + // extractMeta 訊息),但不顯示在畫面上,只用來跑一次性的 metadata 解析。 + const extractMetaFor = useCallback((record: BookRecord) => { + return new Promise((resolve) => { + let settled = false; + let waitTimer: ReturnType | null = null; + const finish = () => { + if (settled) return; + settled = true; + if (waitTimer) clearInterval(waitTimer); + pendingExtractionRef.current = null; + resolve(); + }; + const timer = setTimeout(finish, META_EXTRACT_TIMEOUT_MS); + pendingExtractionRef.current = { + id: record.id, + resolve: () => { + clearTimeout(timer); + finish(); + }, + }; + + const send = async () => { + const base64 = await getBookBase64(record); + if (__DEV__) console.log('[library] sending extractMeta', { id: record.id, base64Length: base64.length }); + extractorRef.current?.postMessage(JSON.stringify({ type: 'extractMeta', base64 })); + }; + if (extractorReadyRef.current) { + send(); + } else { + // WebView 尚未載入完成時,等 onLoadEnd 之後的 'ready' 訊息再送出。 + waitTimer = setInterval(() => { + if (extractorReadyRef.current) { + if (waitTimer) clearInterval(waitTimer); + send(); + } + }, 100); + } + }); + }, []); + + const handleExtractorMessage = useCallback( + (event: { nativeEvent: { data: string } }) => { + let msg: OutboundMessage; + try { + msg = JSON.parse(event.nativeEvent.data); + } catch { + return; + } + if (msg.type === 'ready') { + if (__DEV__) console.log('[library] extractor webview ready'); + extractorReadyRef.current = true; + return; + } + const pending = pendingExtractionRef.current; + if (!pending) return; + + if (msg.type === 'metaExtracted') { + if (__DEV__) { + console.log('[library] metaExtracted', { + id: pending.id, + title: msg.title, + author: msg.author, + hasCover: Boolean(msg.coverBase64), + }); + } + const patch: Partial> = {}; + if (msg.title) patch.title = msg.title; + if (msg.author) patch.author = msg.author; + // 封面寫檔失敗(例如解碼異常)不應該連帶丟掉已經擷取到的書名/作者,兩者分開處理。 + if (msg.coverBase64) { + try { + patch.coverUri = saveCoverImage(pending.id, msg.coverBase64, msg.coverMediaType); + } catch (err) { + console.warn('[library] saveCoverImage failed', err); + } + } + if (Object.keys(patch).length > 0) { + updateBookMeta(pending.id, patch) + .then(refresh) + .catch((err) => console.warn('[library] updateBookMeta failed', err)); + } + pending.resolve(); + } else if (msg.type === 'metaError') { + if (__DEV__) console.warn('[library] metaError', msg.message); + pending.resolve(); + } + }, + [refresh] + ); + const handleAddBook = async () => { + // 擷取 metadata 的 WebView 一次只能處理一本書(pendingExtractionRef 是單一插槽), + // 擋掉在前一次還沒跑完前重疊觸發,避免兩本書的擷取結果互相覆蓋、寫錯 metadata。 + if (addingRef.current) return; + addingRef.current = true; try { const record = await addBook(); - if (record) refresh(); + if (!record) return; + refresh(); + await extractMetaFor(record); + refresh(); } catch { Alert.alert('加入書籍失敗', '請稍後再試一次'); + } finally { + addingRef.current = false; } }; @@ -46,38 +176,68 @@ const LibraryScreen = () => { ]); }; + const shown = useMemo(() => { + const list = [...books]; + if (sort === 'recent') list.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + if (sort === 'title') list.sort((a, b) => a.title.localeCompare(b.title, 'zh-Hant')); + if (sort === 'progress') list.sort((a, b) => (b.progress ?? 0) - (a.progress ?? 0)); + return list; + }, [books, sort]); + return ( - + - 書櫃 + + 書櫃 {books.length} 本 + + 加入書籍 + {books.length > 0 && ( + + + + )} + {!loading && books.length === 0 ? ( - 尚未加入任何書籍 + 尚未加入任何書籍 ) : ( item.id} - contentContainerStyle={{ padding: 16 }} + numColumns={COLUMNS} + contentContainerStyle={{ padding: H_PADDING, gap: GRID_GAP }} + columnWrapperStyle={{ gap: GRID_GAP }} renderItem={({ item }) => ( - router.push(`/reader/${item.id}`)} - onLongPress={() => handleDeleteBook(item)} - style={{ paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#e5e5e5' }} - > - {item.title} - {item.progress ? ( - {Math.round(item.progress * 100)}% - ) : null} - + onDelete={() => handleDeleteBook(item)} + /> )} /> )} + + __DEV__ && console.warn('[library] extractor webview onError', e.nativeEvent)} + onHttpError={(e) => __DEV__ && console.warn('[library] extractor webview onHttpError', e.nativeEvent)} + javaScriptEnabled + // 用 1x1 近乎歸零的尺寸隱藏這個 WebView 時,WKWebView(iOS)有可能不會確實跑內容的 JS + // (近似 headless/離屏極小視圖被系統節流),改用較合理的尺寸(100x100)搬到畫面外, + // 只用 opacity:0 隱藏,避免這個因素導致整個 extractMeta 流程收不到任何回應。 + style={{ position: 'absolute', width: 100, height: 100, opacity: 0, top: -1000, left: 0 }} + pointerEvents="none" + /> ); }; diff --git a/mobile/app/reader/[id].tsx b/mobile/app/reader/[id].tsx index 34ab879..0f45e0f 100644 --- a/mobile/app/reader/[id].tsx +++ b/mobile/app/reader/[id].tsx @@ -65,7 +65,7 @@ const ReaderScreen = () => { setLoading(false); if (!id) return; saveReadingCfi(id, msg.cfi); - if (msg.total > 0) updateProgress(id, msg.page / msg.total); + updateProgress(id, msg.percentage); return; } if (msg.type === 'error') { diff --git a/mobile/components/BookCard.tsx b/mobile/components/BookCard.tsx new file mode 100644 index 0000000..7f3316b --- /dev/null +++ b/mobile/components/BookCard.tsx @@ -0,0 +1,102 @@ +import { Image, Pressable, Text, View } from 'react-native'; +import type { BookRecord } from '../lib/library'; +import { coverStyleFor, PROGRESS_FILL_COLOR, PROGRESS_TRACK_COLOR } from '../lib/coverStyles'; + +interface Props { + record: BookRecord; + width: number; + onPress: () => void; + onDelete: () => void; +} + +// 封面用固定像素寬高(而非 flex:1 + aspectRatio),因為卡片寬度依賴 FlatList numColumns +// 產生的欄寬去算,若 numColumns 沒套用成功(例如 Fast Refresh 沒有完整重新掛載), +// flex:1 會讓卡片撐滿整列寬度變得超大;改成呼叫端算好固定寬度傳進來,就不會受這個影響。 +const BookCard = ({ record, width, onPress, onDelete }: Props) => { + const pct = Math.round((record.progress ?? 0) * 100); + const style = coverStyleFor(record.id); + const coverHeight = Math.round(width * 1.5); + + return ( + + + + {record.coverUri ? ( + + ) : ( + + + + + {record.title} + + + {record.author ? ( + + {record.author} + + ) : null} + + )} + + + + {/* 刪除按鈕:比照 renderer/src/components/Library/BookCard.tsx 的右上角圓形 ✕ 按鈕, + 取代原本只能長按刪除(手機上不夠明顯)的做法;長按仍保留當備援手勢。 */} + + + + + + + {record.title} + + {record.author ? ( + + {record.author} + + ) : null} + + + + + {pct === 100 ? '讀畢' : `${pct}%`} + + + + ); +}; + +export default BookCard; diff --git a/mobile/components/SortControl.tsx b/mobile/components/SortControl.tsx new file mode 100644 index 0000000..4a2080e --- /dev/null +++ b/mobile/components/SortControl.tsx @@ -0,0 +1,55 @@ +import { Pressable, Text, View } from 'react-native'; +import { BORDER_COLOR, INK_COLOR, INK3_COLOR, PAPER_BG, PAPER_BG2 } from '../lib/theme'; + +export type SortKey = 'recent' | 'title' | 'progress'; + +const LABELS: Record = { + recent: '最近閱讀', + title: '書名', + progress: '進度', +}; + +interface Props { + sort: SortKey; + onSortChange: (sort: SortKey) => void; +} + +// 比照 renderer/src/components/Library/SortControl.tsx 的分段控制項設計語言。 +const SortControl = ({ sort, onSortChange }: Props) => ( + + {(Object.keys(LABELS) as SortKey[]).map((key) => { + const active = sort === key; + return ( + onSortChange(key)} + style={{ + height: 26, + paddingHorizontal: 10, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: active ? PAPER_BG : 'transparent', + }} + > + + {LABELS[key]} + + + ); + })} + +); + +export default SortControl; diff --git a/mobile/lib/coverStyles.ts b/mobile/lib/coverStyles.ts new file mode 100644 index 0000000..66a232e --- /dev/null +++ b/mobile/lib/coverStyles.ts @@ -0,0 +1,19 @@ +// 無封面時的佔位色卡(比照 renderer/src/components/Library/coverStyles.ts 的配色語言, +// 但用 RN 支援的 hex 值取代網頁版的 oklch() 字串,因為 RN 樣式引擎不認得 CSS 色彩函式)。 +const COVER_STYLES = [ + { bg: '#ECE0C8', ink: '#5B4A34', rule: '#B9906A' }, + { bg: '#DCCBAE', ink: '#4A3B28', rule: '#9C7C52' }, + { bg: '#2B3A52', ink: '#E4E8EF', rule: '#7C93BD' }, + { bg: '#3E5C4A', ink: '#EAF3EC', rule: '#8FBFA0' }, + { bg: '#DCB6A0', ink: '#4A2E22', rule: '#B5654A' }, + { bg: '#455066', ink: '#EEF1F6', rule: '#A9B7CE' }, +]; + +export const coverStyleFor = (id: string) => { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + return COVER_STYLES[h % COVER_STYLES.length]; +}; + +export const PROGRESS_TRACK_COLOR = '#E4DDD0'; +export const PROGRESS_FILL_COLOR = '#C17A4A'; diff --git a/mobile/lib/library.ts b/mobile/lib/library.ts index c632a4c..7a97808 100644 --- a/mobile/lib/library.ts +++ b/mobile/lib/library.ts @@ -9,6 +9,7 @@ export interface BookRecord { addedAt: number; lastOpenedAt: number; progress?: number; + coverUri?: string; } export interface Bookmark { @@ -33,9 +34,34 @@ const settingsKey = (id: string) => `tit:settings:${id}`; const bookmarksKey = (id: string) => `tit:bookmarks:${id}`; const booksDir = () => new Directory(Paths.document, 'books'); +const coversDir = () => new Directory(Paths.document, 'covers'); const generateId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; +const coverExtension = (mediaType: string | null): string => { + if (mediaType?.includes('png')) return 'png'; + if (mediaType?.includes('gif')) return 'gif'; + if (mediaType?.includes('webp')) return 'webp'; + return 'jpg'; +}; + +const base64ToBytes = (base64: string): Uint8Array => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const clean = base64.replace(/[^A-Za-z0-9+/]/g, ''); + const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4)); + let byteIndex = 0; + for (let i = 0; i < clean.length; i += 4) { + const e1 = chars.indexOf(clean[i]); + const e2 = chars.indexOf(clean[i + 1]); + const e3 = chars.indexOf(clean[i + 2]); + const e4 = chars.indexOf(clean[i + 3]); + bytes[byteIndex++] = (e1 << 2) | (e2 >> 4); + if (e3 !== -1) bytes[byteIndex++] = ((e2 & 15) << 4) | (e3 >> 2); + if (e4 !== -1) bytes[byteIndex++] = ((e3 & 3) << 6) | e4; + } + return bytes.slice(0, byteIndex); +}; + const loadMeta = async (): Promise => { try { const raw = await AsyncStorage.getItem(META_KEY); @@ -101,11 +127,35 @@ export const removeBook = (id: string) => if (record) { const file = new File(booksDir(), record.filename); if (file.exists) file.delete(); + if (record.coverUri) { + const cover = new File(record.coverUri); + if (cover.exists) cover.delete(); + } } AsyncStorage.multiRemove([progressKey(id), settingsKey(id), bookmarksKey(id)]); return [records.filter((r) => r.id !== id), undefined]; }); +export const updateBookMeta = ( + id: string, + patch: Partial> +) => + updateMeta((records) => [ + records.map((r) => (r.id === id ? { ...r, ...patch } : r)), + undefined, + ]); + +// 封面圖檔存進沙盒目錄,AsyncStorage 只存檔案路徑(圖片轉 base64 存 AsyncStorage 太肥)。 +export const saveCoverImage = (id: string, base64: string, mediaType: string | null): string => { + const dir = coversDir(); + if (!dir.exists) dir.create({ intermediates: true }); + const file = new File(dir, `${id}.${coverExtension(mediaType)}`); + if (file.exists) file.delete(); + file.create(); + file.write(base64ToBytes(base64)); + return file.uri; +}; + export const touchBook = (id: string) => updateMeta((records) => [records.map((r) => (r.id === id ? { ...r, lastOpenedAt: Date.now() } : r)), undefined]); diff --git a/mobile/lib/readerHtml.generated.ts b/mobile/lib/readerHtml.generated.ts index 0780cd3..5c677ae 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 c98967a..c02d7d6 100644 --- a/mobile/lib/readerMessages.ts +++ b/mobile/lib/readerMessages.ts @@ -1,10 +1,13 @@ export type InboundMessage = | { type: 'load'; base64: string; cfi: string | null } | { type: 'prev' } - | { type: 'next' }; + | { type: 'next' } + | { type: 'extractMeta'; base64: string }; export type OutboundMessage = | { type: 'ready' } - | { type: 'relocated'; cfi: string; page: number; total: number; atStart: boolean; atEnd: boolean } + | { type: 'relocated'; cfi: string; page: number; total: number; percentage: number; atStart: boolean; atEnd: boolean } | { type: 'error'; message: string } - | { type: 'debug'; message: string }; + | { type: 'debug'; message: string } + | { type: 'metaExtracted'; title: string; author: string; coverBase64: string | null; coverMediaType: string | null } + | { type: 'metaError'; message: string }; diff --git a/mobile/lib/theme.ts b/mobile/lib/theme.ts new file mode 100644 index 0000000..e920291 --- /dev/null +++ b/mobile/lib/theme.ts @@ -0,0 +1,5 @@ +export const PAPER_BG = '#f9f7f2'; +export const PAPER_BG2 = '#f1ede4'; +export const BORDER_COLOR = '#e4ddd0'; +export const INK_COLOR = '#2a2420'; +export const INK3_COLOR = '#9a8f80'; diff --git a/mobile/reader-web/index.ts b/mobile/reader-web/index.ts index e6e2cdc..a1f984a 100644 --- a/mobile/reader-web/index.ts +++ b/mobile/reader-web/index.ts @@ -79,6 +79,48 @@ const registerTapZone = (id: string, direction: 'prev' | 'next') => { }); }; +const blobToBase64 = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve((reader.result as string).split(',')[1] ?? ''); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + +// 書名/作者/封面擷取:跟閱讀渲染共用同一份 epub.js bundle,但不呼叫 renderTo(), +// 純粹讀 book.package.metadata 與 book.coverUrl(),不需要 #viewer 實際顯示內容, +// 因此可以直接借用 reader 頁面既有的 WebView(RN 端隱藏顯示)跑,不用另外打包一份 HTML。 +const extractMeta = async (base64: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const metaBook = ePub(base64ToArrayBuffer(base64)) as any; + try { + await metaBook.ready; + const pkg = metaBook.package?.metadata; + const title = (pkg?.title as string | undefined)?.trim() || ''; + const author = (pkg?.creator as string | undefined)?.trim() || ''; + + let coverBase64: string | null = null; + let coverMediaType: string | null = null; + try { + const coverUrl: string | null = await metaBook.coverUrl(); + if (coverUrl) { + const blob: Blob = await fetch(coverUrl).then((r) => r.blob()); + coverMediaType = blob.type || null; + coverBase64 = await blobToBase64(blob); + URL.revokeObjectURL(coverUrl); + } + } catch { + /* 無封面,忽略 */ + } + + post({ type: 'metaExtracted', title, author, coverBase64, coverMediaType }); + } catch (err) { + post({ type: 'metaError', message: err instanceof Error ? err.message : String(err) }); + } finally { + metaBook.destroy(); + } +}; + const loadBook = async (base64: string, cfi: string | null) => { const viewer = document.getElementById('viewer'); if (!viewer) return; @@ -103,12 +145,26 @@ const loadBook = async (base64: string, cfi: string | null) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const l = loc as any; const displayed = l?.start?.displayed as { page: number; total: number } | undefined; - if (!l?.start?.cfi || !displayed) return; + if (!l?.start?.cfi || !displayed || !book) return; + + // l.start.displayed 的 page/total 只是「目前這個章節(spine item)」內部的頁碼, + // 不是全書頁碼——書快翻到某一章結尾時 page 就會等於 total,導致每章結尾都會被 + // RN 端誤判成「整本書讀完」而顯示 100%/讀畢。epub.js 要拿到準確的全書百分比, + // 需要先跑過 book.locations.generate()(見下方 loadBook 內呼叫),跑完之前 + // l.start.percentage 會是 undefined,這裡用「章節索引 + 章內頁碼比例」概算一個 + // 過渡值,locations 產生完成後之後的 relocated 事件就會換成精確值。 + // epubjs 的 Spine 型別定義沒有列出 length(實際上 unpack() 時有設定這個欄位),只能用 any 存取。 + const spineLength = (book.spine as any).length as number; + const total = typeof l.start.percentage === 'number' + ? l.start.percentage + : (l.start.index + (displayed.page - 1) / Math.max(displayed.total, 1)) / Math.max(spineLength, 1); + post({ type: 'relocated', cfi: l.start.cfi, page: displayed.page, total: displayed.total, + percentage: Math.max(0, Math.min(1, total)), atStart: Boolean(l.atStart), atEnd: Boolean(l.atEnd), }); @@ -116,6 +172,12 @@ const loadBook = async (base64: string, cfi: string | null) => { await book.ready; await rendition.display(cfi ?? undefined); + // 全書頁面定位索引,跑完後 relocated 事件的 l.start.percentage 才會是精確的全書百分比 + // (不是章節內比例)。放在 display() 之後、不 await,避免拖慢開書速度;期間的 + // relocated 事件會先用上面的章節索引概算值頂著。 + book.locations.generate(1024).catch(() => { + /* 定位索引產生失敗不影響閱讀本身,忽略即可,頂多進度概算比較粗略 */ + }); } catch (err) { unlockNav(); post({ type: 'error', message: err instanceof Error ? err.message : String(err) }); @@ -132,6 +194,7 @@ const handleMessage = (event: MessageEvent) => { if (msg.type === 'load') loadBook(msg.base64, msg.cfi); if (msg.type === 'prev') turnPage('prev'); if (msg.type === 'next') turnPage('next'); + if (msg.type === 'extractMeta') extractMeta(msg.base64); }; // RN WebView 在 Android 觸發 document 的 message 事件,iOS 觸發 window 的,兩者都要監聽