Refactors + epub location fixes
This commit is contained in:
parent
9ae6dd9e82
commit
0ce14f3aec
9 changed files with 239 additions and 205 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import nlp from 'compromise';
|
||||
|
||||
const MAX_BLOCK_LENGTH = 350;
|
||||
const MAX_BLOCK_LENGTH = 300;
|
||||
|
||||
const preprocessSentenceForAudio = (text: string): string => {
|
||||
return text
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEPUB } from '@/contexts/EPUBContext';
|
||||
|
|
@ -24,18 +24,29 @@ interface EPUBViewerProps {
|
|||
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||
const { id } = useParams();
|
||||
const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB();
|
||||
const { setEPUBPageInChapter, registerLocationChangeHandler } = useTTS();
|
||||
const { skipToLocation, registerLocationChangeHandler, setIsEPUB } = useTTS();
|
||||
const { epubTheme } = useConfig();
|
||||
const bookRef = useRef<Book | null>(null);
|
||||
const rendition = useRef<Rendition | undefined>(undefined);
|
||||
const toc = useRef<NavItem[]>([]);
|
||||
const locationRef = useRef<string | number>(currDocPage);
|
||||
const [initialPrevLocLoad, setInitialPrevLocLoad] = useState(false);
|
||||
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;
|
||||
// Handle special 'next' and 'prev' cases, which
|
||||
|
||||
// Handle special 'next' and 'prev' cases
|
||||
if (location === 'next' && rendition.current) {
|
||||
rendition.current.next();
|
||||
return;
|
||||
|
|
@ -45,33 +56,18 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
|
||||
const { displayed, href } = rendition.current.location.start;
|
||||
const chapter = toc.current.find((item) => item.href === href);
|
||||
|
||||
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());
|
||||
}
|
||||
// Save the location to IndexedDB if not initial
|
||||
if (id && locationRef.current !== 1) {
|
||||
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
|
||||
if (initial) {
|
||||
rendition.current.display(location.toString()).then(() => {
|
||||
setInitialPrevLocLoad(true);
|
||||
});
|
||||
} else {
|
||||
locationRef.current = location;
|
||||
extractPageText(bookRef.current, rendition.current);
|
||||
}
|
||||
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
|
||||
const debouncedExtractText = useMemo(() => {
|
||||
|
|
@ -86,27 +82,27 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
|
||||
// Load the initial location and setup resize handler
|
||||
useEffect(() => {
|
||||
if (bookRef.current && rendition.current) {
|
||||
extractPageText(bookRef.current, rendition.current);
|
||||
if (!bookRef.current || !rendition.current || isEPUBSetOnce.current) return;
|
||||
|
||||
// Add resize observer
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (bookRef.current && rendition.current) {
|
||||
debouncedExtractText(bookRef.current, rendition.current);
|
||||
}
|
||||
});
|
||||
extractPageText(bookRef.current, rendition.current);
|
||||
|
||||
// Observe the container element
|
||||
const container = document.querySelector('.epub-container');
|
||||
if (container) {
|
||||
resizeObserver.observe(container);
|
||||
// Add resize observer
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (bookRef.current && rendition.current) {
|
||||
debouncedExtractText(bookRef.current, rendition.current);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
// Observe the container element
|
||||
const container = document.querySelector('.epub-container');
|
||||
if (container) {
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
}, [extractPageText, debouncedExtractText, initialPrevLocLoad]);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [extractPageText, debouncedExtractText]);
|
||||
|
||||
// Register the location change handler
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
const {
|
||||
currentSentence,
|
||||
stopAndPlayFromIndex,
|
||||
isProcessing
|
||||
isProcessing,
|
||||
} = useTTS();
|
||||
|
||||
// PDF context
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
export const Navigator = ({ currentPage, numPages, skipToPage }: {
|
||||
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||
currentPage: number;
|
||||
numPages: number | undefined;
|
||||
skipToPage: (page: number) => void;
|
||||
skipToLocation: (location: string | number) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Page back */}
|
||||
<Button
|
||||
onClick={() => skipToPage(currentPage - 1)}
|
||||
onClick={() => skipToLocation(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"
|
||||
aria-label="Previous page"
|
||||
|
|
@ -30,7 +30,7 @@ export const Navigator = ({ currentPage, numPages, skipToPage }: {
|
|||
|
||||
{/* Page forward */}
|
||||
<Button
|
||||
onClick={() => skipToPage(currentPage + 1)}
|
||||
onClick={() => skipToLocation(currentPage + 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"
|
||||
aria-label="Next page"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
setSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
availableVoices,
|
||||
skipToPage,
|
||||
skipToLocation,
|
||||
} = useTTS();
|
||||
|
||||
return (
|
||||
|
|
@ -36,8 +36,13 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} />
|
||||
|
||||
{/* Page Navigation */}
|
||||
{currentPage && numPages
|
||||
&& <Navigator currentPage={currentPage} numPages={numPages} skipToPage={skipToPage} />}
|
||||
{currentPage && numPages && (
|
||||
<Navigator
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
skipToLocation={skipToLocation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Playback Controls */}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -3,22 +3,10 @@
|
|||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { getItem, indexedDBService, setItem } from '@/utils/indexedDB';
|
||||
|
||||
/** Represents the possible view types for document display */
|
||||
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 = {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
|
|
@ -26,11 +14,32 @@ type ConfigValues = {
|
|||
voiceSpeed: number;
|
||||
voice: string;
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
// Config state
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
|
|
@ -108,6 +117,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
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 }>) => {
|
||||
try {
|
||||
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]) => {
|
||||
try {
|
||||
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() {
|
||||
const context = useContext(ConfigContext);
|
||||
if (context === undefined) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ interface EPUBContextType {
|
|||
|
||||
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 }) {
|
||||
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop } = useTTS();
|
||||
|
||||
|
|
@ -35,7 +41,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
|
||||
/**
|
||||
* Clears the current document state
|
||||
* Clears all current document state and stops any active TTS
|
||||
*/
|
||||
const clearCurrDoc = useCallback(() => {
|
||||
setCurrDocData(undefined);
|
||||
|
|
@ -46,7 +52,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}, [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> => {
|
||||
try {
|
||||
|
|
@ -73,6 +81,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* 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> => {
|
||||
try {
|
||||
|
|
@ -127,7 +138,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* 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() {
|
||||
const context = useContext(EPUBContext);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
|||
*
|
||||
* Main provider component that manages PDF state and functionality.
|
||||
* 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 }) {
|
||||
const { setText: setTTSText, stop, currDocPageNumber: currDocPage, currDocPages, setCurrDocPages } = useTTS();
|
||||
|
|
@ -82,9 +85,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
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) => {
|
||||
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
|
||||
* Extracts text from the PDF and updates both document text and TTS text states
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const loadCurrDocText = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -108,7 +114,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
}, [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(() => {
|
||||
if (currDocURL) {
|
||||
|
|
@ -118,8 +125,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* 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> => {
|
||||
try {
|
||||
|
|
@ -136,6 +145,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* Clears the current document state
|
||||
* Resets all document-related states and stops any ongoing TTS playback
|
||||
*/
|
||||
const clearCurrDoc = useCallback(() => {
|
||||
setCurrDocName(undefined);
|
||||
|
|
@ -187,7 +197,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
* Ensures the context is used within a provider
|
||||
*
|
||||
* @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() {
|
||||
const context = useContext(PDFContext);
|
||||
|
|
|
|||
|
|
@ -71,19 +71,21 @@ interface TTSContextType {
|
|||
setCurrDocPages: (num: number | undefined) => void;
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
setVoiceAndRestart: (voice: string) => void;
|
||||
skipToPage: (page: number) => void;
|
||||
setEPUBPageInChapter: (page: string | number, total: number, chapter: string | number) => void; // Add this line
|
||||
registerLocationChangeHandler: (handler: (location: string | number, initial?: boolean) => void) => void;
|
||||
skipToLocation: (location: string | number) => void;
|
||||
registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation
|
||||
setIsEPUB: (isEPUB: boolean) => void;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* TTSProvider Component
|
||||
*
|
||||
* Main provider component that manages the TTS state and functionality.
|
||||
* 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 }) {
|
||||
// Configuration context consumption
|
||||
|
|
@ -106,10 +108,15 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl);
|
||||
|
||||
// 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;
|
||||
}, []);
|
||||
|
||||
|
|
@ -140,68 +147,97 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
* Changes the current page by a specified amount
|
||||
*
|
||||
* @param {number} [num=1] - The number of pages to increment by
|
||||
* @returns {void}
|
||||
*/
|
||||
const incrementPage = useCallback((num = 1) => {
|
||||
setNextPageLoading(true);
|
||||
setCurrDocPage(currDocPageNumber + num);
|
||||
}, [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
|
||||
*
|
||||
* @param {string} text - The text to be processed
|
||||
*/
|
||||
const setText = useCallback((text: string) => {
|
||||
console.log('Setting page text:', text);
|
||||
|
||||
fetch('/api/nlp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
.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;
|
||||
}
|
||||
processTextToSentences(text)
|
||||
.then(newSentences => {
|
||||
if (handleBlankSection(newSentences)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSentences(newSentences);
|
||||
|
|
@ -217,12 +253,10 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
duration: 3000,
|
||||
});
|
||||
});
|
||||
}, [isPlaying, skipBlank, currDocPageNumber, currDocPages, incrementPage, isEPUB]);
|
||||
}, [processTextToSentences, handleBlankSection]);
|
||||
|
||||
/**
|
||||
* Stops the current audio playback and clears the active Howl instance
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const abortAudio = useCallback(() => {
|
||||
if (activeHowl) {
|
||||
|
|
@ -233,8 +267,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* Toggles the playback state between playing and paused
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const togglePlay = useCallback(() => {
|
||||
setIsPlaying((prev) => {
|
||||
|
|
@ -248,61 +280,28 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
}, [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
|
||||
* @returns {void}
|
||||
* @param {string | number} location - The target location to navigate to
|
||||
*/
|
||||
const skipToPage = useCallback((page: number) => {
|
||||
abortAudio();
|
||||
setIsPlaying(false);
|
||||
const skipToLocation = useCallback((location: string | number) => {
|
||||
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
|
||||
abortAudio();
|
||||
setIsPlaying(false);
|
||||
setNextPageLoading(true);
|
||||
setCurrentIndex(0);
|
||||
setSentences([]);
|
||||
|
||||
// Update current page
|
||||
setCurrDocPage(Number(page));
|
||||
|
||||
// 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]);
|
||||
// Update current page/location
|
||||
setCurrDocPage(location);
|
||||
}, [abortAudio]);
|
||||
|
||||
/**
|
||||
* Moves to the next or previous sentence
|
||||
*
|
||||
* @param {boolean} [backwards=false] - Whether to move backwards
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const advance = useCallback(async (backwards = false) => {
|
||||
const nextIndex = currentIndex + (backwards ? -1 : 1);
|
||||
|
|
@ -345,8 +344,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* Moves forward one sentence in the text
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const skipForward = useCallback(() => {
|
||||
setIsProcessing(true);
|
||||
|
|
@ -360,8 +357,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* Moves backward one sentence in the text
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const skipBackward = useCallback(() => {
|
||||
setIsProcessing(true);
|
||||
|
|
@ -375,8 +370,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* Updates the voice and speed settings from the configuration
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateVoiceAndSpeed = useCallback(() => {
|
||||
setVoice(configVoice);
|
||||
|
|
@ -401,7 +394,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
/**
|
||||
* 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> => {
|
||||
// Check if the audio is already cached
|
||||
|
|
@ -448,7 +442,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
/**
|
||||
* 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> => {
|
||||
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
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* @param {string} sentence - The sentence to play
|
||||
*/
|
||||
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
||||
try {
|
||||
|
|
@ -530,8 +526,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* Preloads the next sentence's audio
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const preloadNextAudio = useCallback(() => {
|
||||
try {
|
||||
|
|
@ -545,8 +539,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
/**
|
||||
* Plays the current sentence's audio
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const playAudio = useCallback(async () => {
|
||||
await playSentenceWithHowl(sentences[currentIndex]);
|
||||
|
|
@ -585,9 +577,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
]);
|
||||
|
||||
/**
|
||||
* Stops the current audio playback
|
||||
*
|
||||
* @returns {void}
|
||||
* Stops the current audio playback and resets all state
|
||||
*/
|
||||
const stop = useCallback(() => {
|
||||
// Cancel any ongoing request
|
||||
|
|
@ -596,7 +586,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
setIsPlaying(false);
|
||||
setCurrentIndex(0);
|
||||
setSentences([]);
|
||||
setCurrChapter('');
|
||||
setCurrDocPage(1);
|
||||
setCurrDocPages(undefined);
|
||||
setNextPageLoading(false);
|
||||
|
|
@ -608,7 +597,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
* Stops the current audio playback and starts playing from a specified index
|
||||
*
|
||||
* @param {number} index - The index to start playing from
|
||||
* @returns {void}
|
||||
*/
|
||||
const stopAndPlayFromIndex = useCallback((index: number) => {
|
||||
abortAudio();
|
||||
|
|
@ -621,7 +609,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
* Sets the speed and restarts the playback
|
||||
*
|
||||
* @param {number} newSpeed - The new speed to set
|
||||
* @returns {void}
|
||||
*/
|
||||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||
setSpeed(newSpeed);
|
||||
|
|
@ -640,7 +627,6 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
* Sets the voice and restarts the playback
|
||||
*
|
||||
* @param {string} newVoice - The new voice to set
|
||||
* @returns {void}
|
||||
*/
|
||||
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
||||
setVoice(newVoice);
|
||||
|
|
@ -675,9 +661,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
setCurrDocPages,
|
||||
setSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
skipToPage,
|
||||
setEPUBPageInChapter, // Add this line
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
setIsEPUB
|
||||
}), [
|
||||
isPlaying,
|
||||
isProcessing,
|
||||
|
|
@ -696,9 +682,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
setCurrDocPages,
|
||||
setSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
skipToPage,
|
||||
setEPUBPageInChapter, // Add this line
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
setIsEPUB
|
||||
]);
|
||||
|
||||
// Use media session hook
|
||||
|
|
@ -714,9 +700,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
|
|||
getLastDocumentLocation(id as string).then(lastLocation => {
|
||||
if (lastLocation) {
|
||||
console.log('Setting last location:', lastLocation);
|
||||
setCurrDocPage(lastLocation);
|
||||
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
|
||||
* Ensures the context is used within a provider
|
||||
*
|
||||
* @throws {Error} If used outside of TTSProvider
|
||||
* @returns {TTSContextType} The TTS context value
|
||||
*/
|
||||
export function useTTS() {
|
||||
const context = useContext(TTSContext);
|
||||
|
|
|
|||
Loading…
Reference in a new issue