Refactors + epub location fixes

This commit is contained in:
Richard Roberson 2025-02-18 02:31:30 -07:00
parent 9ae6dd9e82
commit 0ce14f3aec
9 changed files with 239 additions and 205 deletions

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import nlp from 'compromise'; import nlp from 'compromise';
const MAX_BLOCK_LENGTH = 350; const MAX_BLOCK_LENGTH = 300;
const preprocessSentenceForAudio = (text: string): string => { const preprocessSentenceForAudio = (text: string): string => {
return text return text

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { useEffect, useRef, useCallback, useMemo } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useEPUB } from '@/contexts/EPUBContext'; import { useEPUB } from '@/contexts/EPUBContext';
@ -24,18 +24,29 @@ interface EPUBViewerProps {
export function EPUBViewer({ className = '' }: EPUBViewerProps) { export function EPUBViewer({ className = '' }: EPUBViewerProps) {
const { id } = useParams(); const { id } = useParams();
const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB(); const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB();
const { setEPUBPageInChapter, registerLocationChangeHandler } = useTTS(); const { skipToLocation, registerLocationChangeHandler, setIsEPUB } = useTTS();
const { epubTheme } = useConfig(); const { epubTheme } = useConfig();
const bookRef = useRef<Book | null>(null); const bookRef = useRef<Book | null>(null);
const rendition = useRef<Rendition | undefined>(undefined); const rendition = useRef<Rendition | undefined>(undefined);
const toc = useRef<NavItem[]>([]); const toc = useRef<NavItem[]>([]);
const locationRef = useRef<string | number>(currDocPage); const locationRef = useRef<string | number>(currDocPage);
const [initialPrevLocLoad, setInitialPrevLocLoad] = useState(false);
const { updateTheme } = useEPUBTheme(epubTheme, rendition.current); const { updateTheme } = useEPUBTheme(epubTheme, rendition.current);
const handleLocationChanged = useCallback((location: string | number, initial = false) => { const isEPUBSetOnce = useRef(false);
const handleLocationChanged = useCallback((location: string | number) => {
// Set the EPUB flag once the location changes
if (!isEPUBSetOnce.current) {
setIsEPUB(true);
isEPUBSetOnce.current = true;
rendition.current?.display(location.toString());
return;
}
if (!bookRef.current?.isOpen || !rendition.current) return; if (!bookRef.current?.isOpen || !rendition.current) return;
// Handle special 'next' and 'prev' cases, which
// Handle special 'next' and 'prev' cases
if (location === 'next' && rendition.current) { if (location === 'next' && rendition.current) {
rendition.current.next(); rendition.current.next();
return; return;
@ -45,33 +56,18 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
return; return;
} }
// Save the location to IndexedDB if not initial
const { displayed, href } = rendition.current.location.start; if (id && locationRef.current !== 1) {
const chapter = toc.current.find((item) => item.href === href); console.log('Saving location:', location);
setLastDocumentLocation(id as string, location.toString());
console.log('Displayed:', displayed, 'Chapter:', chapter);
if (locationRef.current !== 1) {
// Save the location to IndexedDB
if (id) {
console.log('Saving location:', location);
setLastDocumentLocation(id as string, location.toString());
}
} }
setEPUBPageInChapter(displayed.page, displayed.total, chapter?.label || ''); skipToLocation(location);
// Add a small delay for initial load to ensure rendition is ready locationRef.current = location;
if (initial) { extractPageText(bookRef.current, rendition.current);
rendition.current.display(location.toString()).then(() => {
setInitialPrevLocLoad(true);
});
} else {
locationRef.current = location;
extractPageText(bookRef.current, rendition.current);
}
}, [id, setEPUBPageInChapter, extractPageText]); }, [id, skipToLocation, extractPageText, setIsEPUB]);
// Replace the debounced text extraction with a proper implementation using useMemo // Replace the debounced text extraction with a proper implementation using useMemo
const debouncedExtractText = useMemo(() => { const debouncedExtractText = useMemo(() => {
@ -86,27 +82,27 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
// Load the initial location and setup resize handler // Load the initial location and setup resize handler
useEffect(() => { useEffect(() => {
if (bookRef.current && rendition.current) { if (!bookRef.current || !rendition.current || isEPUBSetOnce.current) return;
extractPageText(bookRef.current, rendition.current);
// Add resize observer extractPageText(bookRef.current, rendition.current);
const resizeObserver = new ResizeObserver(() => {
if (bookRef.current && rendition.current) {
debouncedExtractText(bookRef.current, rendition.current);
}
});
// Observe the container element // Add resize observer
const container = document.querySelector('.epub-container'); const resizeObserver = new ResizeObserver(() => {
if (container) { if (bookRef.current && rendition.current) {
resizeObserver.observe(container); debouncedExtractText(bookRef.current, rendition.current);
} }
});
return () => { // Observe the container element
resizeObserver.disconnect(); const container = document.querySelector('.epub-container');
}; if (container) {
resizeObserver.observe(container);
} }
}, [extractPageText, debouncedExtractText, initialPrevLocLoad]);
return () => {
resizeObserver.disconnect();
};
}, [extractPageText, debouncedExtractText]);
// Register the location change handler // Register the location change handler
useEffect(() => { useEffect(() => {

View file

@ -27,7 +27,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const { const {
currentSentence, currentSentence,
stopAndPlayFromIndex, stopAndPlayFromIndex,
isProcessing isProcessing,
} = useTTS(); } = useTTS();
// PDF context // PDF context

View file

@ -2,16 +2,16 @@
import { Button } from '@headlessui/react'; import { Button } from '@headlessui/react';
export const Navigator = ({ currentPage, numPages, skipToPage }: { export const Navigator = ({ currentPage, numPages, skipToLocation }: {
currentPage: number; currentPage: number;
numPages: number | undefined; numPages: number | undefined;
skipToPage: (page: number) => void; skipToLocation: (location: string | number) => void;
}) => { }) => {
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{/* Page back */} {/* Page back */}
<Button <Button
onClick={() => skipToPage(currentPage - 1)} onClick={() => skipToLocation(currentPage - 1)}
disabled={currentPage <= 1} disabled={currentPage <= 1}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50" className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
aria-label="Previous page" aria-label="Previous page"
@ -30,7 +30,7 @@ export const Navigator = ({ currentPage, numPages, skipToPage }: {
{/* Page forward */} {/* Page forward */}
<Button <Button
onClick={() => skipToPage(currentPage + 1)} onClick={() => skipToLocation(currentPage + 1)}
disabled={currentPage >= (numPages || 1)} disabled={currentPage >= (numPages || 1)}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50" className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
aria-label="Next page" aria-label="Next page"

View file

@ -26,7 +26,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
setSpeedAndRestart, setSpeedAndRestart,
setVoiceAndRestart, setVoiceAndRestart,
availableVoices, availableVoices,
skipToPage, skipToLocation,
} = useTTS(); } = useTTS();
return ( return (
@ -36,8 +36,13 @@ export default function TTSPlayer({ currentPage, numPages }: {
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} /> <SpeedControl setSpeedAndRestart={setSpeedAndRestart} />
{/* Page Navigation */} {/* Page Navigation */}
{currentPage && numPages {currentPage && numPages && (
&& <Navigator currentPage={currentPage} numPages={numPages} skipToPage={skipToPage} />} <Navigator
currentPage={currentPage}
numPages={numPages}
skipToLocation={skipToLocation}
/>
)}
{/* Playback Controls */} {/* Playback Controls */}
<Button <Button

View file

@ -3,22 +3,10 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { getItem, indexedDBService, setItem } from '@/utils/indexedDB'; import { getItem, indexedDBService, setItem } from '@/utils/indexedDB';
/** Represents the possible view types for document display */
export type ViewType = 'single' | 'dual' | 'scroll'; export type ViewType = 'single' | 'dual' | 'scroll';
interface ConfigContextType {
apiKey: string;
baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
voice: string;
skipBlank: boolean;
epubTheme: boolean; // Add this line
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
isLoading: boolean;
isDBReady: boolean;
}
// Add this type to help with type safety /** Configuration values for the application */
type ConfigValues = { type ConfigValues = {
apiKey: string; apiKey: string;
baseUrl: string; baseUrl: string;
@ -26,11 +14,32 @@ type ConfigValues = {
voiceSpeed: number; voiceSpeed: number;
voice: string; voice: string;
skipBlank: boolean; skipBlank: boolean;
epubTheme: boolean; // Add this line epubTheme: boolean;
}; };
/** Interface defining the configuration context shape and functionality */
interface ConfigContextType {
apiKey: string;
baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
voice: string;
skipBlank: boolean;
epubTheme: boolean;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
isLoading: boolean;
isDBReady: boolean;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined); const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
/**
* Provider component for application configuration
* Manages global configuration state and persistence
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
*/
export function ConfigProvider({ children }: { children: ReactNode }) { export function ConfigProvider({ children }: { children: ReactNode }) {
// Config state // Config state
const [apiKey, setApiKey] = useState<string>(''); const [apiKey, setApiKey] = useState<string>('');
@ -108,6 +117,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
initializeDB(); initializeDB();
}, []); }, []);
/**
* Updates multiple configuration values simultaneously
* @param {Partial<{apiKey: string; baseUrl: string}>} newConfig - Object containing new config values
*/
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => { const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
try { try {
if (newConfig.apiKey !== undefined) { if (newConfig.apiKey !== undefined) {
@ -124,6 +137,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
} }
}; };
/**
* Updates a single configuration value by key
* @param {K} key - The configuration key to update
* @param {ConfigValues[K]} value - The new value for the configuration
*/
const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => { const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
try { try {
await setItem(key, value.toString()); await setItem(key, value.toString());
@ -175,6 +193,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
); );
} }
/**
* Custom hook to consume the configuration context
* @returns {ConfigContextType} The configuration context value
* @throws {Error} When used outside of ConfigProvider
*/
export function useConfig() { export function useConfig() {
const context = useContext(ConfigContext); const context = useContext(ConfigContext);
if (context === undefined) { if (context === undefined) {

View file

@ -26,6 +26,12 @@ interface EPUBContextType {
const EPUBContext = createContext<EPUBContextType | undefined>(undefined); const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
/**
* Provider component for EPUB functionality
* Manages the state and operations for EPUB document handling
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
*/
export function EPUBProvider({ children }: { children: ReactNode }) { export function EPUBProvider({ children }: { children: ReactNode }) {
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop } = useTTS(); const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop } = useTTS();
@ -35,7 +41,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const [currDocText, setCurrDocText] = useState<string>(); const [currDocText, setCurrDocText] = useState<string>();
/** /**
* Clears the current document state * Clears all current document state and stops any active TTS
*/ */
const clearCurrDoc = useCallback(() => { const clearCurrDoc = useCallback(() => {
setCurrDocData(undefined); setCurrDocData(undefined);
@ -46,7 +52,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}, [setCurrDocPages, stop]); }, [setCurrDocPages, stop]);
/** /**
* Sets the current document based on its ID * Sets the current document based on its ID by fetching from IndexedDB
* @param {string} id - The unique identifier of the document
* @throws {Error} When document data is empty or retrieval fails
*/ */
const setCurrentDocument = useCallback(async (id: string): Promise<void> => { const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
try { try {
@ -73,6 +81,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
/** /**
* Extracts text content from the current EPUB page/location * Extracts text content from the current EPUB page/location
* @param {Book} book - The EPUB.js Book instance
* @param {Rendition} rendition - The EPUB.js Rendition instance
* @returns {Promise<string>} The extracted text content
*/ */
const extractPageText = useCallback(async (book: Book, rendition: Rendition): Promise<string> => { const extractPageText = useCallback(async (book: Book, rendition: Rendition): Promise<string> => {
try { try {
@ -127,7 +138,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
/** /**
* Custom hook to consume the EPUB context * Custom hook to consume the EPUB context
* Ensures the context is used within a provider * @returns {EPUBContextType} The EPUB context value
* @throws {Error} When used outside of EPUBProvider
*/ */
export function useEPUB() { export function useEPUB() {
const context = useContext(EPUBContext); const context = useContext(EPUBContext);

View file

@ -71,6 +71,9 @@ const PDFContext = createContext<PDFContextType | undefined>(undefined);
* *
* Main provider component that manages PDF state and functionality. * Main provider component that manages PDF state and functionality.
* Handles document loading, text processing, and integration with TTS. * Handles document loading, text processing, and integration with TTS.
*
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
*/ */
export function PDFProvider({ children }: { children: ReactNode }) { export function PDFProvider({ children }: { children: ReactNode }) {
const { setText: setTTSText, stop, currDocPageNumber: currDocPage, currDocPages, setCurrDocPages } = useTTS(); const { setText: setTTSText, stop, currDocPageNumber: currDocPage, currDocPages, setCurrDocPages } = useTTS();
@ -82,9 +85,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>(); const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
/** /**
* Handles successful document load * Handles successful PDF document load
* *
* @param {Object} param0 - Object containing numPages * @param {PDFDocumentProxy} pdf - The loaded PDF document proxy object
*/ */
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => { const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
console.log('Document loaded:', pdf.numPages); console.log('Document loaded:', pdf.numPages);
@ -94,6 +97,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
/** /**
* Loads and processes text from the current document page * Loads and processes text from the current document page
* Extracts text from the PDF and updates both document text and TTS text states
*
* @returns {Promise<void>}
*/ */
const loadCurrDocText = useCallback(async () => { const loadCurrDocText = useCallback(async () => {
try { try {
@ -108,7 +114,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}, [pdfDocument, currDocPage, setTTSText]); }, [pdfDocument, currDocPage, setTTSText]);
/** /**
* Updates the current document text when the page changes * Effect hook to update document text when the page changes
* Triggers text extraction and processing when either the document URL or page changes
*/ */
useEffect(() => { useEffect(() => {
if (currDocURL) { if (currDocURL) {
@ -118,8 +125,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
/** /**
* Sets the current document based on its ID * Sets the current document based on its ID
* Retrieves document from IndexedDB and converts it to a viewable URL
* *
* @param {string} id - The ID of the document to set * @param {string} id - The unique identifier of the document to set
* @returns {Promise<void>}
*/ */
const setCurrentDocument = useCallback(async (id: string): Promise<void> => { const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
try { try {
@ -136,6 +145,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
/** /**
* Clears the current document state * Clears the current document state
* Resets all document-related states and stops any ongoing TTS playback
*/ */
const clearCurrDoc = useCallback(() => { const clearCurrDoc = useCallback(() => {
setCurrDocName(undefined); setCurrDocName(undefined);
@ -187,7 +197,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
* Ensures the context is used within a provider * Ensures the context is used within a provider
* *
* @throws {Error} If used outside of PDFProvider * @throws {Error} If used outside of PDFProvider
* @returns {PDFContextType} The PDF context value * @returns {PDFContextType} The PDF context value containing all PDF-related functionality
*/ */
export function usePDF() { export function usePDF() {
const context = useContext(PDFContext); const context = useContext(PDFContext);

View file

@ -71,19 +71,21 @@ interface TTSContextType {
setCurrDocPages: (num: number | undefined) => void; setCurrDocPages: (num: number | undefined) => void;
setSpeedAndRestart: (speed: number) => void; setSpeedAndRestart: (speed: number) => void;
setVoiceAndRestart: (voice: string) => void; setVoiceAndRestart: (voice: string) => void;
skipToPage: (page: number) => void; skipToLocation: (location: string | number) => void;
setEPUBPageInChapter: (page: string | number, total: number, chapter: string | number) => void; // Add this line registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation
registerLocationChangeHandler: (handler: (location: string | number, initial?: boolean) => void) => void; setIsEPUB: (isEPUB: boolean) => void;
} }
// Create the context // Create the context
const TTSContext = createContext<TTSContextType | undefined>(undefined); const TTSContext = createContext<TTSContextType | undefined>(undefined);
/** /**
* TTSProvider Component
*
* Main provider component that manages the TTS state and functionality. * Main provider component that manages the TTS state and functionality.
* Handles initialization of OpenAI client, audio context, and media session. * Handles initialization of OpenAI client, audio context, and media session.
*
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
* @returns {JSX.Element} TTSProvider component
*/ */
export function TTSProvider({ children }: { children: ReactNode }) { export function TTSProvider({ children }: { children: ReactNode }) {
// Configuration context consumption // Configuration context consumption
@ -106,10 +108,15 @@ export function TTSProvider({ children }: { children: ReactNode }) {
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl);
// Add ref for location change handler // Add ref for location change handler
const locationChangeHandlerRef = useRef<((location: string | number, initial?: boolean) => void) | null>(null); const locationChangeHandlerRef = useRef<((location: string | number) => void) | null>(null);
// Add method to register location change handler /**
const registerLocationChangeHandler = useCallback((handler: (location: string | number, initial?: boolean) => void) => { * Registers a handler function for location changes in EPUB documents
* This is only used for EPUB documents to handle chapter navigation
*
* @param {Function} handler - Function to handle location changes
*/
const registerLocationChangeHandler = useCallback((handler: (location: string | number) => void) => {
locationChangeHandlerRef.current = handler; locationChangeHandlerRef.current = handler;
}, []); }, []);
@ -140,68 +147,97 @@ export function TTSProvider({ children }: { children: ReactNode }) {
* Changes the current page by a specified amount * Changes the current page by a specified amount
* *
* @param {number} [num=1] - The number of pages to increment by * @param {number} [num=1] - The number of pages to increment by
* @returns {void}
*/ */
const incrementPage = useCallback((num = 1) => { const incrementPage = useCallback((num = 1) => {
setNextPageLoading(true); setNextPageLoading(true);
setCurrDocPage(currDocPageNumber + num); setCurrDocPage(currDocPageNumber + num);
}, [currDocPageNumber]); }, [currDocPageNumber]);
/**
* Processes text through the NLP API to split it into sentences
*
* @param {string} text - The text to be processed
* @returns {Promise<string[]>} Array of processed sentences
*/
const processTextToSentences = useCallback(async (text: string): Promise<string[]> => {
const response = await fetch('/api/nlp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!response.ok) {
throw new Error('Failed to process text');
}
const { sentences } = await response.json();
return sentences;
}, []);
/**
* Handles blank text sections based on document type
*
* @param {string[]} sentences - Array of processed sentences
* @returns {boolean} - True if blank section was handled
*/
const handleBlankSection = useCallback((sentences: string[]): boolean => {
if (!isPlaying || !skipBlank || sentences.length > 0) {
return false;
}
if (isEPUB && locationChangeHandlerRef.current) {
locationChangeHandlerRef.current('next');
toast.success('Skipping blank section', {
id: `epub-section-skip`,
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 1000,
position: 'top-center',
});
return true;
}
if (currDocPageNumber < currDocPages!) {
incrementPage();
toast.success(`Skipping blank page ${currDocPageNumber}`, {
id: `page-${currDocPageNumber}`,
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 1000,
position: 'top-center',
});
return true;
}
return false;
}, [isPlaying, skipBlank, isEPUB, currDocPageNumber, currDocPages, incrementPage]);
/** /**
* Sets the current text and splits it into sentences * Sets the current text and splits it into sentences
*
* @param {string} text - The text to be processed
*/ */
const setText = useCallback((text: string) => { const setText = useCallback((text: string) => {
console.log('Setting page text:', text); console.log('Setting page text:', text);
fetch('/api/nlp', { processTextToSentences(text)
method: 'POST', .then(newSentences => {
headers: { 'Content-Type': 'application/json' }, if (handleBlankSection(newSentences)) {
body: JSON.stringify({ text }), return;
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to process text');
}
return response.json();
})
.then(({ sentences: newSentences }) => {
// If skipBlank is enabled and there's no text
if (isPlaying && skipBlank && newSentences.length === 0) {
if (isEPUB && locationChangeHandlerRef.current) {
locationChangeHandlerRef.current('next');
toast.success('Skipping blank section', {
id: `epub-section-skip`,
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 1000,
position: 'top-center',
});
return;
} else if (currDocPageNumber < currDocPages!) {
incrementPage();
toast.success(`Skipping blank page ${currDocPageNumber}`, {
id: `page-${currDocPageNumber}`,
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 1000,
position: 'top-center',
});
return;
}
} }
setSentences(newSentences); setSentences(newSentences);
@ -217,12 +253,10 @@ export function TTSProvider({ children }: { children: ReactNode }) {
duration: 3000, duration: 3000,
}); });
}); });
}, [isPlaying, skipBlank, currDocPageNumber, currDocPages, incrementPage, isEPUB]); }, [processTextToSentences, handleBlankSection]);
/** /**
* Stops the current audio playback and clears the active Howl instance * Stops the current audio playback and clears the active Howl instance
*
* @returns {void}
*/ */
const abortAudio = useCallback(() => { const abortAudio = useCallback(() => {
if (activeHowl) { if (activeHowl) {
@ -233,8 +267,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Toggles the playback state between playing and paused * Toggles the playback state between playing and paused
*
* @returns {void}
*/ */
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
setIsPlaying((prev) => { setIsPlaying((prev) => {
@ -248,61 +280,28 @@ export function TTSProvider({ children }: { children: ReactNode }) {
}, [abortAudio]); }, [abortAudio]);
/** /**
* Navigates to a specific page in the document * Navigates to a specific location in the document
* Works for both PDF pages and EPUB locations
* *
* @param {number} page - The target page number * @param {string | number} location - The target location to navigate to
* @returns {void}
*/ */
const skipToPage = useCallback((page: number) => { const skipToLocation = useCallback((location: string | number) => {
abortAudio();
setIsPlaying(false);
setNextPageLoading(true); setNextPageLoading(true);
setCurrentIndex(0);
setCurrDocPage(page);
}, [abortAudio]);
/**
* Navigates to a specific location in the EPUB document
* Similar to skipToPage but for EPUB locations
*/
const [currChapter, setCurrChapter] = useState<string | number>('');
const setEPUBPageInChapter = useCallback((page: string | number, total: number, chapter: string | number) => {
const alreadyPlaying = isPlaying;
const isNewChapter = chapter !== currChapter;
// Mark that we're in EPUB mode
setIsEPUB(true);
// Update chapter info
if (isNewChapter) {
setCurrDocPages(total);
setCurrChapter(chapter);
console.log('Changed to chapter:', chapter);
}
// Reset state for new content // Reset state for new content
abortAudio(); abortAudio();
setIsPlaying(false); setIsPlaying(false);
setNextPageLoading(true);
setCurrentIndex(0); setCurrentIndex(0);
setSentences([]); setSentences([]);
// Update current page // Update current page/location
setCurrDocPage(Number(page)); setCurrDocPage(location);
}, [abortAudio]);
// Resume playback if it was playing before
// Only auto-resume if this was triggered by automatic navigation (not manual page turns)
if (alreadyPlaying) {
setIsPlaying(true);
}
}, [abortAudio, currChapter, isPlaying]);
/** /**
* Moves to the next or previous sentence * Moves to the next or previous sentence
* *
* @param {boolean} [backwards=false] - Whether to move backwards * @param {boolean} [backwards=false] - Whether to move backwards
* @returns {Promise<void>}
*/ */
const advance = useCallback(async (backwards = false) => { const advance = useCallback(async (backwards = false) => {
const nextIndex = currentIndex + (backwards ? -1 : 1); const nextIndex = currentIndex + (backwards ? -1 : 1);
@ -345,8 +344,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Moves forward one sentence in the text * Moves forward one sentence in the text
*
* @returns {void}
*/ */
const skipForward = useCallback(() => { const skipForward = useCallback(() => {
setIsProcessing(true); setIsProcessing(true);
@ -360,8 +357,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Moves backward one sentence in the text * Moves backward one sentence in the text
*
* @returns {void}
*/ */
const skipBackward = useCallback(() => { const skipBackward = useCallback(() => {
setIsProcessing(true); setIsProcessing(true);
@ -375,8 +370,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Updates the voice and speed settings from the configuration * Updates the voice and speed settings from the configuration
*
* @returns {void}
*/ */
const updateVoiceAndSpeed = useCallback(() => { const updateVoiceAndSpeed = useCallback(() => {
setVoice(configVoice); setVoice(configVoice);
@ -401,7 +394,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Generates and plays audio for the current sentence * Generates and plays audio for the current sentence
* *
* @returns {Promise<void>} * @param {string} sentence - The sentence to generate audio for
* @returns {Promise<AudioBuffer | undefined>} The generated audio buffer
*/ */
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => { const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
// Check if the audio is already cached // Check if the audio is already cached
@ -448,7 +442,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Processes and plays the current sentence * Processes and plays the current sentence
* *
* @returns {Promise<void>} * @param {string} sentence - The sentence to process
* @param {boolean} [preload=false] - Whether this is a preload request
* @returns {Promise<string>} The URL of the processed audio
*/ */
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => { const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
if (isProcessing && !preload) throw new Error('Audio is already being processed'); if (isProcessing && !preload) throw new Error('Audio is already being processed');
@ -465,7 +461,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Plays the current sentence with Howl * Plays the current sentence with Howl
* *
* @returns {Promise<void>} * @param {string} sentence - The sentence to play
*/ */
const playSentenceWithHowl = useCallback(async (sentence: string) => { const playSentenceWithHowl = useCallback(async (sentence: string) => {
try { try {
@ -530,8 +526,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Preloads the next sentence's audio * Preloads the next sentence's audio
*
* @returns {void}
*/ */
const preloadNextAudio = useCallback(() => { const preloadNextAudio = useCallback(() => {
try { try {
@ -545,8 +539,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Plays the current sentence's audio * Plays the current sentence's audio
*
* @returns {Promise<void>}
*/ */
const playAudio = useCallback(async () => { const playAudio = useCallback(async () => {
await playSentenceWithHowl(sentences[currentIndex]); await playSentenceWithHowl(sentences[currentIndex]);
@ -585,9 +577,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
]); ]);
/** /**
* Stops the current audio playback * Stops the current audio playback and resets all state
*
* @returns {void}
*/ */
const stop = useCallback(() => { const stop = useCallback(() => {
// Cancel any ongoing request // Cancel any ongoing request
@ -596,7 +586,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
setIsPlaying(false); setIsPlaying(false);
setCurrentIndex(0); setCurrentIndex(0);
setSentences([]); setSentences([]);
setCurrChapter('');
setCurrDocPage(1); setCurrDocPage(1);
setCurrDocPages(undefined); setCurrDocPages(undefined);
setNextPageLoading(false); setNextPageLoading(false);
@ -608,7 +597,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
* Stops the current audio playback and starts playing from a specified index * Stops the current audio playback and starts playing from a specified index
* *
* @param {number} index - The index to start playing from * @param {number} index - The index to start playing from
* @returns {void}
*/ */
const stopAndPlayFromIndex = useCallback((index: number) => { const stopAndPlayFromIndex = useCallback((index: number) => {
abortAudio(); abortAudio();
@ -621,7 +609,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
* Sets the speed and restarts the playback * Sets the speed and restarts the playback
* *
* @param {number} newSpeed - The new speed to set * @param {number} newSpeed - The new speed to set
* @returns {void}
*/ */
const setSpeedAndRestart = useCallback((newSpeed: number) => { const setSpeedAndRestart = useCallback((newSpeed: number) => {
setSpeed(newSpeed); setSpeed(newSpeed);
@ -640,7 +627,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
* Sets the voice and restarts the playback * Sets the voice and restarts the playback
* *
* @param {string} newVoice - The new voice to set * @param {string} newVoice - The new voice to set
* @returns {void}
*/ */
const setVoiceAndRestart = useCallback((newVoice: string) => { const setVoiceAndRestart = useCallback((newVoice: string) => {
setVoice(newVoice); setVoice(newVoice);
@ -675,9 +661,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
setCurrDocPages, setCurrDocPages,
setSpeedAndRestart, setSpeedAndRestart,
setVoiceAndRestart, setVoiceAndRestart,
skipToPage, skipToLocation,
setEPUBPageInChapter, // Add this line
registerLocationChangeHandler, registerLocationChangeHandler,
setIsEPUB
}), [ }), [
isPlaying, isPlaying,
isProcessing, isProcessing,
@ -696,9 +682,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
setCurrDocPages, setCurrDocPages,
setSpeedAndRestart, setSpeedAndRestart,
setVoiceAndRestart, setVoiceAndRestart,
skipToPage, skipToLocation,
setEPUBPageInChapter, // Add this line
registerLocationChangeHandler, registerLocationChangeHandler,
setIsEPUB
]); ]);
// Use media session hook // Use media session hook
@ -714,9 +700,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
getLastDocumentLocation(id as string).then(lastLocation => { getLastDocumentLocation(id as string).then(lastLocation => {
if (lastLocation) { if (lastLocation) {
console.log('Setting last location:', lastLocation); console.log('Setting last location:', lastLocation);
setCurrDocPage(lastLocation);
if (locationChangeHandlerRef.current) { if (locationChangeHandlerRef.current) {
locationChangeHandlerRef.current(lastLocation, true); locationChangeHandlerRef.current(lastLocation);
} }
} }
}); });
@ -739,6 +724,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
/** /**
* Custom hook to consume the TTS context * Custom hook to consume the TTS context
* Ensures the context is used within a provider * Ensures the context is used within a provider
*
* @throws {Error} If used outside of TTSProvider
* @returns {TTSContextType} The TTS context value
*/ */
export function useTTS() { export function useTTS() {
const context = useContext(TTSContext); const context = useContext(TTSContext);