'use client'; import dynamic from 'next/dynamic'; import { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/documents/DocumentSettings'; import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar'; import { Header } from '@/components/Header'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { LoadingSpinner } from '@/components/Spinner'; import { PdfLayoutScan } from '@/components/reader/PdfLayoutScan'; import { Button, ButtonLink } from '@/components/ui'; import { FORCE_REPARSE_CONFIRM_MESSAGE, FORCE_REPARSE_CONFIRM_TEXT, FORCE_REPARSE_CONFIRM_TITLE, isForceReparseDisabled, } from '@/lib/client/pdf/force-reparse'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { usePdfDocument } from './usePdfDocument'; // Dynamic import for client-side rendering only const PDFViewer = dynamic( () => import('@/components/views/PDFViewer').then((module) => module.PDFViewer), { ssr: false, loading: () => null } ); const PARSE_LOADER_EXPAND_DELAY_MS = 320; export default function PDFViewerPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); const { id } = useParams(); const router = useRouter(); const pdfState = usePdfDocument(); const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, isPlaybackReady, parseStatus, parseProgress, documentSettings, updateDocumentSettings, parsedOverlayEnabled, setParsedOverlayEnabled, forceReparseParsedPdf, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter, } = pdfState; const { stop } = useTTS(); const { isAtLimit } = useAuthRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isPdfViewerReady, setIsPdfViewerReady] = useState(false); const [zoomLevel, setZoomLevel] = useState(100); const [activeSidebar, setActiveSidebar] = useState(null); const [showForceReparseConfirm, setShowForceReparseConfirm] = useState(false); const [showDetailedParseLoader, setShowDetailedParseLoader] = useState(false); const [containerHeight, setContainerHeight] = useState('auto'); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); const [isNavigatingBack, setIsNavigatingBack] = useState(false); const parseUiState: NonNullable = parseStatus ?? 'pending'; const hasResolvedParseStatus = parseStatus !== null; const isParseReady = parseUiState === 'ready'; const forceReparseDisabled = isForceReparseDisabled(parseStatus); const hasRealParseProgress = !!parseProgress && parseProgress.totalPages > 0 && parseProgress.pagesParsed >= 0; const shouldShowExpandedParseLoader = !isLoading && hasResolvedParseStatus && (parseUiState === 'pending' || parseUiState === 'running' || parseUiState === 'failed' || hasRealParseProgress); useEffect(() => { setIsLoading(true); setIsPdfViewerReady(false); setError(null); setActiveSidebar(null); inFlightDocIdRef.current = null; loadedDocIdRef.current = null; }, [id]); const loadDocument = useCallback(async () => { if (!isLoading) return; // Prevent calls when not loading new doc console.log('Loading new document (from page.tsx)'); let didRedirect = false; let startedLoad = false; let loadSucceeded = false; try { if (!id) { setError('Document not found'); return; } const resolved = await resolveDocumentId(id as string); if (resolved !== (id as string)) { didRedirect = true; router.replace(`/pdf/${resolved}`); return; } if (loadedDocIdRef.current === resolved) { return; } if (inFlightDocIdRef.current === resolved) { return; } startedLoad = true; inFlightDocIdRef.current = resolved; stop(); // Reset TTS when loading new document for (let attempt = 0; attempt < 2; attempt += 1) { const loaded = await setCurrentDocument(resolved); if (loaded) { loadSucceeded = true; loadedDocIdRef.current = resolved; break; } if (attempt === 0) { await new Promise((resolve) => setTimeout(resolve, 250)); } } if (!loadSucceeded) { throw new Error(`Failed to load PDF document ${resolved}`); } } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { if (startedLoad) { inFlightDocIdRef.current = null; } if (!didRedirect && startedLoad && loadSucceeded) { setIsLoading(false); } } }, [isLoading, id, router, setCurrentDocument, stop]); useEffect(() => { loadDocument(); }, [loadDocument]); useUnmountCleanupRef(clearCurrDoc); useEffect(() => { if (isLoading) return; if (isParseReady) return; stop(); }, [isLoading, isParseReady, stop]); useEffect(() => { if (!shouldShowExpandedParseLoader) { // Keep the current loader variant stable during the final // parse-ready -> first-frame handoff to avoid a visual flash. if (!isLoading && isParseReady && !isPdfViewerReady) { return; } setShowDetailedParseLoader(false); return; } const timeout = window.setTimeout(() => { setShowDetailedParseLoader(true); }, PARSE_LOADER_EXPAND_DELAY_MS); return () => window.clearTimeout(timeout); }, [shouldShowExpandedParseLoader, id, isLoading, isParseReady, isPdfViewerReady]); // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { const header = document.querySelector('[data-app-header]') as HTMLElement | null; const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null; const headerH = header ? header.getBoundingClientRect().height : 0; const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); // Avoid locking the reader at 0px during transient startup layout states. if (h > 0) { setContainerHeight(`${h}px`); } }; compute(); const settleT1 = window.setTimeout(compute, 0); const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); return () => { window.removeEventListener('resize', compute); window.clearTimeout(settleT1); window.clearTimeout(settleT2); }; }, [isLoading, isParseReady, isAtLimit, activeSidebar]); const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); const handleBackToDocuments = useCallback((event?: MouseEvent) => { event?.preventDefault(); if (isNavigatingBack) return; setIsNavigatingBack(true); stop(); setActiveSidebar(null); router.push('/app'); }, [isNavigatingBack, stop, router]); const requestForceReparse = useCallback(() => { if (forceReparseDisabled) return; setShowForceReparseConfirm(true); }, [forceReparseDisabled]); const confirmForceReparse = useCallback(() => { setShowForceReparseConfirm(false); void forceReparseParsedPdf(); }, [forceReparseParsedPdf]); const handleGenerateAudiobook = useCallback(async ( onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, settings: AudiobookGenerationSettings ) => { if (!isParseReady) { throw new Error('PDF parsing is not ready yet.'); } return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); }, [createPDFAudioBook, id, isParseReady]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, bookId: string, settings: AudiobookGenerationSettings, signal: AbortSignal ) => { if (!isParseReady) { throw new Error('PDF parsing is not ready yet.'); } return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); }, [regeneratePDFChapter, isParseReady]); if (error) { return (

{error}

Back to Documents
); } const renderPdfStatusLoader = () => { const compactLabel = isLoading ? 'Opening PDF...' : (parseUiState === 'ready' ? 'Rendering pages...' : 'Preparing PDF layout...'); const compactSubLabel = isLoading ? 'Loading document data' : (parseUiState === 'ready' ? 'Preparing first frame' : 'Queueing parser and preparing page extraction'); const totalPages = parseProgress?.totalPages ?? 0; const pagesParsed = parseProgress?.pagesParsed ?? 0; const progressPercent = totalPages > 0 ? Math.max(0, Math.min(100, (pagesParsed / totalPages) * 100)) : 0; const hasMeasuredProgress = totalPages > 0; const isMerging = parseProgress?.phase === 'merge'; let statusText = 'Loading PDF...'; if (!isLoading) { if (parseUiState === 'pending') { statusText = 'Preparing PDF layout...'; } else if (parseUiState === 'running') { statusText = 'Parsing PDF layout blocks...'; } else if (parseUiState === 'failed') { statusText = 'PDF parsing failed. Retry to continue.'; } } const stageLabel = parseUiState === 'failed' ? 'Stage: blocked' : (parseUiState === 'pending' ? 'Stage: prepare' : (isMerging ? 'Stage: merge' : 'Stage: infer')); return (
{showDetailedParseLoader ? (
{/* prism top edge + corner glow for depth */}
{/* dotted texture spanning the whole loader */}
{/* header: status badge + model attribution */}
{parseUiState === 'failed' ? ( ) : ( )} PDF Layout Parse
PP-DocLayout-V3
{/* animated layout scanner — static "halted" view when failed */}
{/* live status + progress */} {parseUiState === 'failed' ? (

{statusText}

{stageLabel}

) : (

{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}

{stageLabel}

{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : 'Calibrating layout pass'}

)}
{/* attribution footer */}

Classifying footnotes, titles, tables, figures, formulas, & more with PP-DocLayout-V3.

{!isLoading && parseUiState === 'failed' ? (
) : null}
) : (

{compactLabel}

{compactSubLabel}

)}
); }; return ( <>
Documents } title={isLoading ? 'Loading…' : (currDocName || '')} right={
setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')} onOpenAudiobook={() => setActiveSidebar((prev) => prev === 'audiobook' ? null : 'audiobook')} onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')} isSettingsOpen={activeSidebar === 'settings'} isAudiobookOpen={activeSidebar === 'audiobook'} isSegmentsOpen={activeSidebar === 'segments'} showAudiobookExport={canExportAudiobook} minZoom={50} maxZoom={300} />
} />
{isParseReady ? (
setIsPdfViewerReady(true)} pdfState={pdfState} />
) : null} {isLoading || !isParseReady || !isPdfViewerReady ? (
{renderPdfStatusLoader()}
) : null}
{canExportAudiobook && ( setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))} documentType="pdf" documentId={id as string} onGenerateAudiobook={handleGenerateAudiobook} onRegenerateChapter={handleRegenerateChapter} /> )} {isAtLimit ? (
) : isParseReady ? ( ) : null} setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} language={documentSettings.language ?? 'auto'} onLanguageChange={(language) => { void updateDocumentSettings({ ...documentSettings, schemaVersion: 1, language, }); }} pdf={{ parseStatus, parsedOverlayEnabled, skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], onToggleOverlay: (enabled) => setParsedOverlayEnabled(enabled), onToggleSkipKind: (kind, enabled) => { const current = new Set(documentSettings.pdf?.skipBlockKinds ?? []); if (enabled) current.add(kind); else current.delete(kind); void updateDocumentSettings({ ...documentSettings, schemaVersion: 1, pdf: { ...(documentSettings.pdf ?? {}), skipBlockKinds: Array.from(current), }, }); }, onForceReparse: requestForceReparse, }} /> setShowForceReparseConfirm(false)} onConfirm={confirmForceReparse} title={FORCE_REPARSE_CONFIRM_TITLE} message={FORCE_REPARSE_CONFIRM_MESSAGE} confirmText={FORCE_REPARSE_CONFIRM_TEXT} cancelText="Cancel" /> setActiveSidebar((prev) => isOpen ? 'segments' : (prev === 'segments' ? null : prev))} documentId={id as string} /> ); }