From e8008b68ab08d9166f19484a3270974e788b3eee Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Mon, 10 Feb 2025 11:49:49 -0700 Subject: [PATCH] Save last EPUB location --- src/app/epub/[id]/page.tsx | 7 ++- src/app/pdf/[id]/page.tsx | 7 +-- src/components/EPUBViewer.tsx | 31 +++++++++++-- src/contexts/EPUBContext.tsx | 7 ++- src/contexts/PDFContext.tsx | 6 +-- src/contexts/TTSContext.tsx | 48 +++++++++++++++----- src/utils/indexedDB.ts | 84 +++++++++++++++++++++++++++++------ 7 files changed, 150 insertions(+), 40 deletions(-) diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 6778b4e..dae8c58 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -9,16 +9,21 @@ import { EPUBViewer } from '@/components/EPUBViewer'; import { Button } from '@headlessui/react'; import { DocumentSettings } from '@/components/DocumentSettings'; import { SettingsIcon } from '@/components/icons/Icons'; +import { useTTS } from "@/contexts/TTSContext"; export default function EPUBPage() { const { id } = useParams(); const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB(); + const { stop } = useTTS(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const loadDocument = useCallback(async () => { if (!isLoading) return; + console.log('Loading new epub (from page.tsx)'); + stop(); // Reset TTS when loading new document + try { if (!id) { setError('Document not found'); @@ -31,7 +36,7 @@ export default function EPUBPage() { } finally { setIsLoading(false); } - }, [isLoading, id, setCurrentDocument]); + }, [isLoading, id, setCurrentDocument, stop]); useEffect(() => { loadDocument(); diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index bbf602e..fe3d6fd 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -32,6 +32,7 @@ export default function PDFViewerPage() { const loadDocument = useCallback(async () => { if (!isLoading) return; // Prevent calls when not loading new doc console.log('Loading new document (from page.tsx)'); + stop(); // Reset TTS when loading new document try { if (!id) { setError('Document not found'); @@ -44,7 +45,7 @@ export default function PDFViewerPage() { } finally { setIsLoading(false); } - }, [isLoading, id, setCurrentDocument]); + }, [isLoading, id, setCurrentDocument, stop]); useEffect(() => { loadDocument(); @@ -59,7 +60,7 @@ export default function PDFViewerPage() {

{error}

{clearCurrDoc(); stop();}} + onClick={() => {clearCurrDoc();}} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors" > @@ -78,7 +79,7 @@ export default function PDFViewerPage() {
{clearCurrDoc(); stop();}} + onClick={() => {clearCurrDoc();}} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]" > diff --git a/src/components/EPUBViewer.tsx b/src/components/EPUBViewer.tsx index e4d295c..58440f2 100644 --- a/src/components/EPUBViewer.tsx +++ b/src/components/EPUBViewer.tsx @@ -1,11 +1,13 @@ 'use client'; import { useEffect, useRef, useCallback } from 'react'; +import { useParams } from 'next/navigation'; import dynamic from 'next/dynamic'; import { useEPUB } from '@/contexts/EPUBContext'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import TTSPlayer from '@/components/player/TTSPlayer'; +import { setLastDocumentLocation } from '@/utils/indexedDB'; import type { Rendition, Book, NavItem } from 'epubjs'; const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), { @@ -18,6 +20,7 @@ interface EPUBViewerProps { } export function EPUBViewer({ className = '' }: EPUBViewerProps) { + const { id } = useParams(); const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB(); const { setEPUBPageInChapter, registerLocationChangeHandler } = useTTS(); const bookRef = useRef(null); @@ -25,6 +28,19 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { const toc = useRef([]); const locationRef = useRef(currDocPage); + // Load the last location when component mounts + // useEffect(() => { + // const loadLastLocation = async () => { + // if (id) { + // const lastLocation = await getLastDocumentLocation(id as string); + // if (lastLocation) { + // locationRef.current = lastLocation; + // } + // } + // }; + // loadLastLocation(); + // }, [id]); + const handleLocationChanged = useCallback((location: string | number) => { // Handle special 'next' and 'prev' cases if (location === 'next' && rendition.current) { @@ -42,12 +58,21 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { console.log('Displayed:', displayed, 'Chapter:', chapter); - locationRef.current = location; - setEPUBPageInChapter(displayed.page, displayed.total, chapter?.label || ''); + if (locationRef.current !== 1) { + // Save the location to IndexedDB + if (id) { + console.log('Saving location:', location); + setLastDocumentLocation(id as string, location.toString()); + } + } + + locationRef.current = location; + + setEPUBPageInChapter(displayed.page, displayed.total, chapter?.label || ''); extractPageText(bookRef.current, rendition.current); } - }, [setEPUBPageInChapter, extractPageText]); + }, [id, setEPUBPageInChapter, extractPageText]); // Register the location change handler useEffect(() => { diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 0fe8637..e40aae6 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -17,7 +17,7 @@ interface EPUBContextType { currDocData: ArrayBuffer | undefined; currDocName: string | undefined; currDocPages: number | undefined; - currDocPage: number; + currDocPage: number | string; currDocText: string | undefined; setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; @@ -28,7 +28,7 @@ interface EPUBContextType { const EPUBContext = createContext(undefined); export function EPUBProvider({ children }: { children: ReactNode }) { - const { setText: setTTSText, stop, currDocPage, currDocPages, setCurrDocPages } = useTTS(); + const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages } = useTTS(); // Current document state const [currDocData, setCurrDocData] = useState(); @@ -51,8 +51,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { setCurrDocName(undefined); setCurrDocText(undefined); setCurrDocPages(undefined); - stop(); - }, [setCurrDocPages, stop]); + }, [setCurrDocPages]); /** * Sets the current document based on its ID diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 627e8e1..d2396f4 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -69,7 +69,7 @@ const PDFContext = createContext(undefined); * Handles document loading, text processing, and integration with TTS. */ export function PDFProvider({ children }: { children: ReactNode }) { - const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages } = useTTS(); + const { setText: setTTSText, stop, currDocPageNumber: currDocPage, currDocPages, setCurrDocPages } = useTTS(); // Current document state const [currDocURL, setCurrDocURL] = useState(); @@ -136,8 +136,8 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocURL(undefined); setCurrDocText(undefined); setCurrDocPages(undefined); - setTTSText(''); - }, [setCurrDocPages, setTTSText]); + stop(); + }, [setCurrDocPages, stop]); // Context value memoization const contextValue = useMemo( diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 295dda1..e587d30 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -26,6 +26,7 @@ import React, { import OpenAI from 'openai'; import { Howl } from 'howler'; import toast from 'react-hot-toast'; +import { useParams } from 'next/navigation'; import { useConfig } from '@/contexts/ConfigContext'; import { splitIntoSentences, preprocessSentenceForAudio } from '@/utils/nlp'; @@ -34,6 +35,7 @@ import { useAudioCache } from '@/hooks/audio/useAudioCache'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; +import { getLastDocumentLocation } from '@/utils/indexedDB'; // Media globals declare global { @@ -52,7 +54,8 @@ interface TTSContextType { currentSentence: string; // Navigation - currDocPage: number; + currDocPage: string | number; // Change this to allow both types + currDocPageNumber: number; // For PDF currDocPages: number | undefined; // Voice settings @@ -110,6 +113,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { locationChangeHandlerRef.current = handler; }, []); + // Get document ID from URL params + const { id } = useParams(); + /** * State Management */ @@ -120,13 +126,15 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [isProcessing, setIsProcessing] = useState(false); const [speed, setSpeed] = useState(voiceSpeed); const [voice, setVoice] = useState(configVoice); - const [currDocPage, setCurrDocPage] = useState(1); + const [currDocPage, setCurrDocPage] = useState(1); const [currDocPages, setCurrDocPages] = useState(); const [nextPageLoading, setNextPageLoading] = useState(false); // Add this state to track if we're in EPUB mode const [isEPUB, setIsEPUB] = useState(false); + const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); + console.log('page:', currDocPage, 'pages:', currDocPages); /** @@ -137,8 +145,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { */ const incrementPage = useCallback((num = 1) => { setNextPageLoading(true); - setCurrDocPage(currDocPage + num); - }, [currDocPage]); + setCurrDocPage(currDocPageNumber + num); + }, [currDocPageNumber]); /** * Sets the current text and splits it into sentences @@ -167,12 +175,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { position: 'top-center', }); return; - } else if (currDocPage < currDocPages!) { + } else if (currDocPageNumber < currDocPages!) { // For PDF, increment the page incrementPage(); - toast.success(`Skipping blank page ${currDocPage}`, { - id: `page-${currDocPage}`, + toast.success(`Skipping blank page ${currDocPageNumber}`, { + id: `page-${currDocPageNumber}`, iconTheme: { primary: 'var(--accent)', secondary: 'var(--background)', @@ -190,7 +198,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setSentences(newSentences); setNextPageLoading(false); - }, [isPlaying, skipBlank, currDocPage, currDocPages, incrementPage, isEPUB]); + }, [isPlaying, skipBlank, currDocPageNumber, currDocPages, incrementPage, isEPUB]); /** * Stops the current audio playback and clears the active Howl instance @@ -299,8 +307,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { // For PDFs and other documents, check page bounds if (!isEPUB) { // Handle next/previous page transitions - if ((nextIndex >= sentences.length && currDocPage < currDocPages!) || - (nextIndex < 0 && currDocPage > 1)) { + if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) || + (nextIndex < 0 && currDocPageNumber > 1)) { console.log('PDF: Advancing to next/prev page'); setCurrentIndex(0); setSentences([]); @@ -309,12 +317,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } // Handle end of document (PDF only) - if (nextIndex >= sentences.length && currDocPage >= currDocPages!) { + if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) { console.log('PDF: Reached end of document'); setIsPlaying(false); } } - }, [currentIndex, incrementPage, sentences, currDocPage, currDocPages, isEPUB]); + }, [currentIndex, incrementPage, sentences, currDocPageNumber, currDocPages, isEPUB]); /** * Moves forward one sentence in the text @@ -637,6 +645,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { isProcessing, currentSentence: sentences[currentIndex] || '', currDocPage, + currDocPageNumber, currDocPages, availableVoices, togglePlay, @@ -657,6 +666,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { sentences, currentIndex, currDocPage, + currDocPageNumber, currDocPages, availableVoices, togglePlay, @@ -680,6 +690,20 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { skipBackward, }); + // Load last location on mount for EPUB only + useEffect(() => { + if (id && isEPUB) { + getLastDocumentLocation(id as string).then(lastLocation => { + if (lastLocation) { + setCurrDocPage(lastLocation); + if (locationChangeHandlerRef.current) { + locationChangeHandlerRef.current(lastLocation); + } + } + }); + } + }, [id, isEPUB]); + /** * Renders the TTS context provider with its children * diff --git a/src/utils/indexedDB.ts b/src/utils/indexedDB.ts index fd770d7..4f500f1 100644 --- a/src/utils/indexedDB.ts +++ b/src/utils/indexedDB.ts @@ -174,20 +174,40 @@ class IndexedDBService { return new Promise((resolve, reject) => { try { console.log('Removing document:', id); - const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(PDF_STORE_NAME); - const request = store.delete(id); + const locationKey = `lastLocation_${id}`; - request.onerror = (event) => { + // Create two transactions - one for document deletion, one for location cleanup + const docTransaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); + const configTransaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); + + const docStore = docTransaction.objectStore(PDF_STORE_NAME); + const configStore = configTransaction.objectStore(CONFIG_STORE_NAME); + + // Remove the document + const docRequest = docStore.delete(id); + // Remove any saved location + const locationRequest = configStore.delete(locationKey); + + docRequest.onerror = (event) => { const error = (event.target as IDBRequest).error; console.error('Error removing document:', error); reject(error); }; - transaction.oncomplete = () => { - console.log('Document removed successfully:', id); - resolve(); + locationRequest.onerror = (event) => { + console.warn('Error cleaning up location:', (event.target as IDBRequest).error); + // Don't reject here, as document deletion is the primary operation }; + + // Wait for both transactions to complete + Promise.all([ + new Promise((res) => { docTransaction.oncomplete = () => res(); }), + new Promise((res) => { configTransaction.oncomplete = () => res(); }) + ]).then(() => { + console.log('Document and location data removed successfully:', id); + resolve(); + }).catch(reject); + } catch (error) { console.error('Error in removeDocument transaction:', error); reject(error); @@ -303,18 +323,43 @@ class IndexedDBService { return new Promise((resolve, reject) => { try { - const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite'); - const store = transaction.objectStore(EPUB_STORE_NAME); - const request = store.delete(id); + console.log('Removing EPUB document:', id); + const locationKey = `lastLocation_${id}`; - request.onerror = (event) => { - reject((event.target as IDBRequest).error); + // Create two transactions - one for document deletion, one for location cleanup + const docTransaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite'); + const configTransaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); + + const docStore = docTransaction.objectStore(EPUB_STORE_NAME); + const configStore = configTransaction.objectStore(CONFIG_STORE_NAME); + + // Remove the document + const docRequest = docStore.delete(id); + // Remove any saved location + const locationRequest = configStore.delete(locationKey); + + docRequest.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error removing EPUB document:', error); + reject(error); }; - transaction.oncomplete = () => { + locationRequest.onerror = (event) => { + console.warn('Error cleaning up location:', (event.target as IDBRequest).error); + // Don't reject here, as document deletion is the primary operation + }; + + // Wait for both transactions to complete + Promise.all([ + new Promise((res) => { docTransaction.oncomplete = () => res(); }), + new Promise((res) => { configTransaction.oncomplete = () => res(); }) + ]).then(() => { + console.log('EPUB document and location data removed successfully:', id); resolve(); - }; + }).catch(reject); + } catch (error) { + console.error('Error in removeEPUBDocument transaction:', error); reject(error); } }); @@ -455,4 +500,15 @@ export async function getItem(key: string): Promise { export async function setItem(key: string, value: string): Promise { return indexedDBService.setConfigItem(key, value); +} + +// Add these helper functions before the final export +export async function getLastDocumentLocation(docId: string): Promise { + const key = `lastLocation_${docId}`; + return indexedDBService.getConfigItem(key); +} + +export async function setLastDocumentLocation(docId: string, location: string): Promise { + const key = `lastLocation_${docId}`; + return indexedDBService.setConfigItem(key, location); } \ No newline at end of file