import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { View, Text, ActivityIndicator, StyleSheet, type LayoutChangeEvent, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Reader, ReaderProvider, useReader } from "@epubjs-react-native/core"; import { useFileSystem } from "@epubjs-react-native/expo-file-system"; import * as FileSystem from "expo-file-system"; import type { EBookDetail } from "@cloud-reader/shared"; import { getAccessToken } from "../../api/client"; import { ebooksApi } from "../../api/ebooks"; import { getReadingProgress, updateReadingProgress } from "../../api/reader"; import { fetchBookmarks, createMarker, deleteBookmark, } from "../../api/annotations"; import { useReadingSettings } from "../../hooks/useReadingSettings"; import { buildEpubTheme, FONT_STACKS, resolvePalette, } from "../../utils/epubTheme"; import type { Bookmark } from "../../types"; import { ReaderToolbar } from "./ReaderToolbar"; import { TocModal, type TocItem } from "./TocModal"; import { ReadingSettingsModal } from "./ReadingSettingsModal"; import { BookmarksModal } from "./BookmarksModal"; const HIGHLIGHT_COLOR = "#ffd54a"; const PROGRESS_SAVE_MS = 800; /** Loose view of the epubjs-react-native reader API we rely on. */ interface ReaderApi { goToLocation?: (target: string) => void; changeTheme?: (theme: Record>) => void; changeFontSize?: (size: string) => void; changeFontFamily?: (font: string) => void; addAnnotation?: ( type: string, cfiRange: string, data?: unknown, styles?: Record, ) => void; removeAnnotationByCfi?: (cfi: string) => void; removeSelection?: () => void; toc?: TocItem[]; } interface EpubLocation { start?: { cfi?: string }; } interface EpubSection { label?: string; index?: number; } interface EpubReaderViewProps { book: EBookDetail; ebookId: number; onClose: () => void; } export function EpubReaderView(props: EpubReaderViewProps): ReactNode { return ( ); } function EpubReaderInner({ book, ebookId, onClose, }: EpubReaderViewProps): ReactNode { const reader = useReader() as unknown as ReaderApi; const { settings, updateSettings } = useReadingSettings(); const [src, setSrc] = useState(null); const [initialLocation, setInitialLocation] = useState(); const [fileError, setFileError] = useState(false); const [ready, setReady] = useState(false); const [size, setSize] = useState<{ width: number; height: number } | null>( null, ); const [progressPct, setProgressPct] = useState( Math.round(book.progress?.current_position ?? 0), ); const [chapterTitle, setChapterTitle] = useState(""); const [currentCfi, setCurrentCfi] = useState(""); const [bookmarks, setBookmarks] = useState([]); const [tocOpen, setTocOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [bookmarksOpen, setBookmarksOpen] = useState(false); const currentCfiRef = useRef(""); const progressPctRef = useRef(progressPct); const sectionRef = useRef<{ index: number; label: string }>({ index: 0, label: "", }); const bookmarksRef = useRef([]); const saveTimerRef = useRef | null>(null); useEffect(() => { bookmarksRef.current = bookmarks; }, [bookmarks]); // Download the EPUB with auth, and resolve the saved resume location. useEffect(() => { let active = true; (async () => { try { const progress = await getReadingProgress(ebookId).catch(() => null); if (active && progress?.epub_location) { setInitialLocation(progress.epub_location); currentCfiRef.current = progress.epub_location; setCurrentCfi(progress.epub_location); } const token = await getAccessToken(); const url = ebooksApi.getFileUrl(ebookId); const dest = `${FileSystem.cacheDirectory}ebook-${ebookId}.epub`; const result = await FileSystem.downloadAsync(url, dest, { headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); if (!active) return; if (result.status >= 400) { setFileError(true); return; } setSrc(result.uri); } catch { if (active) setFileError(true); } })(); return () => { active = false; }; }, [ebookId]); // Load existing bookmarks/highlights for this book. useEffect(() => { let active = true; fetchBookmarks(ebookId) .then((res) => { if (active) setBookmarks(res.results); }) .catch(() => { /* bookmarks are optional */ }); return () => { active = false; }; }, [ebookId]); // Apply typography/theme to the rendition once it is ready and on changes. useEffect(() => { if (!ready) return; try { reader.changeTheme?.(buildEpubTheme(settings)); reader.changeFontSize?.(`${settings.font_size}px`); reader.changeFontFamily?.(FONT_STACKS[settings.font_family]); } catch { /* ignore rendition styling errors */ } }, [ready, settings, reader]); const flushProgress = useCallback(() => { const cfi = currentCfiRef.current; if (!cfi) return; updateReadingProgress(ebookId, { percentage: progressPctRef.current, epub_location: cfi, current_chapter: sectionRef.current.index + 1, }).catch(() => { /* progress is best-effort */ }); }, [ebookId]); // Flush the latest progress when leaving the reader. useEffect( () => () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); flushProgress(); }, [flushProgress], ); const handleReady = useCallback(() => { setReady(true); bookmarksRef.current.forEach((bookmark) => { if (bookmark.highlight_color && bookmark.epub_cfi) { try { reader.addAnnotation?.("highlight", bookmark.epub_cfi, undefined, { fill: bookmark.highlight_color, }); } catch { /* highlight rendering is best-effort */ } } }); }, [reader]); const handleLocationChange = useCallback( ( _total: number, location: EpubLocation, progress: number, section: EpubSection | null, ) => { const cfi = location?.start?.cfi ?? ""; if (cfi) { currentCfiRef.current = cfi; setCurrentCfi(cfi); } const pct = Math.round(progress ?? 0); progressPctRef.current = pct; setProgressPct(pct); const label = section?.label?.trim() ?? ""; sectionRef.current = { index: section?.index ?? sectionRef.current.index, label, }; setChapterTitle(label); if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(flushProgress, PROGRESS_SAVE_MS); }, [flushProgress], ); const handleSelected = useCallback( async (selectedText: string, cfiRange: string) => { const excerpt = (selectedText ?? "").slice(0, 280); try { const created = await createMarker({ ebook: ebookId, epub_cfi: cfiRange, chapter_index: sectionRef.current.index, chapter_title: sectionRef.current.label, location_text: excerpt, content: excerpt, highlight_color: HIGHLIGHT_COLOR, }); setBookmarks((prev) => [created, ...prev]); try { reader.addAnnotation?.("highlight", cfiRange, undefined, { fill: HIGHLIGHT_COLOR, }); reader.removeSelection?.(); } catch { /* annotation rendering is best-effort */ } } catch { /* ignore highlight save failures */ } }, [ebookId, reader], ); const handleToggleBookmarkHere = useCallback(async () => { const cfi = currentCfiRef.current; if (!cfi) return; const existing = bookmarksRef.current.find((b) => b.epub_cfi === cfi); if (existing) { setBookmarks((prev) => prev.filter((b) => b.id !== existing.id)); try { reader.removeAnnotationByCfi?.(cfi); } catch { /* best-effort */ } deleteBookmark(existing.id).catch(() => {}); return; } try { const created = await createMarker({ ebook: ebookId, epub_cfi: cfi, chapter_index: sectionRef.current.index, chapter_title: sectionRef.current.label, location_text: sectionRef.current.label, }); setBookmarks((prev) => [created, ...prev]); } catch { /* ignore bookmark save failures */ } }, [ebookId, reader]); const handleSelectBookmark = useCallback( (bookmark: Bookmark) => { setBookmarksOpen(false); try { reader.goToLocation?.(bookmark.epub_cfi); } catch { /* best-effort */ } }, [reader], ); const handleDeleteBookmark = useCallback( (bookmark: Bookmark) => { setBookmarks((prev) => prev.filter((b) => b.id !== bookmark.id)); try { if (bookmark.highlight_color) { reader.removeAnnotationByCfi?.(bookmark.epub_cfi); } } catch { /* best-effort */ } deleteBookmark(bookmark.id).catch(() => {}); }, [reader], ); const handleTocSelect = useCallback( (href: string) => { setTocOpen(false); try { reader.goToLocation?.(href); } catch { /* best-effort */ } }, [reader], ); const onReaderLayout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setSize((prev) => prev && prev.width === width && prev.height === height ? prev : { width, height }, ); }, []); const palette = resolvePalette(settings); const isBookmarked = bookmarks.some((b) => b.epub_cfi === currentCfi); return ( setTocOpen(true)} onToggleSettings={() => setSettingsOpen(true)} onToggleBookmarks={() => setBookmarksOpen(true)} onToggleBookmarkHere={handleToggleBookmarkHere} /> {fileError ? ( Could not download this book. Check your connection and try again. ) : src && size ? ( ) : ( )} setTocOpen(false)} /> setSettingsOpen(false)} /> setBookmarksOpen(false)} /> ); } const styles = StyleSheet.create({ container: { flex: 1, }, readerArea: { flex: 1, }, centered: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24, }, message: { fontSize: 15, textAlign: "center", }, });