openreader/src/contexts/TTSContext.tsx
Richard Roberson b21bdb3a21 feat(tts): add multi-provider TTS support with Deepinfra and custom OpenAI-compatible endpoints
Add comprehensive multi-provider TTS support enabling users to choose between OpenAI, Deepinfra, and custom OpenAI-compatible endpoints. Implement provider-specific voice management with automatic voice restoration per provider-model combination, and migrate package manager to pnpm for improved dependency handling.

Key changes:
- Add TTS provider selection (OpenAI, Deepinfra, custom-openai) in settings UI
- Implement provider-specific model and voice lists with dynamic fetching
- Add voice persistence per provider-model combination in savedVoices
- Support Deepinfra models: Kokoro-82M, Orpheus-3B, Sesame-1B with their voice libraries
- Migrate to pnpm with frozen lockfile for reproducible builds
- Update Docker configuration to use pnpm and Deepinfra API defaults
- Add migration logic for existing users to infer provider from stored baseUrl
- Update test helpers and Playwright configuration for Deepinfra API
- Add example docker-compose.yml with Kokoro-FastAPI integration

BREAKING CHANGE: Voice selection is now provider-model specific. Previously saved voices will be migrated to the new savedVoices structure, but users may need to reselect voices if switching providers.
2025-11-09 15:47:17 -07:00

1008 lines
No EOL
31 KiB
TypeScript

