diff --git a/apps/readest-app/src/app/reader/hooks/useKOSync.ts b/apps/readest-app/src/app/reader/hooks/useKOSync.ts index 7a4e9f34..7cff20d9 100644 --- a/apps/readest-app/src/app/reader/hooks/useKOSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useKOSync.ts @@ -11,6 +11,8 @@ import { debounce } from '@/utils/debounce'; import { eventDispatcher } from '@/utils/event'; import { getCFIFromXPointer, XCFI } from '@/utils/xcfi'; +import { useWindowActiveChanged } from './useWindowActiveChanged'; + type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error'; export interface SyncDetails { @@ -252,53 +254,75 @@ export const useKOSync = (bookKey: string) => { [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, pushProgress]); + }, [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, pullProgress]); + }, [bookKey]); // Pull: pull progress once when the book is opened useEffect(() => { if (!appService || !kosyncClient || !progress?.location) return; if (hasPulledOnce.current) return; - pullProgress(); - }, [appService, kosyncClient, progress?.location, pushProgress, pullProgress]); + syncRefs.current.pullProgress(); + }, [appService, kosyncClient, progress?.location]); // Push: auto-push progress when progress changes with a debounce useEffect(() => { if (syncState === 'synced' && progress) { const { strategy, enabled } = settings.kosync; if (strategy !== 'receive' && enabled) { - pushProgress(); + syncRefs.current.pushProgress(); } } - }, [progress, syncState, settings.kosync, pushProgress]); + }, [progress, syncState, settings.kosync]); + + useWindowActiveChanged((isActive) => { + const { pushProgress, pullProgress } = syncRefs.current; + + if (isActive) { + hasPulledOnce.current = false; + pullProgress(); + } else { + pushProgress(); + pushProgress.flush(); + } + }); const resolveWithLocal = () => { pushProgress(); diff --git a/apps/readest-app/src/app/reader/hooks/useWindowActiveChanged.ts b/apps/readest-app/src/app/reader/hooks/useWindowActiveChanged.ts new file mode 100644 index 00000000..7d4e6050 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useWindowActiveChanged.ts @@ -0,0 +1,64 @@ +// used to execute a callback when the "active" state of the current window changes. +// On web and mobile, "active" means "is visible". On desktop, the 'visibilitychange' +// event is unreliable, so "active" means "has focus". + +import { useEffect, useRef } from 'react'; + +import { useEnv } from '@/context/EnvContext'; + +export type ActiveCallback = (isActive: boolean) => void; + +type Cleanup = () => void; +async function activeChangedDesktop(onChange: ActiveCallback): Promise { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const appWindow = getCurrentWindow(); + + const unlisten = await appWindow.onFocusChanged(({ payload: isActive }) => onChange(isActive)); + + return () => { + unlisten(); + }; +} +async function activeChangedOther(onChange: ActiveCallback): Promise { + const handler = () => onChange(document.visibilityState === 'visible'); + + document.addEventListener('visibilitychange', handler); + return () => document.removeEventListener('visibilitychange', handler); +} + +export function useWindowActiveChanged(callback: ActiveCallback) { + const onActiveChanged = useRef(callback); + const { appService } = useEnv(); + + const subscribe = appService?.isDesktopApp ? activeChangedDesktop : activeChangedOther; + + useEffect(() => { + onActiveChanged.current = callback; + }, [callback]); + + useEffect(() => { + let isAlive = true; + let unsub: Cleanup | undefined; + const onChange = (isActive: boolean) => { + onActiveChanged.current?.(isActive); + }; + + subscribe(onChange) + .then((cleanup) => { + if (isAlive) { + unsub = cleanup; + } else { + // component was already unmounted, just clean up immediately + cleanup(); + } + }) + .catch((e) => { + console.error('Could not listen for window active changes', e); + }); + + return () => { + isAlive = false; + unsub?.(); + }; + }, [subscribe]); +}