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
39 changes: 37 additions & 2 deletions RN_SETUP_GUIDE.md

Large diffs are not rendered by default.

196 changes: 178 additions & 18 deletions mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -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<BookRecord[]>([]);
const [loading, setLoading] = useState(true);
const [sort, setSort] = useState<SortKey>('recent');
const extractorRef = useRef<WebView>(null);
const extractorReadyRef = useRef(false);
const addingRef = useRef(false);
const pendingExtractionRef = useRef<{
id: string;
resolve: () => void;
} | null>(null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const refresh = useCallback(async () => {
setBooks(await listBooks());
Expand All @@ -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<void>((resolve) => {
let settled = false;
let waitTimer: ReturnType<typeof setInterval> | 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<Pick<BookRecord, 'title' | 'author' | 'coverUri'>> = {};
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;
}
};

Expand All @@ -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 (
<SafeAreaView edges={['top']} style={{ flex: 1 }}>
<SafeAreaView edges={['top']} style={{ flex: 1, backgroundColor: PAPER_BG }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', height: 44, paddingHorizontal: 16 }}>
<Text style={{ fontSize: 20, fontWeight: '600' }}>書櫃</Text>
<Text style={{ fontSize: 20, fontWeight: '600', color: INK_COLOR }}>
書櫃 <Text style={{ fontSize: 13, fontWeight: '400', color: INK3_COLOR }}>{books.length} 本</Text>
</Text>
<Pressable onPress={handleAddBook} hitSlop={12}>
<Text style={{ fontSize: 16, color: '#2563eb' }}>+ 加入書籍</Text>
</Pressable>
</View>

{books.length > 0 && (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<SortControl sort={sort} onSortChange={setSort} />
</View>
)}

{!loading && books.length === 0 ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>尚未加入任何書籍</Text>
<Text style={{ color: INK3_COLOR }}>尚未加入任何書籍</Text>
</View>
) : (
<FlatList
data={books}
key={`library-grid-${COLUMNS}col`}
data={shown}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16 }}
numColumns={COLUMNS}
contentContainerStyle={{ padding: H_PADDING, gap: GRID_GAP }}
columnWrapperStyle={{ gap: GRID_GAP }}
renderItem={({ item }) => (
<Pressable
<BookCard
record={item}
width={cardWidth}
onPress={() => router.push(`/reader/${item.id}`)}
onLongPress={() => handleDeleteBook(item)}
style={{ paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#e5e5e5' }}
>
<Text style={{ fontSize: 16 }}>{item.title}</Text>
{item.progress ? (
<Text style={{ fontSize: 12, color: '#888', marginTop: 4 }}>{Math.round(item.progress * 100)}%</Text>
) : null}
</Pressable>
onDelete={() => handleDeleteBook(item)}
/>
)}
/>
)}

<WebView
ref={extractorRef}
originWhitelist={['*']}
source={{ html: READER_HTML }}
onMessage={handleExtractorMessage}
onError={(e) => __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"
/>
</SafeAreaView>
);
};
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/reader/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
102 changes: 102 additions & 0 deletions mobile/components/BookCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={{ width }}>
<Pressable
onPress={onPress}
onLongPress={onDelete}
accessibilityRole="button"
accessibilityLabel={record.author ? `開啟《${record.title}》,作者 ${record.author}` : `開啟《${record.title}》`}
>
<View
style={{
width,
height: coverHeight,
borderRadius: 6,
overflow: 'hidden',
backgroundColor: style.bg,
}}
>
{record.coverUri ? (
<Image source={{ uri: record.coverUri }} style={{ width: '100%', height: '100%' }} resizeMode="cover" />
) : (
<View style={{ flex: 1, justifyContent: 'space-between', padding: 14 }}>
<View>
<View style={{ width: 20, height: 2, backgroundColor: style.rule, marginBottom: 8 }} />
<Text numberOfLines={4} style={{ color: style.ink, fontSize: 13, fontWeight: '600', lineHeight: 17 }}>
{record.title}
</Text>
</View>
{record.author ? (
<Text
numberOfLines={1}
style={{ color: style.ink, fontSize: 9, letterSpacing: 1, textTransform: 'uppercase', opacity: 0.75 }}
>
{record.author}
</Text>
) : null}
</View>
)}
</View>
</Pressable>

{/* 刪除按鈕:比照 renderer/src/components/Library/BookCard.tsx 的右上角圓形 ✕ 按鈕,
取代原本只能長按刪除(手機上不夠明顯)的做法;長按仍保留當備援手勢。 */}
<Pressable
onPress={onDelete}
hitSlop={8}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: 'rgba(0,0,0,0.55)',
alignItems: 'center',
justifyContent: 'center',
}}
accessibilityRole="button"
accessibilityLabel="移除書籍"
>
<Text style={{ color: '#fff', fontSize: 12, lineHeight: 14 }}>✕</Text>
</Pressable>

<View style={{ marginTop: 8 }}>
<Text numberOfLines={2} style={{ fontSize: 13, fontWeight: '500', color: '#2a2420', lineHeight: 17 }}>
{record.title}
</Text>
{record.author ? (
<Text numberOfLines={1} style={{ fontSize: 11, color: '#9a8f80', marginTop: 3 }}>
{record.author}
</Text>
) : null}
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 6, gap: 6 }}>
<View style={{ flex: 1, height: 4, borderRadius: 2, backgroundColor: PROGRESS_TRACK_COLOR, overflow: 'hidden' }}>
<View style={{ width: `${pct}%`, height: '100%', backgroundColor: PROGRESS_FILL_COLOR }} />
</View>
<Text style={{ fontSize: 10, color: '#9a8f80' }}>{pct === 100 ? '讀畢' : `${pct}%`}</Text>
</View>
</View>
</View>
);
};

export default BookCard;
Loading
Loading