diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index a404dac..44b524e 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -19,9 +19,8 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const { id } = useParams(); - const { getDocument } = usePDF(); + const { setCurrentDocument, currDocName } = usePDF(); const { setText, stop } = useTTS(); - const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [zoomLevel, setZoomLevel] = useState(100); @@ -29,12 +28,11 @@ export default function PDFViewerPage() { useEffect(() => { async function loadDocument() { try { - const doc = await getDocument(id as string); - if (!doc) { + if (!id) { setError('Document not found'); return; } - setDocument(doc); + setCurrentDocument(id as string); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); @@ -44,7 +42,7 @@ export default function PDFViewerPage() { } loadDocument(); - }, [id, getDocument]); + }, [id, setCurrentDocument]); const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 200)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); @@ -107,7 +105,7 @@ export default function PDFViewerPage() {

- {isLoading ? 'Loading...' : document?.name} + {isLoading ? 'Loading...' : currDocName}

@@ -116,7 +114,7 @@ export default function PDFViewerPage() { ) : ( - + )} ); diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index 28e7ee9..6f38afd 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -13,19 +13,31 @@ import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import TTSPlayer from '@/components/player/TTSPlayer'; interface PDFViewerProps { - pdfData: Blob | undefined; zoomLevel: number; } -export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) { - const [numPages, setNumPages] = useState(); +export function PDFViewer({ zoomLevel }: PDFViewerProps) { const [containerWidth, setContainerWidth] = useState(0); - const { setText, currentSentence, stopAndPlayFromIndex, isProcessing, isPlaying, currentIndex, sentences, stop } = useTTS(); - const [pdfText, setPdfText] = useState(''); - const [pdfDataUrl, setPdfDataUrl] = useState(); - const [loadingError, setLoadingError] = useState(); const containerRef = useRef(null); - const { extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick } = usePDF(); + + // TTS context + const { + currentSentence, + stopAndPlayFromIndex, + isProcessing + } = useTTS(); + + // PDF context + const { + highlightPattern, + clearHighlights, + handleTextClick, + onDocumentLoadSuccess, + currDocURL, + currDocPages, + currDocText, + currDocPage, + } = usePDF(); // Add static styles once during component initialization const styleElement = document.createElement('style'); @@ -47,58 +59,6 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) { }; }, [styleElement]); - useEffect(() => { - /* - * Converts PDF blob to a data URL for display. - * Cleans up by clearing the data URL when component unmounts. - * - * Dependencies: - * - pdfData: Re-run when the PDF blob changes to convert it to a new data URL - */ - if (!pdfData) return; - - const reader = new FileReader(); - reader.onload = () => { - setPdfDataUrl(reader.result as string); - }; - reader.onerror = () => { - console.error('Error reading file:', reader.error); - setLoadingError('Failed to load PDF'); - }; - reader.readAsDataURL(pdfData); - - return () => { - setPdfDataUrl(undefined); - }; - }, [pdfData]); - - useEffect(() => { - /* - * Extracts text content from the PDF once it's loaded. - * Sets the extracted text for both display and text-to-speech. - * - * Dependencies: - * - pdfDataUrl: Re-run when the data URL is ready - * - extractTextFromPDF: Function from context that could change - * - setText: Function from context that could change - * - pdfData: Source PDF blob that's being processed - */ - if (!pdfDataUrl || !pdfData) return; - - const loadPdfText = async () => { - try { - const text = await extractTextFromPDF(pdfData); - setPdfText(text); - setText(text); - } catch (error) { - console.error('Error loading PDF text:', error); - setLoadingError('Failed to extract PDF text'); - } - }; - - loadPdfText(); - }, [pdfDataUrl, extractTextFromPDF, setText, pdfData]); - useEffect(() => { /* * Sets up click event listeners for text selection in the PDF. @@ -111,10 +71,11 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) { */ const container = containerRef.current; if (!container) return; + if (!currDocText) return; const handleClick = (event: MouseEvent) => handleTextClick( event, - pdfText, + currDocText, containerRef as RefObject, stopAndPlayFromIndex, isProcessing @@ -123,7 +84,7 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) { return () => { container.removeEventListener('click', handleClick); }; - }, [pdfText, handleTextClick, stopAndPlayFromIndex, isProcessing]); + }, [currDocText, handleTextClick, stopAndPlayFromIndex, isProcessing]); useEffect(() => { /* @@ -136,25 +97,27 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) { * - highlightPattern: Function from context that could change * - clearHighlights: Function from context that could change */ + if (!currDocText) return; + const highlightTimeout = setTimeout(() => { if (containerRef.current) { - highlightPattern(pdfText, currentSentence || '', containerRef as RefObject); + highlightPattern(currDocText, currentSentence || '', containerRef as RefObject); } - }, 100); + }, 200); return () => { clearTimeout(highlightTimeout); clearHighlights(); }; - }, [pdfText, currentSentence, highlightPattern, clearHighlights]); + }, [currDocText, currentSentence, highlightPattern, clearHighlights]); // Add scale calculation function - const calculateScale = (pageWidth: number = 595) => { // 595 is default PDF width in points + const calculateScale = useCallback((pageWidth = 595): number => { const margin = 24; // 24px padding on each side const targetWidth = containerWidth - margin; const baseScale = targetWidth / pageWidth; return baseScale * (zoomLevel / 100); - }; + }, [containerWidth, zoomLevel]); // Add resize observer effect useEffect(() => { @@ -171,121 +134,22 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) { return () => observer.disconnect(); }, []); - function onDocumentLoadSuccess({ numPages }: { numPages: number }): void { - setNumPages(numPages); - } - - const [currentPage, setCurrentPage] = useState(1); - const handlePageChange = useCallback(async (pageNumber: number) => { - if (pageNumber < 1 || pageNumber > (numPages || 1)) return; - - // Stop current playback and reset states - if (isPlaying) { - stop(); - } - - setCurrentPage(pageNumber); - - // Extract text from the new page - if (pdfData) { - try { - const pdf = await pdfjs.getDocument(pdfDataUrl!).promise; - const page = await pdf.getPage(pageNumber); - const textContent = await page.getTextContent(); - const textItems = textContent.items.filter((item): item is TextItem => - 'str' in item && 'transform' in item - ); - - // Process text items to maintain reading order - const lines: TextItem[][] = []; - let currentLine: TextItem[] = []; - let currentY: number | null = null; - const tolerance = 2; - - textItems.forEach((item) => { - const y = item.transform[5]; - if (currentY === null) { - currentY = y; - currentLine.push(item); - } else if (Math.abs(y - currentY) < tolerance) { - currentLine.push(item); - } else { - lines.push(currentLine); - currentLine = [item]; - currentY = y; - } - }); - lines.push(currentLine); - - // Build page text - let fullText = ''; - for (const line of lines) { - line.sort((a, b) => a.transform[4] - b.transform[4]); - let lineText = ''; - let prevItem: TextItem | null = null; - - for (const item of line) { - if (!prevItem) { - lineText = item.str; - } else { - const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0); - const currentStartX = item.transform[4]; - const space = currentStartX - prevEndX; - - if (space > ((item.width ?? 0) * 0.3)) { - lineText += ' ' + item.str; - } else { - lineText += item.str; - } - } - prevItem = item; - } - fullText += lineText + ' '; - } - - setText(fullText.trim()); // Update TTS with current page text - - // // Wait for text processing before resuming playback - // await new Promise(resolve => setTimeout(resolve, 300)); - - // if (wasPlaying) { - // stopAndPlayFromIndex(0); - // } - } catch (error) { - console.error('Error extracting page text:', error); - } - } - }, [isPlaying, pdfData, pdfDataUrl, numPages, setText, stop]); - - // Auto-advance to next page when TTS reaches the end - useEffect(() => { - if (!isPlaying && currentIndex >= sentences.length - 1 && currentPage < (numPages || 1)) { - const timer = setTimeout(() => { - handlePageChange(currentPage + 1); - }, 500); // Longer delay to ensure states are settled - return () => clearTimeout(timer); - } - }, [isPlaying, currentIndex, sentences.length, currentPage, numPages, handlePageChange]); - return (
- {loadingError ? ( -
{loadingError}
- ) : null} } noData={} - file={pdfDataUrl} + file={currDocURL} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); - handlePageChange(1); // Load first page text + //handlePageChange(1); // Load first page text }} className="flex flex-col items-center m-0" >
{}} />
); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 227d114..1810f02 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -18,10 +18,21 @@ import nlp from 'compromise'; // Add the correct type import import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import { useConfig } from '@/contexts/ConfigContext'; +import { useTTS } from '@/contexts/TTSContext'; // Set worker from public directory pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; +// Convert PDF data to PDF data URL +const convertPDFDataToURL = (pdfData: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(pdfData); + }); +}; + interface PDFContextType { documents: PDFDocument[]; addDocument: (file: File) => Promise; @@ -29,7 +40,7 @@ interface PDFContextType { removeDocument: (id: string) => Promise; isLoading: boolean; error: string | null; - extractTextFromPDF: (pdfData: Blob) => Promise; + extractTextFromPDF: (pdfURL: string, currDocPage: number) => Promise; highlightPattern: (text: string, pattern: string, containerRef: React.RefObject) => void; clearHighlights: () => void; handleTextClick: ( @@ -39,21 +50,41 @@ interface PDFContextType { stopAndPlayFromIndex: (index: number) => void, isProcessing: boolean ) => void; + onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void; + convertPDFDataToURL: (pdfData: Blob) => Promise; + setCurrentDocument: (id: string) => Promise; + currDocURL: string | undefined; + currDocName: string | undefined; + currDocPages: number | undefined; + currDocPage: number; + currDocText: string | undefined; } const PDFContext = createContext(undefined); export function PDFProvider({ children }: { children: ReactNode }) { - const { isDBReady } = useConfig(); const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const { isDBReady } = useConfig(); + const { + setText: setTTSText, + currDocPage, + currDocPages, + setCurrDocPages, + } = useTTS(); + + // Current document state + const [currDocURL, setCurrDocURL] = useState(); + const [currDocName, setCurrDocName] = useState(); + const [currDocText, setCurrDocText] = useState(); + // Load documents from IndexedDB once DB is ready useEffect(() => { const loadDocuments = async () => { if (!isDBReady) return; - + try { setError(null); const docs = await indexedDBService.getAllDocuments(); @@ -117,33 +148,30 @@ export function PDFProvider({ children }: { children: ReactNode }) { } }, []); + function onDocumentLoadSuccess({ numPages }: { numPages: number }): void { + console.log('Document loaded:', numPages); + setCurrDocPages(numPages); + } + // Extract text from a PDF file - const extractTextFromPDF = useCallback(async (pdfData: Blob): Promise => { - try { - const reader = new FileReader(); - const dataUrl = await new Promise((resolve, reject) => { - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(pdfData); - }); - - const base64Data = dataUrl.split(',')[1]; - const binaryData = atob(base64Data); - const bytes = new Uint8Array(binaryData.length); - for (let i = 0; i < binaryData.length; i++) { - bytes[i] = binaryData.charCodeAt(i); - } - - const loadingTask = pdfjs.getDocument({ data: bytes }); - const pdf = await loadingTask.promise; - let fullText = ''; - - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const textContent = await page.getTextContent(); + const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise => { + try { + const base64Data = pdfURL.split(',')[1]; + const binaryData = atob(base64Data); + const bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + + const loadingTask = pdfjs.getDocument({ data: bytes }); + const pdf = await loadingTask.promise; + // Get only the specified page + const page = await pdf.getPage(currDocPage); + const textContent = await page.getTextContent(); + // Filter out non-text items and assert proper type - const textItems = textContent.items.filter((item): item is TextItem => + const textItems = textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item ); @@ -173,7 +201,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { for (const line of lines) { // Sort items horizontally within the line line.sort((a, b) => a.transform[4] - b.transform[4]); - + let lineText = ''; let prevItem: TextItem | null = null; @@ -196,16 +224,50 @@ export function PDFProvider({ children }: { children: ReactNode }) { } pageText += lineText + ' '; } - - fullText += pageText + '\n'; + + return pageText.replace(/\s+/g, ' ').trim(); + } catch (error) { + console.error('Error extracting text from PDF:', error); + throw new Error('Failed to extract text from PDF'); } + }, []); - return fullText.replace(/\s+/g, ' ').trim(); + // Load curr doc text + const loadCurrDocText = useCallback(async () => { + try { + if (!currDocURL) return; + const text = await extractTextFromPDF(currDocURL, currDocPage); + setCurrDocText(text); + setTTSText(text); } catch (error) { - console.error('Error extracting text from PDF:', error); - throw new Error('Failed to extract text from PDF'); + console.error('Error loading PDF text:', error); + setError('Failed to extract PDF text'); } - }, []); + }, [currDocURL, currDocPage, extractTextFromPDF, setTTSText]); + + // Update the current document text when the page changes + useEffect(() => { + if (currDocURL) { + loadCurrDocText(); + } + }, [currDocPage, currDocURL, loadCurrDocText]); + + // Set curr document + const setCurrentDocument = useCallback(async (id: string): Promise => { + setError(null); + try { + const doc = await getDocument(id); + if (doc) { + const url = await convertPDFDataToURL(doc.data); + setCurrDocName(doc.name); + setCurrDocURL(url); + //await loadCurrDocText(); + } + } catch (error) { + console.error('Failed to get document URL:', error); + setError('Failed to retrieve the document. Please try again.'); + } + }, [getDocument, convertPDFDataToURL, loadCurrDocText]); // Clear all highlights in the PDF viewer const clearHighlights = useCallback(() => { @@ -291,7 +353,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { // Search for the best match within the visible area first let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2); - + // If no good match found in visible area, search the entire document if (bestMatch.rating < 0.3) { bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2); @@ -395,6 +457,14 @@ export function PDFProvider({ children }: { children: ReactNode }) { highlightPattern, clearHighlights, handleTextClick, + onDocumentLoadSuccess, + convertPDFDataToURL, + setCurrentDocument, + currDocURL, + currDocName, + currDocPages, + currDocPage, + currDocText, }), [ documents, @@ -407,6 +477,14 @@ export function PDFProvider({ children }: { children: ReactNode }) { highlightPattern, clearHighlights, handleTextClick, + onDocumentLoadSuccess, + convertPDFDataToURL, + setCurrentDocument, + currDocURL, + currDocName, + currDocPages, + currDocPage, + currDocText, ] ); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index e93aa99..5021ba3 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -51,6 +51,9 @@ interface TTSContextType { availableVoices: string[]; currentIndex: number; setCurrentIndex: (index: number) => void; + currDocPage: number; + currDocPages: number | undefined; + setCurrDocPages: (pages: number) => void; } const TTSContext = createContext(undefined); @@ -67,7 +70,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [sentences, setSentences] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [audioContext, setAudioContext] = useState(); - const currentRequestRef = useRef(null); const [activeHowl, setActiveHowl] = useState(null); const [audioQueue] = useState([]); const [isProcessing, setIsProcessing] = useState(false); @@ -75,31 +77,33 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [voice, setVoice] = useState('alloy'); const [availableVoices, setAvailableVoices] = useState([]); + const [currDocPage, setCurrDocPage] = useState(1); + const [currDocPages, setCurrDocPages] = useState(); + const [nextPageLoading, setNextPageLoading] = useState(false); + // Audio cache using LRUCache with a maximum size of 50 entries const audioCacheRef = useRef(new LRUCache({ max: 50 })); const setText = useCallback((text: string) => { setCurrentText(text); + console.log('Setting page text:', text); const newSentences = splitIntoSentences(text); setSentences(newSentences); - setCurrentIndex(0); - setIsPlaying(false); + //setCurrentIndex(0); + //setIsPlaying(false); // Clear audio cache audioCacheRef.current.clear(); + + setNextPageLoading(false); }, []); const abortAudio = useCallback(() => { - if (currentRequestRef.current) { - currentRequestRef.current.abort(); - currentRequestRef.current = null; - } - if (activeHowl) { activeHowl.stop(); setActiveHowl(null); } - }, [currentRequestRef, activeHowl]); + }, [activeHowl]); const togglePlay = useCallback(() => { setIsPlaying((prev) => { @@ -231,10 +235,17 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { if (nextIndex < sentences.length) { console.log('Auto-advancing to next sentence:', sentences[nextIndex]); return nextIndex; + } else if (currDocPage < currDocPages!) { + console.log('Auto-advancing to next page:', currDocPage + 1); + + setNextPageLoading(true); + setCurrDocPage(currDocPage + 1); + + return 0; } return prev; }); - }, [sentences]); + }, [sentences, currDocPage, currDocPages]); //new function to return audio buffer with caching const getAudio = useCallback(async (sentence: string): Promise => { @@ -273,23 +284,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { // Only set processing state if not preloading if (!preload) setIsProcessing(true); - try { - const cleanedSentence = preprocessSentenceForAudio(sentence); - currentRequestRef.current = new AbortController(); - - const audioBuffer = await getAudio(cleanedSentence); - if (!currentRequestRef.current) throw new Error('Request was cancelled'); - - - return audioBufferToURL(audioBuffer!); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - console.log('Request was cancelled'); - } - throw error; - } finally { - currentRequestRef.current = null; - } + const cleanedSentence = preprocessSentenceForAudio(sentence); + const audioBuffer = await getAudio(cleanedSentence); + + return audioBufferToURL(audioBuffer!); }, [isProcessing, audioContext, getAudio]); const playSentenceWithHowl = useCallback(async (sentence: string) => { @@ -299,9 +297,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { throw new Error('No audio URL generated'); } - // Set processing to false before creating the Howl instance - setIsProcessing(false); - const howl = new Howl({ src: [audioUrl], format: ['wav'], @@ -335,28 +330,28 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setActiveHowl(howl); howl.play(); + setIsProcessing(false); } catch (error) { console.error('Error playing TTS:', error); setActiveHowl(null); setIsProcessing(false); - setIsPlaying(false); // Stop playback on error + //setIsPlaying(false); // Stop playback on error if (error instanceof Error && (error.message.includes('Unable to decode audio data') || error.message.includes('EncodingError'))) { - console.log('Skipping problematic sentence:', sentence.substring(0, 50) + '...'); - advance(); + console.log('Skipping problematic sentence:', sentence); + advance(); // Skip problematic sentence } } }, [isPlaying, processSentence, advance]); - const preloadNextAudio = useCallback(async () => { + const preloadNextAudio = useCallback(() => { try { - // Only preload next sentence to reduce API load if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) { - await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay - await processSentence(sentences[currentIndex + 1], true); // True indicates preloading + //await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay + processSentence(sentences[currentIndex + 1], true); // True indicates preloading } } catch (error) { console.error('Error preloading next sentence:', error); @@ -364,24 +359,23 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [currentIndex, sentences, audioCacheRef, processSentence]); const playAudio = useCallback(async () => { - //if (isPlaying && sentences[currentIndex] && !isProcessing) { await playSentenceWithHowl(sentences[currentIndex]); - //} }, [sentences, currentIndex, playSentenceWithHowl]); // main driver useEffect useEffect(() => { - if (!sentences[currentIndex]) { - return; // Don't proceed if no valid sentence + if (!isPlaying) return; // Don't proceed if stopped + if (isProcessing) return; // Don't proceed if processing audio + if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play + if (nextPageLoading) return; // Don't proceed if loading next page + if (activeHowl) return; // Don't proceed if audio is already playing + + // Play the current sentence and preload the next one if available + playAudio(); + if (sentences[currentIndex + 1]) { + preloadNextAudio(); } - - if (isPlaying && !isProcessing && !activeHowl) { - playAudio(); - if (isPlaying) { // Only preload if still playing - preloadNextAudio().catch(console.error); - } - } - + return () => { abortAudio(); }; @@ -390,6 +384,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { isProcessing, currentIndex, sentences, + activeHowl, + nextPageLoading, playAudio, preloadNextAudio, abortAudio @@ -477,6 +473,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setVoiceAndRestart, availableVoices, currentIndex, + currDocPage, + currDocPages, + setCurrDocPages, }; if (configIsLoading) {