import { create } from 'zustand'; import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings, TimeInfo, FIXED_LAYOUT_FORMATS, } from '@/types/book'; import { Insets } from '@/types/misc'; import { EnvConfigType } from '@/services/environment'; import { FoliateView } from '@/types/view'; import { DocumentLoader, TOCItem } from '@/libs/document'; import { isPseStreamFileName, openPseStreamBook, parsePseStreamFileName, } from '@/services/opds/pseStream'; import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/services/nav'; import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book'; import { getBaseFilename } from '@/utils/path'; import { SUPPORTED_LANGNAMES } from '@/services/constants'; import { useSettingsStore } from './settingsStore'; import { BookData, useBookDataStore } from './bookDataStore'; import { useLibraryStore } from './libraryStore'; import { clearBookProgress, getBookProgress, setBookProgress } from './readerProgressStore'; import { uniqueId } from '@/utils/misc'; interface ViewState { /* Unique key for each book view */ key: string; view: FoliateView | null; viewerKey: string; isPrimary: boolean; loading: boolean; inited: boolean; error: string | null; /* `progress` moved to readerProgressStore — see that file's header for rationale. Use `useBookProgress(key)` for reactive subscription or `getBookProgress(key)` for one-shot reads. */ ribbonVisible: boolean; ttsEnabled: boolean; /* True while an Auto Scroll session (#4998) is engaged for this view; session-only, never persisted. Drives the View menu checkmark. */ autoScrollEnabled: boolean; syncing: boolean; gridInsets: Insets | null; /* True while the reader is showing a position requested by an external deep link (e.g. ?cfi=...) that the user hasn't yet confirmed by reading. Progress writers (auto-save, cloud sync, kosync) skip while this is true so the user's actual last-read position isn't overwritten by a preview. Cleared on the first user-initiated relocate (page turn / scroll). */ previewMode: boolean; /* View settings for the view: generally view settings have a hierarchy of global settings < book settings < view settings view settings for primary view are saved to book config which is persisted to config file omitting settings that are not changed from global settings */ viewSettings: ViewSettings | null; } interface ReaderStore { viewStates: { [key: string]: ViewState }; bookKeys: string[]; hoveredBookKey: string | null; setBookKeys: (keys: string[]) => void; setHoveredBookKey: (key: string | null) => void; setBookmarkRibbonVisibility: (key: string, visible: boolean) => void; setTTSEnabled: (key: string, enabled: boolean) => void; setAutoScrollEnabled: (key: string, enabled: boolean) => void; setIsLoading: (key: string, loading: boolean) => void; setIsSyncing: (key: string, syncing: boolean) => void; setProgress: ( key: string, location: string, tocItem: TOCItem, pageItem: BookProgress['pageItem'], section: PageInfo, pageinfo: PageInfo, timeinfo: TimeInfo, range: Range, fraction: number, ) => void; getProgress: (key: string) => BookProgress | null; setView: (key: string, view: FoliateView) => void; getView: (key: string | null) => FoliateView | null; getViews: () => FoliateView[]; getViewsById: (id: string) => FoliateView[]; setViewSettings: (key: string, viewSettings: ViewSettings) => void; getViewSettings: (key: string) => ViewSettings | null; initViewState: ( envConfig: EnvConfigType, id: string, key: string, isPrimary?: boolean, reload?: boolean, ) => Promise; clearViewState: (key: string) => void; getViewState: (key: string) => ViewState | null; getGridInsets: (key: string) => Insets | null; setGridInsets: (key: string, insets: Insets | null) => void; setViewInited: (key: string, inited: boolean) => void; setPreviewMode: (key: string, previewMode: boolean) => void; recreateViewer: (envConfig: EnvConfigType, key: string) => void; } export const useReaderStore = create((set, get) => ({ viewStates: {}, bookKeys: [], hoveredBookKey: null, setBookKeys: (keys: string[]) => set({ bookKeys: keys }), setHoveredBookKey: (key: string | null) => set({ hoveredBookKey: key }), getView: (key: string | null) => (key && get().viewStates[key]?.view) || null, setView: (key: string, view) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, view }, }, })), getViews: () => Object.values(get().viewStates).map((state) => state.view!), getViewsById: (id: string) => { const { viewStates } = get(); return Object.values(viewStates) .filter((state) => state.key && state.key.startsWith(id)) .map((state) => state.view!); }, clearViewState: (key: string) => { // Drop the per-book progress entry alongside the view state so the // standalone progress store doesn't leak across opens/closes. clearBookProgress(key); set((state) => { const viewStates = { ...state.viewStates }; delete viewStates[key]; return { viewStates }; }); }, getViewState: (key: string) => get().viewStates[key] || null, initViewState: async ( envConfig: EnvConfigType, id: string, key: string, isPrimary = true, reload = false, ) => { const booksData = useBookDataStore.getState().booksData; const bookData = booksData[id]; set((state) => ({ viewStates: { ...state.viewStates, [key]: { key: '', view: null, viewerKey: '', isPrimary: false, loading: true, inited: false, error: null, ribbonVisible: false, ttsEnabled: false, autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, viewSettings: null, }, }, })); try { const appService = await envConfig.getAppService(); const { settings } = useSettingsStore.getState(); const { getBookByHash, library } = useLibraryStore.getState(); const book = getBookByHash(id); if (!book) { console.error( `Book ${id} not found in library (size=${library.length}); likely the in-memory entry was dropped by a library reload.`, ); throw new Error('Book not found'); } const isPseStream = !!book.url && isPseStreamFileName(book.url); let bookDoc = bookData?.bookDoc; let file: File | null = bookData?.file ?? null; if (!bookDoc || (!isPseStream && !file) || reload) { console.log('Loading book', key); if (isPseStream) { const data = parsePseStreamFileName(book.url!); const doc = await openPseStreamBook(data); bookDoc = doc.book; file = null; } else { const content = (await appService.loadBookContent(book)) as BookContent; file = content.file; let nativeFilePath: string | null = null; try { nativeFilePath = await appService.resolveNativeBookFilePath(book); } catch (err) { console.warn('resolveNativeBookFilePath failed', err); } const doc = await new DocumentLoader(file, { nativeFilePath: nativeFilePath ?? undefined, }).open(); bookDoc = doc.book; } } const config = await appService.loadBookConfig(book, settings); // Import annotations from third-party readers on first open if (bookDoc.metadata.identifier) { const { getAnnotationProviders } = await import('@/services/annotation'); for (const provider of getAnnotationProviders()) { if (provider.isAvailable(appService)) { const merged = await provider.importAnnotations( appService, bookDoc.metadata.identifier, config, ); if (merged !== config) { Object.assign(config, merged); await appService.saveBookConfig(book, config, settings); } } } } // Filter out invalid booknotes config.booknotes = config.booknotes?.filter((booknote) => booknote.cfi) ?? []; // Load cached book navigation (TOC + section fragments) or compute and persist. if (book.format === 'EPUB' && bookDoc.rendition?.layout !== 'pre-paginated') { const cachedNav = await appService.loadBookNav(book); if (cachedNav?.version === BOOK_NAV_VERSION && process.env.NODE_ENV === 'production') { hydrateBookNav(bookDoc, cachedNav); } else { const freshNav = await computeBookNav(bookDoc); hydrateBookNav(bookDoc, freshNav); try { await appService.saveBookNav(book, freshNav); } catch (e) { console.warn('Failed to persist book nav cache:', e); } } } await updateToc( bookDoc, config.viewSettings?.sortedTOC ?? false, config.viewSettings?.convertChineseVariant ?? 'none', ); if (!bookDoc.metadata.title && file) { bookDoc.metadata.title = getBaseFilename(file.name); } book.sourceTitle = formatTitle(bookDoc.metadata.title); // Correct language codes mistakenly set with language names if (typeof bookDoc.metadata?.language === 'string') { if (bookDoc.metadata.language in SUPPORTED_LANGNAMES) { bookDoc.metadata.language = SUPPORTED_LANGNAMES[bookDoc.metadata.language]!; } } // Set the book's language for formerly imported books, newly imported books have this field set const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language); book.primaryLanguage = book.primaryLanguage ?? primaryLanguage; book.metadata = book.metadata ?? bookDoc.metadata; // Update series info from metadata if available and not already set on the book if (bookDoc.metadata.belongsTo?.series) { const belongsTo = bookDoc.metadata.belongsTo.series; const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo; if (series) { book.metadata.series = book.metadata.series ?? formatTitle(series.name); book.metadata.seriesIndex = book.metadata.seriesIndex ?? parseFloat(series.position || '0'); book.metadata.seriesTotal = book.metadata.seriesTotal ?? (series.total ? parseInt(series.total, 10) : undefined); } } // TODO: uncomment this when we can ensure metaHash is correctly generated for all books // book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata); book.metaHash = getMetadataHash(bookDoc.metadata); const isFixedLayout = bookDoc.rendition?.layout === 'pre-paginated' || FIXED_LAYOUT_FORMATS.has(book.format); const newBookData: BookData = { id, book, file, config, bookDoc, isFixedLayout }; useBookDataStore.setState((state) => ({ booksData: { ...state.booksData, [id]: newBookData, }, })); const configViewSettings = config.viewSettings!; const globalViewSettings = settings.globalViewSettings; set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key], key, view: null, viewerKey: `${key}-${uniqueId()}`, isPrimary, loading: false, inited: false, error: null, ribbonVisible: false, ttsEnabled: false, autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, viewSettings: { ...globalViewSettings, ...configViewSettings }, }, }, })); } catch (error) { console.error(error); set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key], key: '', view: null, viewerKey: '', isPrimary: false, loading: false, inited: false, error: 'Failed to load book.', ribbonVisible: false, ttsEnabled: false, autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, viewSettings: null, }, }, })); throw error; } }, getViewSettings: (key: string) => get().viewStates[key]?.viewSettings || null, setViewSettings: (key: string, viewSettings: ViewSettings) => { if (!key) return; const id = key.split('-')[0]!; const bookData = useBookDataStore.getState().booksData[id]; const viewState = get().viewStates[key]; if (!viewState || !bookData) return; if (viewState.isPrimary) { useBookDataStore.setState((state) => ({ booksData: { ...state.booksData, [id]: { ...bookData, config: { ...bookData.config, updatedAt: Date.now(), viewSettings, }, }, }, })); } set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, viewSettings, }, }, })); }, // Delegates to the standalone readerProgressStore so that progress reads // do not subscribe the caller to readerStore. Most call sites need a // one-shot read (event handlers, useEffect bodies). Components that // genuinely depend on progress for rendering should subscribe via the // `useBookProgress(key)` hook exported from readerProgressStore instead. getProgress: (key: string) => getBookProgress(key), setProgress: ( key: string, location: string, tocItem: TOCItem, pageItem: BookProgress['pageItem'], section: PageInfo, pageinfo: PageInfo, timeinfo: TimeInfo, range: Range, fraction: number, ) => { const id = key.split('-')[0]!; const bookData = useBookDataStore.getState().booksData[id]; const viewState = get().viewStates[key]; if (!viewState || !bookData) return; const pageInfo = bookData.isFixedLayout ? section : pageinfo; const progress: [number, number] = [pageInfo.current + 1, pageInfo.total]; const progressPercentage = Math.round((progress[0] / progress[1]) * 100); // Lightweight library update — O(1) lookup, no array copy, no refreshGroups const { getBookByHash, updateBookProgress } = useLibraryStore.getState(); const existingBook = getBookByHash(id); if (existingBook) { let newReadingStatus = existingBook.readingStatus; if (existingBook.readingStatus === 'unread') { newReadingStatus = undefined; } if (progressPercentage >= 100 && existingBook.readingStatus !== 'finished') { newReadingStatus = 'finished'; } updateBookProgress(id, progress, newReadingStatus); } // Only the primary view persists progress into the shared bookData // config — secondary views in a parallel layout shouldn't overwrite // it. Skip the bookDataStore write entirely when not primary to spare // its subscribers a re-render. if (viewState.isPrimary) { useBookDataStore.setState((state) => { const existing = state.booksData[id]; if (!existing) return state; return { booksData: { ...state.booksData, [id]: { ...existing, config: { ...existing.config, progress, location, } as BookConfig, }, }, }; }); } // Write progress to the standalone store. This is the only setState on // the hot swipe path that the previous implementation routed through // the (much bigger) readerStore — the split here is the whole point of // the refactor: components subscribing to `useReaderStore()` without a // selector will no longer re-render per page turn. setBookProgress(key, { location, sectionHref: tocItem?.href, sectionLabel: tocItem?.label, pageItem, section, pageinfo, timeinfo, fraction, index: section.current, range, page: pageInfo.current + 1, } as BookProgress); }, setBookmarkRibbonVisibility: (key: string, visible: boolean) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, ribbonVisible: visible, }, }, })), setTTSEnabled: (key: string, enabled: boolean) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, ttsEnabled: enabled, }, }, })), setAutoScrollEnabled: (key: string, enabled: boolean) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, autoScrollEnabled: enabled, }, }, })), setIsLoading: (key: string, loading: boolean) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, loading, }, }, })), setIsSyncing: (key: string, syncing: boolean) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, syncing, }, }, })), getGridInsets: (key: string) => get().viewStates[key]?.gridInsets || { top: 0, right: 0, bottom: 0, left: 0 }, setGridInsets: (key: string, insets: Insets | null) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, gridInsets: insets, }, }, })), setViewInited: (key: string, inited: boolean) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, inited, }, }, })), setPreviewMode: (key: string, previewMode: boolean) => set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, previewMode, }, }, })), recreateViewer: (envConfig: EnvConfigType, key: string) => { const id = key.split('-')[0]!; get() .initViewState(envConfig, id, key, true, true) .then(() => { set((state) => ({ viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, viewerKey: `${key}-${uniqueId()}`, }, }, })); }); }, }));