Save last EPUB location

This commit is contained in:
Richard Roberson 2025-02-10 11:49:49 -07:00
parent 02595147fa
commit e8008b68ab
7 changed files with 150 additions and 40 deletions

View file

@ -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<string | null>(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();

View file

@ -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() {
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
onClick={() => {clearCurrDoc(); stop();}}
onClick={() => {clearCurrDoc();}}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -78,7 +79,7 @@ export default function PDFViewerPage() {
<div className="flex items-center gap-2">
<Link
href="/"
onClick={() => {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]"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -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<Book | null>(null);
@ -25,6 +28,19 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
const toc = useRef<NavItem[]>([]);
const locationRef = useRef<string | number>(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(() => {

View file

@ -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<void>;
clearCurrDoc: () => void;
@ -28,7 +28,7 @@ interface EPUBContextType {
const EPUBContext = createContext<EPUBContextType | undefined>(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<ArrayBuffer>();
@ -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

View file

@ -69,7 +69,7 @@ const PDFContext = createContext<PDFContextType | undefined>(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<string>();
@ -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(

View file

@ -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<number>(1);
const [currDocPage, setCurrDocPage] = useState<string | number>(1);
const [currDocPages, setCurrDocPages] = useState<number>();
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
*

View file

@ -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<void>((res) => { docTransaction.oncomplete = () => res(); }),
new Promise<void>((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<void>((res) => { docTransaction.oncomplete = () => res(); }),
new Promise<void>((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<string | null> {
export async function setItem(key: string, value: string): Promise<void> {
return indexedDBService.setConfigItem(key, value);
}
// Add these helper functions before the final export
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
const key = `lastLocation_${docId}`;
return indexedDBService.getConfigItem(key);
}
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
const key = `lastLocation_${docId}`;
return indexedDBService.setConfigItem(key, location);
}