/**
* Text-to-Speech (TTS) Context Provider
*
* This module provides a React context for managing text-to-speech functionality.
* It handles audio playback, sentence processing, and integration with OpenAI's TTS API.
*
* Key features:
* - Audio playback control (play/pause/skip)
* - Sentence-by-sentence processing
* - Audio caching for better performance
* - Voice and speed control
* - Document navigation
*/
'use client';
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
useMemo,
ReactNode,
ReactElement
} from 'react';
import { Howl } from 'howler';
import toast from 'react-hot-toast';
import { useParams } from 'next/navigation';
import { useConfig } from '@/contexts/ConfigContext';
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, setLastDocumentLocation } from '@/utils/indexedDB';
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
import { withRetry } from '@/utils/audio';
import { processTextToSentences } from '@/utils/nlp';
// Media globals
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
/**
* Interface defining all available methods and properties in the TTS context
*/
interface TTSContextType {
// Playback state
isPlaying: boolean;
isProcessing: boolean;
currentSentence: string;
isBackgrounded: boolean; // Add this new property
// Navigation
currDocPage: string | number; // Change this to allow both types
currDocPageNumber: number; // For PDF
currDocPages: number | undefined;
// Voice settings
availableVoices: string[];
// Control functions
togglePlay: () => void;
skipForward: () => void;
skipBackward: () => void;
pause: () => void;
stop: () => void;
stopAndPlayFromIndex: (index: number) => void;
setText: (text: string, shouldPause?: boolean) => void;
setCurrDocPages: (num: number | undefined) => void;
setSpeedAndRestart: (speed: number) => void;
setAudioPlayerSpeedAndRestart: (speed: number) => void;
setVoiceAndRestart: (voice: string) => void;
skipToLocation: (location: string | number, shouldPause?: boolean) => 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);
/**
* 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 }): ReactElement {
// Configuration context consumption
const {
apiKey: openApiKey,
baseUrl: openApiBaseUrl,
isLoading: configIsLoading,
voiceSpeed,
audioPlayerSpeed,
voice: configVoice,
ttsProvider: configTTSProvider,
ttsModel: configTTSModel,
ttsInstructions: configTTSInstructions,
updateConfigKey,
skipBlank,
} = useConfig();
// Remove OpenAI client reference as it's no longer needed
const audioContext = useAudioContext();
const audioCache = useAudioCache(25);
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
// Add ref for location change handler
const locationChangeHandlerRef = useRef<((location: string | number) => void) | null>(null);
/**
* 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;
}, []);
// Get document ID from URL params
const { id } = useParams();
/**
* State Management
*/
const [isPlaying, setIsPlaying] = useState(false);
const [isEPUB, setIsEPUB] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [currDocPage, setCurrDocPage] = useState<string | number>(1);
const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
const [currDocPages, setCurrDocPages] = useState<number>();
const [sentences, setSentences] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
const [speed, setSpeed] = useState(voiceSpeed);
const [audioSpeed, setAudioSpeed] = useState(audioPlayerSpeed);
const [voice, setVoice] = useState(configVoice);
const [ttsModel, setTTSModel] = useState(configTTSModel);
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
// Track pending preload requests
const preloadRequests = useRef<Map<string, Promise<string>>>(new Map());
// Track active abort controllers for TTS requests
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
// Track if we're restoring from a saved position
const [pendingRestoreIndex, setPendingRestoreIndex] = useState<number | null>(null);
/**
* Processes text into sentences using the shared NLP utility
*
* @param {string} text - The text to be processed
* @returns {Promise<string[]>} Array of processed sentences
*/
const processTextToSentencesLocal = useCallback(async (text: string): Promise<string[]> => {
if (text.length < 1) {
return [];
}
// Use the shared utility directly instead of making an API call
return processTextToSentences(text);
}, []);
/**
* Stops the current audio playback and clears the active Howl instance
* @param {boolean} [clearPending=false] - Whether to clear pending requests
*/
const abortAudio = useCallback((clearPending = false) => {
if (activeHowl) {
activeHowl.stop();
activeHowl.unload(); // Ensure Howl instance is fully cleaned up
setActiveHowl(null);
}
if (clearPending) {
// Abort all active TTS requests
console.log('Aborting active TTS requests');
activeAbortControllers.current.forEach(controller => {
controller.abort();
});
activeAbortControllers.current.clear();
// Clear any pending preload requests
preloadRequests.current.clear();
}
}, [activeHowl]);
/**
* Pauses the current audio playback
* Used for external control of playback state
*/
const pause = useCallback(() => {
abortAudio();
setIsPlaying(false);
}, [abortAudio]);
/**
* Navigates to a specific location in the document
* Works for both PDF pages and EPUB locations
*
* @param {string | number} location - The target location to navigate to
* @param {boolean} keepPlaying - Whether to maintain playback state
*/
const skipToLocation = useCallback((location: string | number, shouldPause = false) => {
// Reset state for new content in correct order
abortAudio();
if (shouldPause) setIsPlaying(false);
setCurrentIndex(0);
setSentences([]);
setCurrDocPage(location);
}, [abortAudio]);
/**
* Moves to the next or previous sentence
*
* @param {boolean} [backwards=false] - Whether to move backwards
*/
const advance = useCallback(async (backwards = false) => {
const nextIndex = currentIndex + (backwards ? -1 : 1);
// Handle within current page bounds
if (nextIndex < sentences.length && nextIndex >= 0) {
console.log('isEPUB', isEPUB!);
setCurrentIndex(nextIndex);
return;
}
// For EPUB documents, always try to advance to next/prev section
if (isEPUB && locationChangeHandlerRef.current) {
locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev');
return;
}
// For PDFs and other documents, check page bounds
if (!isEPUB) {
// Handle next/previous page transitions
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
(nextIndex < 0 && currDocPageNumber > 1)) {
// Pass wasPlaying to maintain playback state during page turn
skipToLocation(currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1));
return;
}
// Handle end of document (PDF only)
if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) {
setIsPlaying(false);
}
}
}, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation]);
/**
* 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((text: string): boolean => {
if (!isPlaying || !skipBlank || text.length > 0) {
return false;
}
// Use advance to handle navigation for both EPUB and PDF
advance();
toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, {
id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`,
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 1000,
position: 'top-center',
});
return true;
}, [isPlaying, skipBlank, advance, isEPUB, currDocPageNumber]);
/**
* Sets the current text and splits it into sentences
*
* @param {string} text - The text to be processed
*/
const setText = useCallback((text: string, shouldPause = false) => {
// Check for blank section first
if (handleBlankSection(text)) return;
// Keep track of previous state and pause playback
const wasPlaying = isPlaying;
setIsPlaying(false);
abortAudio(true); // Clear pending requests since text is changing
setIsProcessing(true); // Set processing state before text processing starts
console.log('Setting text:', text);
processTextToSentencesLocal(text)
.then(newSentences => {
if (newSentences.length === 0) {
console.warn('No sentences found in text');
setIsProcessing(false);
return;
}
// Set all state updates in a predictable order
setSentences(newSentences);
// Check if we have a pending restore index for PDF
if (pendingRestoreIndex !== null && !isEPUB) {
const restoreIndex = Math.min(pendingRestoreIndex, newSentences.length - 1);
console.log(`Restoring sentence index: ${restoreIndex}`);
setCurrentIndex(restoreIndex);
setPendingRestoreIndex(null); // Clear the pending restore
} else {
setCurrentIndex(0);
}
setIsProcessing(false);
// Restore playback state if needed
if (!shouldPause && wasPlaying) {
setIsPlaying(true);
}
})
.catch(error => {
console.warn('Error processing text:', error);
setIsProcessing(false);
toast.error('Failed to process text', {
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 3000,
});
});
}, [isPlaying, handleBlankSection, abortAudio, processTextToSentencesLocal, pendingRestoreIndex, isEPUB]);
/**
* Toggles the playback state between playing and paused
*/
const togglePlay = useCallback(() => {
setIsPlaying((prev) => {
if (!prev) {
return true;
} else {
abortAudio();
return false;
}
});
}, [abortAudio]);
/**
* Moves forward one sentence in the text
*/
const skipForward = useCallback(async () => {
// Only show processing state if we're currently playing
if (isPlaying) {
setIsProcessing(true);
}
abortAudio(false); // Don't clear pending requests
await advance();
}, [isPlaying, abortAudio, advance]);
/**
* Moves backward one sentence in the text
*/
const skipBackward = useCallback(async () => {
// Only show processing state if we're currently playing
if (isPlaying) {
setIsProcessing(true);
}
abortAudio(false); // Don't clear pending requests
await advance(true);
}, [isPlaying, abortAudio, advance]);
/**
* Updates the voice and speed settings from the configuration
*/
const updateVoiceAndSpeed = useCallback(() => {
setVoice(configVoice);
setSpeed(voiceSpeed);
setAudioSpeed(audioPlayerSpeed);
}, [configVoice, voiceSpeed, audioPlayerSpeed]);
/**
* Initializes configuration and fetches available voices
*/
useEffect(() => {
if (!configIsLoading) {
fetchVoices();
updateVoiceAndSpeed();
setTTSModel(configTTSModel);
setTTSInstructions(configTTSInstructions);
}
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
/**
* Validates that the current voice is in the available voices list
* If voice is empty or invalid, use the first available voice (only in local state, don't save)
*/
useEffect(() => {
if (availableVoices.length > 0) {
if (!voice || !availableVoices.includes(voice)) {
console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`);
setVoice(availableVoices[0]);
// Don't save to config - just use it temporarily until user explicitly selects one
}
}
}, [availableVoices, voice]);
/**
* Generates and plays audio for the current sentence
*
* @param {string} sentence - The sentence to generate audio for
* @returns {Promise<AudioBuffer | undefined>} The generated audio buffer
*/
const getAudio = useCallback(async (sentence: string): Promise<ArrayBuffer | undefined> => {
// Check if the audio is already cached
const cachedAudio = audioCache.get(sentence);
if (cachedAudio) {
console.log('Using cached audio for sentence:', sentence.substring(0, 20));
return cachedAudio;
}
try {
console.log('Requesting audio for sentence:', sentence);
// Create an AbortController for this request
const controller = new AbortController();
activeAbortControllers.current.add(controller);
const arrayBuffer = await withRetry(
async () => {
const response = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-openai-key': openApiKey || '',
'x-openai-base-url': openApiBaseUrl || '',
'x-tts-provider': configTTSProvider,
},
body: JSON.stringify({
text: sentence,
voice: voice,
speed: speed,
model: ttsModel,
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error('Failed to generate audio');
}
return response.arrayBuffer();
},
{
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffFactor: 2
}
);
// Remove the controller once the request is complete
activeAbortControllers.current.delete(controller);
// Cache the array buffer
audioCache.set(sentence, arrayBuffer);
return arrayBuffer;
} catch (error) {
// Check if this was an abort error
if (error instanceof Error && error.name === 'AbortError') {
console.log('TTS request aborted:', sentence.substring(0, 20));
return;
}
setIsPlaying(false);
toast.error('Failed to generate audio. Server not responding.', {
id: 'tts-api-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 7000,
});
throw error;
}
}, [voice, speed, ttsModel, ttsInstructions, audioCache, openApiKey, openApiBaseUrl]);
/**
* Processes and plays the current sentence
*
* @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 (!audioContext) throw new Error('Audio context not initialized');
// Check if there's a pending preload request for this sentence
const pendingRequest = preloadRequests.current.get(sentence);
if (pendingRequest) {
console.log('Using pending preload request for:', sentence.substring(0, 20));
setIsProcessing(true); // Show processing state when using pending request
// If this is not a preload request, remove it from the pending map
if (!preload) {
preloadRequests.current.delete(sentence);
}
return pendingRequest;
}
// Only set processing state if not preloading
if (!preload) setIsProcessing(true);
// Create the audio processing promise
const processPromise = (async () => {
try {
const audioBuffer = await getAudio(sentence);
if (!audioBuffer) throw new Error('No audio data generated');
// Convert to base64 data URI
const bytes = new Uint8Array(audioBuffer);
const binaryString = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
const base64String = btoa(binaryString);
return `data:audio/mp3;base64,${base64String}`;
} catch (error) {
setIsProcessing(false);
throw error;
}
})();
// If this is a preload request, store it in the map
if (preload) {
preloadRequests.current.set(sentence, processPromise);
// Clean up the map entry once the promise resolves or rejects
processPromise.finally(() => {
preloadRequests.current.delete(sentence);
});
}
return processPromise;
}, [audioContext, getAudio]);
/**
* Plays the current sentence with Howl
*
* @param {string} sentence - The sentence to play
*/
const playSentenceWithHowl = useCallback(async (sentence: string) => {
if (!sentence) {
console.log('No sentence to play');
setIsProcessing(false);
return;
}
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000; // 1 second
const createHowl = async (retryCount = 0): Promise<Howl | null> => {
try {
// Get the processed audio data URI directly from processSentence
const audioDataUri = await processSentence(sentence);
if (!audioDataUri) {
throw new Error('No audio data generated');
}
// Force unload any previous Howl instance to free up resources
if (activeHowl) {
activeHowl.unload();
}
return new Howl({
src: [audioDataUri],
format: ['mp3', 'mpeg'],
html5: true,
preload: true,
pool: 5,
rate: audioSpeed,
onplay: () => {
setIsProcessing(false);
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing';
}
},
onplayerror: function(this: Howl, error) {
console.warn('Howl playback error:', error);
// Try to recover by forcing HTML5 audio mode
if (this.state() === 'loaded') {
this.unload();
this.once('load', () => {
this.play();
});
this.load();
}
},
onloaderror: async function(this: Howl, error) {
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
if (retryCount < MAX_RETRIES) {
// Calculate exponential backoff delay
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
console.log(`Retrying in ${delay}ms...`);
// Wait for the delay
await new Promise(resolve => setTimeout(resolve, delay));
// Try to create a new Howl instance
const retryHowl = await createHowl(retryCount + 1);
if (retryHowl) {
setActiveHowl(retryHowl);
retryHowl.play();
}
} else {
console.error('Max retries reached, moving to next sentence');
setIsProcessing(false);
setActiveHowl(null);
this.unload();
setIsPlaying(false);
toast.error('Audio loading failed after retries. Moving to next sentence...', {
id: 'audio-load-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 2000,
});
advance();
}
},
onend: function(this: Howl) {
this.unload();
setActiveHowl(null);
if (isPlaying) {
advance();
}
},
onstop: function(this: Howl) {
setIsProcessing(false);
this.unload();
}
});
} catch (error) {
console.error('Error creating Howl instance:', error);
return null;
}
};
try {
const howl = await createHowl();
if (howl) {
setActiveHowl(howl);
return howl;
}
throw new Error('Failed to create Howl instance');
} catch (error) {
console.error('Error playing TTS:', error);
setActiveHowl(null);
setIsProcessing(false);
toast.error('Failed to process audio. Skipping problematic sentence.', {
id: 'tts-processing-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 3000,
});
advance();
return null;
}
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
const playAudio = useCallback(async () => {
const howl = await playSentenceWithHowl(sentences[currentIndex]);
if (howl) {
howl.play();
}
}, [sentences, currentIndex, playSentenceWithHowl]);
// Place useBackgroundState after playAudio is defined
const isBackgrounded = useBackgroundState({
activeHowl,
isPlaying,
playAudio,
});
/**
* Preloads the next sentence's audio
*/
const preloadNextAudio = useCallback(async () => {
try {
const nextSentence = sentences[currentIndex + 1];
if (nextSentence && !audioCache.has(nextSentence) && !preloadRequests.current.has(nextSentence)) {
// Start preloading but don't wait for it to complete
processSentence(nextSentence, true).catch(error => {
console.error('Error preloading next sentence:', error);
});
}
} catch (error) {
console.error('Error initiating preload:', error);
}
}, [currentIndex, sentences, audioCache, processSentence]);
/**
* Main Playback Driver
* Controls the flow of audio playback and sentence processing
*/
useEffect(() => {
if (!isPlaying) return; // Don't proceed if stopped
if (isProcessing) return; // Don't proceed if processing audio
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
if (activeHowl) return; // Don't proceed if audio is already playing
if (isBackgrounded) return; // Don't proceed if backgrounded
// Start playing current sentence
playAudio();
// Start preloading next sentence in parallel
preloadNextAudio();
return () => {
// Only abort if we're actually stopping playback
if (!isPlaying) {
abortAudio();
}
};
}, [
isPlaying,
isProcessing,
currentIndex,
sentences,
activeHowl,
isBackgrounded,
playAudio,
preloadNextAudio,
abortAudio
]);
/**
* Stops the current audio playback and resets all state
*/
const stop = useCallback(() => {
// Cancel any ongoing request
abortAudio();
locationChangeHandlerRef.current = null;
setIsPlaying(false);
setCurrentIndex(0);
setSentences([]);
setCurrDocPage(1);
setCurrDocPages(undefined);
setIsProcessing(false);
setIsEPUB(false);
}, [abortAudio]);
/**
* Stops the current audio playback and starts playing from a specified index
*
* @param {number} index - The index to start playing from
*/
const stopAndPlayFromIndex = useCallback((index: number) => {
abortAudio();
setCurrentIndex(index);
setIsPlaying(true);
}, [abortAudio]);
/**
* Sets the speed and restarts the playback
*
* @param {number} newSpeed - The new speed to set
*/
const setSpeedAndRestart = useCallback((newSpeed: number) => {
const wasPlaying = isPlaying;
// Set a flag to prevent double audio requests during config update
setIsProcessing(true);
// First stop any current playback
setIsPlaying(false);
abortAudio(true); // Clear pending requests since speed changed
setActiveHowl(null);
// Update speed, clear cache, and config
setSpeed(newSpeed);
audioCache.clear();
// Update config after state changes
updateConfigKey('voiceSpeed', newSpeed).then(() => {
setIsProcessing(false);
// Resume playback if it was playing before
if (wasPlaying) {
setIsPlaying(true);
}
});
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
/**
* Sets the voice and restarts the playback
*
* @param {string} newVoice - The new voice to set
*/
const setVoiceAndRestart = useCallback((newVoice: string) => {
const wasPlaying = isPlaying;
// Set a flag to prevent double audio requests during config update
setIsProcessing(true);
// First stop any current playback
setIsPlaying(false);
abortAudio(true); // Clear pending requests since voice changed
setActiveHowl(null);
// Update voice, clear cache, and config
setVoice(newVoice);
audioCache.clear();
// Update config after state changes
updateConfigKey('voice', newVoice).then(() => {
setIsProcessing(false);
// Resume playback if it was playing before
if (wasPlaying) {
setIsPlaying(true);
}
});
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
/**
* Sets the audio player speed and restarts the playback
*
* @param {number} newSpeed - The new audio player speed to set
*/
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
const wasPlaying = isPlaying;
// Set a flag to prevent double audio requests during config update
setIsProcessing(true);
// First stop any current playback
setIsPlaying(false);
abortAudio(true); // Clear pending requests since speed changed
setActiveHowl(null);
// Update audio speed and config
setAudioSpeed(newSpeed);
// Update config after state changes
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
setIsProcessing(false);
// Resume playback if it was playing before
if (wasPlaying) {
setIsPlaying(true);
}
});
}, [abortAudio, updateConfigKey, isPlaying]);
/**
* Provides the TTS context value to child components
*/
const value = useMemo(() => ({
isPlaying,
isProcessing,
isBackgrounded,
currentSentence: sentences[currentIndex] || '',
currDocPage,
currDocPageNumber,
currDocPages,
availableVoices,
togglePlay,
skipForward,
skipBackward,
stop,
pause,
stopAndPlayFromIndex,
setText,
setCurrDocPages,
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
skipToLocation,
registerLocationChangeHandler,
setIsEPUB
}), [
isPlaying,
isProcessing,
isBackgrounded,
sentences,
currentIndex,
currDocPage,
currDocPageNumber,
currDocPages,
availableVoices,
togglePlay,
skipForward,
skipBackward,
stop,
pause,
stopAndPlayFromIndex,
setText,
setCurrDocPages,
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
skipToLocation,
registerLocationChangeHandler,
setIsEPUB
]);
// Use media session hook
useMediaSession({
togglePlay,
skipForward,
skipBackward,
});
// Load last location on mount for both EPUB and PDF
useEffect(() => {
if (id) {
getLastDocumentLocation(id as string).then(lastLocation => {
if (lastLocation) {
console.log('Setting last location:', lastLocation);
if (isEPUB && locationChangeHandlerRef.current) {
// For EPUB documents, use the location change handler
locationChangeHandlerRef.current(lastLocation);
} else if (!isEPUB) {
// For PDF documents, parse the location as "page:sentence"
try {
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
const page = parseInt(pageStr, 10);
const sentenceIndex = parseInt(sentenceIndexStr, 10);
if (!isNaN(page) && !isNaN(sentenceIndex)) {
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
// Skip to the page first, then the sentence index will be restored when setText is called
setCurrDocPage(page);
// Store the sentence index to be used when text is loaded
setPendingRestoreIndex(sentenceIndex);
}
} catch (error) {
console.warn('Error parsing PDF location:', error);
}
}
}
});
}
}, [id, isEPUB]);
// Save current position periodically for PDFs
useEffect(() => {
if (id && !isEPUB && sentences.length > 0) {
const location = `${currDocPageNumber}:${currentIndex}`;
const timeoutId = setTimeout(() => {
console.log(`Saving PDF position: ${location}`);
setLastDocumentLocation(id as string, location).catch(error => {
console.warn('Error saving PDF location:', error);
});
}, 1000); // Debounce saves by 1 second
return () => clearTimeout(timeoutId);
}
}, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length]);
/**
* Renders the TTS context provider with its children
*
* @param {ReactNode} children - Child components to be wrapped
* @returns {JSX.Element}
*/
return (
<TTSContext.Provider value={value}>
{children}
</TTSContext.Provider>
);
}
/**
* 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);
if (context === undefined) {
throw new Error('useTTS must be used within a TTSProvider');
}
return context;
}