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.
414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useBookProgress } from '@/store/readerProgressStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
|
|
import { Book, BookProgress, FIXED_LAYOUT_FORMATS } from '@/types/book';
|
|
import { BookDoc } from '@/libs/document';
|
|
import { debounce } from '@/utils/debounce';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
|
|
import { isMalformedLocationCfi } from '@/utils/cfi';
|
|
import {
|
|
formatProgressPercentage,
|
|
getLocalProgressPreview,
|
|
getProgressPercentage,
|
|
} from './kosyncPreview';
|
|
import { getRemoteFraction, getRemoteLocalFraction, isXPointerProgress } from './kosyncProgress';
|
|
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
|
|
|
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
|
|
|
|
export interface SyncDetails {
|
|
book: Book;
|
|
bookDoc: BookDoc;
|
|
local: {
|
|
cfi?: string;
|
|
preview: string;
|
|
};
|
|
remote: KoSyncProgress & {
|
|
preview: string;
|
|
percentage?: number;
|
|
};
|
|
}
|
|
|
|
export const useKOSync = (bookKey: string) => {
|
|
const _ = useTranslation();
|
|
const { appService } = useEnv();
|
|
const { settings } = useSettingsStore();
|
|
// Per-field selectors — methods are stable refs, so no subscription churn.
|
|
const getProgress = useReaderStore((s) => s.getProgress);
|
|
const getView = useReaderStore((s) => s.getView);
|
|
const getBookData = useBookDataStore((s) => s.getBookData);
|
|
const getConfig = useBookDataStore((s) => s.getConfig);
|
|
const setConfig = useBookDataStore((s) => s.setConfig);
|
|
|
|
const [kosyncClient, setKOSyncClient] = useState<KOSyncClient | null>(null);
|
|
const [syncState, setSyncState] = useState<SyncState>('idle');
|
|
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
|
|
const [errorMessage] = useState<string | null>(null);
|
|
const hasPulledOnce = useRef(false);
|
|
|
|
// Reactive subscription: drives the auto-push effect and the initial
|
|
// pull-on-open effect below. Reads from readerProgressStore.
|
|
const progress = useBookProgress(bookKey);
|
|
|
|
useEffect(() => {
|
|
if (!settings.kosync.username || !settings.kosync.userkey) {
|
|
setKOSyncClient(null);
|
|
return;
|
|
}
|
|
const client = new KOSyncClient(settings.kosync);
|
|
setKOSyncClient(client);
|
|
}, [settings]);
|
|
|
|
const generateKOProgress = useCallback(async () => {
|
|
const progress = getProgress(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
if (!progress || !bookData) return null;
|
|
|
|
let koProgress = '';
|
|
let percentage: number;
|
|
if (bookData.isFixedLayout) {
|
|
const page = progress.section?.current ?? 0;
|
|
const totalPages = progress.section?.total ?? 0;
|
|
koProgress = page.toString();
|
|
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
|
|
} else {
|
|
const view = getView(bookKey);
|
|
const config = getConfig(bookKey);
|
|
const cfi = progress.location;
|
|
if (!view || !cfi) return null;
|
|
if (isMalformedLocationCfi(cfi)) {
|
|
// A malformed empty-start/end CFI (cfi-inert skip-link bug) resolves to
|
|
// the wrong end of the section. Don't derive an XPointer from it — once
|
|
// pushed as an XPointer the "malformed" signal is lost and other devices
|
|
// can't discard it. Reuse the last known-good XPointer instead.
|
|
if (config?.xpointer) koProgress = config.xpointer;
|
|
} else {
|
|
try {
|
|
const koContents = view.renderer.getContents();
|
|
const koPrimaryIdx = view.renderer.primaryIndex;
|
|
const content = koContents.find((x) => x.index === koPrimaryIdx) ?? koContents[0];
|
|
// progress.location may be a CFI in a different spine section than the
|
|
// currently-rendered primary view (#primaryIndex can lag behind the
|
|
// viewport while scrolling). Resolve against the CFI's own section
|
|
// rather than forcing the primary view's document, which throws on a
|
|
// spine-index mismatch.
|
|
const xpointerResult = await getXPointerFromCFI(
|
|
cfi,
|
|
content?.doc,
|
|
content?.index,
|
|
bookData.bookDoc ?? undefined,
|
|
);
|
|
koProgress = xpointerResult.xpointer;
|
|
setConfig(bookKey, { xpointer: koProgress });
|
|
} catch (error) {
|
|
console.error('Failed to convert CFI to XPointer', error);
|
|
if (config?.xpointer) koProgress = config.xpointer;
|
|
}
|
|
}
|
|
|
|
const page = progress.pageinfo?.current ?? 0;
|
|
const totalPages = progress.pageinfo?.total ?? 0;
|
|
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
|
|
}
|
|
|
|
return { koProgress, percentage };
|
|
}, [bookKey, getProgress, getBookData, getView, getConfig, setConfig]);
|
|
|
|
const applyRemoteProgress = async (book: Book, bookDoc: BookDoc, remote: KoSyncProgress) => {
|
|
const view = getView(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
if (!view || !bookData) return;
|
|
|
|
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
|
|
const pageToGo = parseInt(remote.progress!, 10);
|
|
if (isNaN(pageToGo)) return;
|
|
view.select(pageToGo - 1);
|
|
} else {
|
|
let navigated = false;
|
|
// KOReader stores positions as CREngine XPointers; convert and jump
|
|
// precisely when we have one.
|
|
if (isXPointerProgress(remote.progress)) {
|
|
try {
|
|
const content = view.renderer
|
|
.getContents()
|
|
.find((x) => x.index === view.renderer.primaryIndex);
|
|
const cfi = await getCFIFromXPointer(
|
|
remote.progress!,
|
|
content?.doc,
|
|
content?.index,
|
|
bookDoc,
|
|
);
|
|
view.goTo(cfi);
|
|
navigated = true;
|
|
} catch (error) {
|
|
console.error('Failed to convert XPointer to CFI', error);
|
|
}
|
|
}
|
|
// Other KOSync-compatible servers (e.g. Kavita) report progress in
|
|
// formats Readest can't resolve positionally — approximate with the
|
|
// reported percentage so "use remote" still moves the reader.
|
|
if (!navigated) {
|
|
const remoteFraction = getRemoteFraction(remote);
|
|
if (remoteFraction === undefined) return;
|
|
view.goToFraction(remoteFraction);
|
|
}
|
|
}
|
|
eventDispatcher.dispatch('hint', {
|
|
bookKey,
|
|
message: _('Reading Progress Synced'),
|
|
});
|
|
};
|
|
|
|
const promptedSync = async (
|
|
book: Book,
|
|
bookDoc: BookDoc,
|
|
local: BookProgress,
|
|
remote: KoSyncProgress,
|
|
) => {
|
|
let remotePreview = '';
|
|
const remotePercentage = remote.percentage || 0;
|
|
// Progress last pushed from this same device is just our own earlier
|
|
// position; only treat a sizeable jump (≥1%) as a conflict so we don't
|
|
// prompt on the sub-page drift between a push and the next pull.
|
|
const isSameDevice = !!remote.device_id && remote.device_id === settings.kosync.deviceId;
|
|
const conflictProgressDiffThreshold = isSameDevice ? 0.01 : 0.0001;
|
|
// The remote progress as a percentage to compare against the local one;
|
|
// refined to a locally-resolved fraction for reflowable books below.
|
|
let remoteComparePercentage = remotePercentage;
|
|
let showConflictDetails = false;
|
|
const isFixedLayout = FIXED_LAYOUT_FORMATS.has(book.format);
|
|
|
|
const localPreview = getLocalProgressPreview(local, isFixedLayout, _);
|
|
const localPercentage = getProgressPercentage(isFixedLayout ? local.section : local.pageinfo);
|
|
|
|
if (isFixedLayout) {
|
|
const localPageInfo = local.section;
|
|
const remotePage = parseInt(remote.progress!, 10);
|
|
if (!isNaN(remotePage) && remotePercentage > 0) {
|
|
const localTotalPages = localPageInfo?.total ?? 0;
|
|
const remoteTotalPages = Math.round(remotePage / remotePercentage);
|
|
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
|
|
|
|
if (pagesMatch) {
|
|
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
|
|
page: remotePage,
|
|
total: remoteTotalPages,
|
|
percentage: formatProgressPercentage(remotePercentage),
|
|
});
|
|
} else {
|
|
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
|
|
page: remotePage,
|
|
total: remoteTotalPages,
|
|
percentage: formatProgressPercentage(remotePercentage),
|
|
});
|
|
}
|
|
showConflictDetails =
|
|
Math.abs(localPercentage - remotePercentage) > conflictProgressDiffThreshold;
|
|
} else {
|
|
remotePreview = _('Approximately {{percentage}}%', {
|
|
percentage: formatProgressPercentage(remotePercentage),
|
|
});
|
|
}
|
|
} else {
|
|
// KOReader's reported percentage comes from its own pagination, so it's
|
|
// not directly comparable to Readest's progress. Resolve the remote
|
|
// position to a local fraction for an apples-to-apples comparison and
|
|
// fall back to the reported percentage only when it can't be resolved
|
|
// locally (non-XPointer progress or a missing section).
|
|
const view = getView(bookKey);
|
|
const localFraction = view ? await getRemoteLocalFraction(remote, view, bookDoc) : undefined;
|
|
remoteComparePercentage = localFraction ?? remotePercentage;
|
|
remotePreview = _('Approximately {{percentage}}%', {
|
|
percentage: formatProgressPercentage(remoteComparePercentage),
|
|
});
|
|
showConflictDetails =
|
|
Math.abs(localPercentage - remoteComparePercentage) > conflictProgressDiffThreshold;
|
|
}
|
|
|
|
if (showConflictDetails) {
|
|
setConflictDetails({
|
|
book,
|
|
bookDoc,
|
|
local: { cfi: local.location, preview: localPreview },
|
|
remote: { ...remote, preview: remotePreview },
|
|
});
|
|
}
|
|
return showConflictDetails;
|
|
};
|
|
|
|
const pushProgress = useMemo(
|
|
() =>
|
|
debounce(async () => {
|
|
if (!bookKey || !appService || !kosyncClient || !hasPulledOnce.current) return;
|
|
const { settings } = useSettingsStore.getState();
|
|
if (['receive', 'disable'].includes(settings.kosync.strategy)) return;
|
|
|
|
const currentBook = getBookData(bookKey)?.book;
|
|
const progress = await generateKOProgress();
|
|
if (!currentBook || !progress || !progress.koProgress) return;
|
|
|
|
console.log('[KOSync] Pushing progress');
|
|
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
|
|
}, 5000),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[bookKey, appService, kosyncClient],
|
|
);
|
|
|
|
const pullProgress = useCallback(
|
|
async () => {
|
|
if (!progress?.location || !appService || !kosyncClient) return;
|
|
|
|
const bookData = getBookData(bookKey);
|
|
const book = bookData?.book;
|
|
const bookDoc = bookData?.bookDoc;
|
|
if (!book || !bookDoc) return;
|
|
|
|
const { strategy, enabled } = settings.kosync;
|
|
if (!enabled) return;
|
|
|
|
hasPulledOnce.current = true;
|
|
if (strategy === 'send') {
|
|
setSyncState('synced');
|
|
return;
|
|
}
|
|
|
|
setSyncState('checking');
|
|
const remoteProgress = await kosyncClient.getProgress(book);
|
|
if (!remoteProgress || !remoteProgress.progress) {
|
|
setSyncState('synced');
|
|
return;
|
|
}
|
|
console.log('[KOSync] Pulled remote progress', { bookKey, remoteProgress });
|
|
|
|
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
|
|
const remoteTimestamp = remoteProgress.timestamp
|
|
? remoteProgress.timestamp * 1000
|
|
: Date.now();
|
|
const remoteIsNewer = remoteTimestamp > localTimestamp;
|
|
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
|
|
applyRemoteProgress(book, bookDoc, remoteProgress);
|
|
setSyncState('synced');
|
|
} else if (strategy === 'prompt') {
|
|
// Only stay in the conflict state when there's an actual conflict to
|
|
// resolve; otherwise return to 'synced' so auto-push keeps working.
|
|
const hasConflict = await promptedSync(book, bookDoc, progress, remoteProgress);
|
|
setSyncState(hasConflict ? 'conflict' : 'synced');
|
|
} else {
|
|
setSyncState('synced');
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[bookKey, appService, kosyncClient, settings.kosync, progress],
|
|
);
|
|
|
|
// use a ref to track the current push/pull functions so they can change without triggering effects
|
|
const syncRefs = useRef({ pushProgress, pullProgress });
|
|
useEffect(() => {
|
|
syncRefs.current = { pushProgress, pullProgress };
|
|
}, [pushProgress, pullProgress]);
|
|
|
|
useEffect(() => {
|
|
const handlePushProgress = (event: CustomEvent) => {
|
|
const { pushProgress } = syncRefs.current;
|
|
if (event.detail.bookKey !== bookKey) return;
|
|
pushProgress();
|
|
pushProgress.flush();
|
|
};
|
|
const handleFlush = (event: CustomEvent) => {
|
|
const { pushProgress } = syncRefs.current;
|
|
if (event.detail.bookKey !== bookKey) return;
|
|
pushProgress.flush();
|
|
};
|
|
eventDispatcher.on('push-kosync', handlePushProgress);
|
|
eventDispatcher.on('flush-kosync', handleFlush);
|
|
return () => {
|
|
const { pushProgress } = syncRefs.current;
|
|
eventDispatcher.off('push-kosync', handlePushProgress);
|
|
eventDispatcher.off('flush-kosync', handleFlush);
|
|
pushProgress.flush();
|
|
};
|
|
}, [bookKey]);
|
|
|
|
useEffect(() => {
|
|
const handlePullProgress = (event: CustomEvent) => {
|
|
if (event.detail.bookKey !== bookKey) return;
|
|
const { pullProgress } = syncRefs.current;
|
|
pullProgress();
|
|
};
|
|
eventDispatcher.on('pull-kosync', handlePullProgress);
|
|
return () => {
|
|
eventDispatcher.off('pull-kosync', handlePullProgress);
|
|
};
|
|
}, [bookKey]);
|
|
|
|
// Pull: pull progress once when the book is opened
|
|
useEffect(() => {
|
|
if (!appService || !kosyncClient || !progress?.location) return;
|
|
if (hasPulledOnce.current) return;
|
|
|
|
syncRefs.current.pullProgress();
|
|
}, [appService, kosyncClient, progress?.location]);
|
|
|
|
// Push: auto-push progress when progress changes with a debounce
|
|
useEffect(() => {
|
|
if (syncState === 'synced' && progress) {
|
|
// Skip auto-pushes while previewing a deep-link target. Manual pushes
|
|
// via the 'push-kosync' event are still respected (explicit user intent).
|
|
if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return;
|
|
const { strategy, enabled } = settings.kosync;
|
|
if (strategy !== 'receive' && enabled) {
|
|
syncRefs.current.pushProgress();
|
|
}
|
|
}
|
|
}, [progress, syncState, settings.kosync, bookKey]);
|
|
|
|
useWindowActiveChanged((isActive) => {
|
|
const { pushProgress, pullProgress } = syncRefs.current;
|
|
|
|
if (isActive) {
|
|
hasPulledOnce.current = false;
|
|
pullProgress();
|
|
} else {
|
|
pushProgress();
|
|
pushProgress.flush();
|
|
}
|
|
});
|
|
|
|
const resolveWithLocal = () => {
|
|
pushProgress();
|
|
pushProgress.flush();
|
|
setSyncState('synced');
|
|
setConflictDetails(null);
|
|
};
|
|
|
|
const resolveWithRemote = async () => {
|
|
const view = getView(bookKey);
|
|
const remote = conflictDetails?.remote;
|
|
const book = conflictDetails?.book;
|
|
const bookDoc = conflictDetails?.bookDoc;
|
|
|
|
if (!book || !bookDoc || !remote || !view) return;
|
|
if (!remote.progress && getRemoteFraction(remote) === undefined) return;
|
|
|
|
applyRemoteProgress(book, bookDoc, remote);
|
|
setSyncState('synced');
|
|
setConflictDetails(null);
|
|
};
|
|
|
|
return {
|
|
syncState,
|
|
conflictDetails,
|
|
errorMessage,
|
|
pushProgress,
|
|
pullProgress,
|
|
resolveWithLocal,
|
|
resolveWithRemote,
|
|
};
|
|
};
|