forked from akai/readest
59d4f0aa33
setProgress was called multiple times per swipe burst, each call writing into readerStore.viewStates[key].progress. ~65 places in the reader subtree subscribed to useReaderStore() without a selector, so every setProgress fan-out re-rendered all of them -- even the 51 that didn't care about progress. On Android release builds this showed up as Layout = 9.8% and Function Call = 9.6% of main-thread self time in Chrome DevTools' Bottom-Up profile during a reading session. Fix: - New tiny store store/readerProgressStore.ts holds the per-book BookProgress map. setBookProgress only fires its own subscribers. - readerStore.setProgress now writes progress to the new store and only touches bookDataStore for the primary view (secondary parallel views shouldn't overwrite the shared config). - readerStore.getProgress is kept as a delegating facade so existing imperative call sites don't break. - Components / hooks that genuinely need to react to progress changes subscribe via the new useBookProgress(bookKey) hook. The handful of call sites that just want a one-shot read use getBookProgress(key) so they don't subscribe at all. - readerStore.clearViewState calls clearBookProgress so the map doesn't grow unbounded across book opens/closes. See store/readerProgressStore.ts header for the full rationale.
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
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;
|
|
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;
|
|
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,
|
|
) => 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<void>;
|
|
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<ReaderStore>((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,
|
|
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,
|
|
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,
|
|
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,
|
|
) => {
|
|
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,
|
|
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,
|
|
},
|
|
},
|
|
})),
|
|
|
|
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()}`,
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
},
|
|
}));
|