Files
readest/apps/readest-app/src/app/reader/components/ReaderContent.tsx
T
2026-07-09 14:15:36 +08:00

318 lines
12 KiB
TypeScript

'use client';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { useGamepad } from '@/hooks/useGamepad';
import { useTranslation } from '@/hooks/useTranslation';
import { SystemSettings } from '@/types/settings';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { UnlistenFn } from '@tauri-apps/api/event';
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
import { uniqueId } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { eventDispatcher } from '@/utils/event';
import {
closeReaderWindowOrGoToLibrary,
ensureMainLibraryWindow,
navigateToLibrary,
} from '@/utils/nav';
import { clearDiscordPresence } from '@/utils/discord';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import { BookDetailModal } from '@/components/metadata';
import ShareBookDialog from '@/app/library/components/ShareBookDialog';
import { useAuth } from '@/context/AuthContext';
import useBooksManager from '../hooks/useBooksManager';
import useBookShortcuts from '../hooks/useBookShortcuts';
import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import ReviewPanel from './ReviewPanel';
import SettingsDialog from '@/components/settings/SettingsDialog';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { bookKeys, dismissBook, getNextBookKey } = useBooksManager();
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
const { saveSettings } = useSettingsStore();
const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys, getViewSettings } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore();
const clearReviewBook = useReviewModeStore((state) => state.clearBook);
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [shareDialogState, setShareDialogState] = useState<{
book: Book;
cfi: string | null;
} | null>(null);
const { user } = useAuth();
const isInitiating = useRef(false);
const [loading, setLoading] = useState(false);
const [errorLoading, setErrorLoading] = useState(false);
useBookShortcuts({ sideBarBookKey, bookKeys });
useGamepad();
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const pathname = window.location.pathname;
const bookIds = ids || searchParams?.get('ids') || pathname.split('/reader/')[1] || '';
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
setBookKeys(initialBookKeys);
const uniqueIds = new Set<string>();
console.log('Initialize books', initialBookKeys);
initialBookKeys.forEach((key, index) => {
const id = key.split('-')[0]!;
const isPrimary = !uniqueIds.has(id);
uniqueIds.add(id);
if (!getViewState(key)) {
initViewState(envConfig, id, key, isPrimary).catch((error) => {
console.log('Error initializing book', key, error);
setErrorLoading(true);
eventDispatcher.dispatch('toast', {
message: _('Unable to open book'),
callback: async () => {
const service = await envConfig.getAppService();
await closeReaderWindowOrGoToLibrary(service, router);
},
timeout: 2000,
type: 'error',
});
});
if (index === 0) setSideBarBookKey(key);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const handleShowBookDetails = (event: CustomEvent) => {
setShowDetailsBook(event.detail as Book);
return true;
};
eventDispatcher.onSync('show-book-details', handleShowBookDetails);
return () => {
eventDispatcher.offSync('show-book-details', handleShowBookDetails);
};
}, []);
useEffect(() => {
const handleShareIntent = (event: CustomEvent) => {
const detail = event.detail as { book: Book; cfi?: string | null } | undefined;
if (!detail?.book) return;
if (!user) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Sign in to share books'),
timeout: 2500,
});
return;
}
setShareDialogState({
book: detail.book,
cfi: detail.cfi ?? null,
});
};
eventDispatcher.on('show-share-dialog', handleShareIntent);
return () => {
eventDispatcher.off('show-share-dialog', handleShareIntent);
};
}, [user, _]);
useEffect(() => {
if (bookKeys && bookKeys.length > 0) {
const settings = useSettingsStore.getState().settings;
const lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
if (settings.lastOpenBooks?.toString() !== lastOpenBooks.toString()) {
settings.lastOpenBooks = lastOpenBooks;
saveSettings(envConfig, settings);
}
}
let unlistenOnCloseWindow: Promise<UnlistenFn>;
if (isTauriAppPlatform()) {
unlistenOnCloseWindow = tauriHandleOnCloseWindow(handleCloseBooks);
}
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('beforereload', handleCloseBooks);
eventDispatcher.on('close-reader', handleCloseReaderToLibrary);
eventDispatcher.on('quit-app', handleCloseBooks);
return () => {
window.removeEventListener('beforeunload', handleCloseBooks);
eventDispatcher.off('beforereload', handleCloseBooks);
eventDispatcher.off('close-reader', handleCloseReaderToLibrary);
eventDispatcher.off('quit-app', handleCloseBooks);
unlistenOnCloseWindow?.then((fn) => fn());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKeys]);
const saveBookConfig = async (bookKey: string) => {
const config = getConfig(bookKey);
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
const settings = useSettingsStore.getState().settings;
eventDispatcher.dispatch('sync-book-progress', { bookKey });
eventDispatcher.dispatch('flush-kosync', { bookKey });
await saveConfig(envConfig, bookKey, config, settings);
}
};
const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => {
console.log('Closing book', bookKey);
clearReviewBook(bookKey);
const viewState = getViewState(bookKey);
if (viewState?.isPrimary && appService?.isDesktopApp) {
await clearDiscordPresence(appService);
}
try {
getView(bookKey)?.close();
getView(bookKey)?.remove();
} catch {
console.info('Error closing book', bookKey);
}
// Closes that keep the webview alive (back to library, Android back, pane
// dismiss) let a live TTS session continue in the background;
// webview-destroying closes (quit, window close, reload) hard-stop so the
// media session and Android foreground service tear down with the page.
eventDispatcher.dispatch(keepTTSAlive ? 'tts-close-book' : 'tts-stop', {
bookKey,
});
await saveBookConfig(bookKey);
clearViewState(bookKey);
};
const navigateBackToLibrary = () => {
navigateToLibrary(router, '', undefined, true);
};
const saveSettingsAndGoToLibrary = () => {
saveSettings(envConfig, settings);
navigateBackToLibrary();
};
const handleCloseReaderToLibrary = () => {
return handleCloseBooks(true);
};
// Also wired directly to beforeunload/quit-app/window-close, which pass an
// event object: only a literal `true` keeps TTS alive.
const handleCloseBooks = throttle(async (keepTTSAlive?: unknown) => {
const settings = useSettingsStore.getState().settings;
await Promise.all(
bookKeys.map(async (key) => await saveConfigAndCloseBook(key, keepTTSAlive === true)),
);
await saveSettings(envConfig, settings);
}, 200);
const handleCloseBooksToLibrary = async () => {
// SPA navigation in the main window (or on web) keeps the webview alive:
// TTS may continue headless. Non-main Tauri windows close their webview
// below, but their per-window TTS dies with the window either way.
handleCloseBooks(true);
if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow();
if (currentWindow.label === 'main') {
navigateBackToLibrary();
} else {
if (appService) {
await ensureMainLibraryWindow(appService);
}
currentWindow.close();
}
} else {
navigateBackToLibrary();
}
};
const handleCloseBook = async (bookKey: string) => {
// Header X / pane close: an SPA-side close on web and the main window.
// The Tauri reader-window branches below destroy their webview, which
// takes the per-window TTS with it either way.
saveConfigAndCloseBook(bookKey, true);
if (sideBarBookKey === bookKey) {
setSideBarBookKey(getNextBookKey(sideBarBookKey));
}
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (appService?.hasWindow) {
if (openWithFiles.length > 0) {
tauriHandleOnCloseWindow(handleCloseBooks);
return await tauriHandleClose();
}
const currentWindow = getCurrentWindow();
if (currentWindow.label.startsWith('reader')) {
return await currentWindow.close();
}
}
saveSettingsAndGoToLibrary();
}
};
if (!bookKeys || bookKeys.length === 0) return null;
const bookData = getBookData(bookKeys[0]!);
const viewSettings = getViewSettings(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc || !viewSettings) {
setTimeout(() => setLoading(true), 200);
return (
loading &&
!errorLoading && (
<div className='hero hero-content full-height'>
<Spinner loading={true} />
</div>
)
);
}
return (
<div className='reader-content full-height flex'>
<SideBar />
<BooksGrid
bookKeys={bookKeys}
onCloseBook={handleCloseBook}
onGoToLibrary={handleCloseBooksToLibrary}
/>
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
<ReviewPanel />
<Notebook />
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={() => setShowDetailsBook(null)}
/>
)}
<ShareBookDialog
isOpen={!!shareDialogState}
book={shareDialogState?.book ?? null}
cfi={shareDialogState?.cfi ?? null}
onClose={() => setShareDialogState(null)}
/>
</div>
);
};
export default ReaderContent;