diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 44a4c18..697958f 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,12 +1,14 @@ 'use client'; import { createContext, useContext, useEffect, useState } from 'react'; -import { getItem, setItem } from '@/services/indexedDB'; +import { getItem, indexedDBService, setItem } from '@/services/indexedDB'; interface ConfigContextType { apiKey: string; baseUrl: string; updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => Promise; + isLoading: boolean; + isDBReady: boolean; } const ConfigContext = createContext(undefined); @@ -14,14 +16,27 @@ const ConfigContext = createContext(undefined); export function ConfigProvider({ children }: { children: React.ReactNode }) { const [apiKey, setApiKey] = useState(''); const [baseUrl, setBaseUrl] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isDBReady, setIsDBReady] = useState(false); useEffect(() => { - const loadConfig = async () => { + const initializeDB = async () => { try { - // Try to load from IndexedDB first + setIsLoading(true); + await indexedDBService.init(); + setIsDBReady(true); + + // Now load config const cachedApiKey = await getItem('apiKey'); const cachedBaseUrl = await getItem('baseUrl'); + if (cachedApiKey) { + console.log('Cached API key found:', cachedApiKey); + } + if (cachedBaseUrl) { + console.log('Cached base URL found:', cachedBaseUrl); + } + // If not in cache, use env variables const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || ''; const defaultBaseUrl = process.env.NEXT_PUBLIC_OPENAI_API_BASE || ''; @@ -30,19 +45,22 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { setApiKey(cachedApiKey || defaultApiKey); setBaseUrl(cachedBaseUrl || defaultBaseUrl); - // If we used default values and they're not empty, store them in IndexedDB - if (!cachedApiKey && defaultApiKey) { + // If not in cache, save to cache + if (!cachedApiKey) { await setItem('apiKey', defaultApiKey); } - if (!cachedBaseUrl && defaultBaseUrl) { + if (!cachedBaseUrl) { await setItem('baseUrl', defaultBaseUrl); } + } catch (error) { - console.error('Error loading config:', error); + console.error('Error initializing:', error); + } finally { + setIsLoading(false); } }; - loadConfig(); + initializeDB(); }, []); const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => { @@ -62,7 +80,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { }; return ( - + {children} ); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 06fb524..85688a4 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -17,6 +17,7 @@ import nlp from 'compromise'; // Add the correct type import import type { TextContent, TextItem } from 'pdfjs-dist/types/src/display/api'; +import { useConfig } from '@/contexts/ConfigContext'; // Set worker from public directory pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; @@ -43,28 +44,30 @@ interface PDFContextType { const PDFContext = createContext(undefined); export function PDFProvider({ children }: { children: ReactNode }) { + const { isDBReady } = useConfig(); const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // Load documents from IndexedDB on mount + // Load documents from IndexedDB once DB is ready useEffect(() => { const loadDocuments = async () => { + if (!isDBReady) return; + try { setError(null); - await indexedDBService.init(); const docs = await indexedDBService.getAllDocuments(); setDocuments(docs); } catch (error) { console.error('Failed to load documents:', error); - setError('Failed to initialize document storage. Please check if your browser supports IndexedDB.'); + setError('Failed to load documents. Please try again.'); } finally { setIsLoading(false); } }; loadDocuments(); - }, []); + }, [isDBReady]); // Add a new document to IndexedDB const addDocument = useCallback(async (file: File): Promise => { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index e039b97..1f780c1 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -11,6 +11,7 @@ import React, { import nlp from 'compromise'; import OpenAI from 'openai'; import { LRUCache } from 'lru-cache'; // Import LRUCache directly +import { useConfig } from './ConfigContext'; // Add type declarations declare global { @@ -50,6 +51,12 @@ interface TTSContextType { const TTSContext = createContext(undefined); export function TTSProvider({ children }: { children: React.ReactNode }) { + const { apiKey: openApiKey, baseUrl: openApiBaseUrl, isLoading: configIsLoading } = useConfig(); + + // Move openai initialization to a ref to avoid breaking hooks rules + const openaiRef = useRef(null); + + // All existing state declarations and refs stay at the top const [isPlaying, setIsPlaying] = useState(false); const [currentText, setCurrentText] = useState(''); const [sentences, setSentences] = useState([]); @@ -66,20 +73,42 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [speed, setSpeed] = useState(1); const [voice, setVoice] = useState('alloy'); const [availableVoices, setAvailableVoices] = useState([]); - - // Create OpenAI instance - const [openai] = useState( - () => - new OpenAI({ - apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY, - baseURL: process.env.NEXT_PUBLIC_OPENAI_API_BASE, - dangerouslyAllowBrowser: true, - }) - ); - + // Audio cache using LRUCache with a maximum size of 50 entries const audioCacheRef = useRef(new LRUCache({ max: 50 })); + // Initialize OpenAI instance when config loads + useEffect(() => { + const fetchVoices = async () => { + try { + const response = await fetch(`${openApiBaseUrl}/audio/voices`, { + headers: { + 'Authorization': `Bearer ${openApiKey}`, + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) throw new Error('Failed to fetch voices'); + const data = await response.json(); + setAvailableVoices(data.voices || []); + } catch (error) { + console.error('Error fetching voices:', error); + + // Set available voices to default openai voices + // Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage and shimmer + setAvailableVoices(['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']); + } + }; + + if (!configIsLoading && openApiKey && openApiBaseUrl) { + openaiRef.current = new OpenAI({ + apiKey: openApiKey, + baseURL: openApiBaseUrl, + dangerouslyAllowBrowser: true, + }); + fetchVoices(); + } + }, [configIsLoading, openApiKey, openApiBaseUrl]); + useEffect(() => { /* * Initializes the AudioContext for text-to-speech playback. @@ -101,31 +130,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [audioContext]); - useEffect(() => { - // Fetch available voices when component mounts - const fetchVoices = async () => { - try { - const response = await fetch(`${openai.baseURL}/audio/voices`, { - headers: { - 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`, - 'Content-Type': 'application/json', - }, - }); - if (!response.ok) throw new Error('Failed to fetch voices'); - const data = await response.json(); - setAvailableVoices(data.voices || []); - } catch (error) { - console.error('Error fetching voices:', error); - - // Set available voices to default openai voices - // Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage and shimmer - setAvailableVoices(['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']); - } - }; - - fetchVoices(); - }, [openai.baseURL]); - // Text preprocessing function to clean and normalize text const preprocessText = (text: string): string => { return text @@ -165,7 +169,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [isPlaying, currentIndex, sentences]); const processAndPlaySentence = async (sentence: string) => { - if (!audioContext || isProcessing) return; + if (!audioContext || isProcessing || !openaiRef.current) return; try { // Only set processing if we need to fetch from API @@ -193,7 +197,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { if (!audioBuffer) { console.log(' Processing TTS for sentence:', cleanedSentence.substring(0, 50) + '...'); const startTime = Date.now(); - const response = await openai.audio.speech.create({ + const response = await openaiRef.current.audio.speech.create({ model: 'tts-1', voice: voice as "alloy", input: cleanedSentence, @@ -414,13 +418,13 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [isPlaying, currentIndex, sentences, isProcessing]); const preloadSentence = async (sentence: string) => { - if (!audioContext) return; + if (!audioContext || !openaiRef.current) return; if (audioCacheRef.current.has(sentence)) return; // Already cached try { console.log(' Preloading TTS for sentence:', sentence.substring(0, 50) + '...'); const startTime = Date.now(); - const response = await openai.audio.speech.create({ + const response = await openaiRef.current.audio.speech.create({ model: 'tts-1', voice: voice as "alloy", input: sentence, @@ -565,6 +569,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { availableVoices, }; + if (configIsLoading) { + return null; + } + return ( {children} diff --git a/src/services/indexedDB.ts b/src/services/indexedDB.ts index bcf06e7..cdee46a 100644 --- a/src/services/indexedDB.ts +++ b/src/services/indexedDB.ts @@ -1,5 +1,5 @@ const DB_NAME = 'openreader-db'; -const DB_VERSION = 2; +const DB_VERSION = 1; const PDF_STORE_NAME = 'pdf-documents'; const CONFIG_STORE_NAME = 'config'; @@ -18,13 +18,18 @@ export interface Config { class IndexedDBService { private db: IDBDatabase | null = null; + private initPromise: Promise | null = null; async init(): Promise { - if (!window.indexedDB) { - throw new Error('IndexedDB is not supported in this browser'); + if (this.initPromise) { + return this.initPromise; } - return new Promise((resolve, reject) => { + if (this.db) { + return Promise.resolve(); + } + + this.initPromise = new Promise((resolve, reject) => { console.log('Initializing IndexedDB...'); const request = indexedDB.open(DB_NAME, DB_VERSION); @@ -55,6 +60,8 @@ class IndexedDBService { } }; }); + + return this.initPromise; } // PDF Document Methods @@ -298,7 +305,9 @@ class IndexedDBService { } } -export const indexedDBService = new IndexedDBService(); +// Make sure we export a singleton instance +const indexedDBServiceInstance = new IndexedDBService(); +export const indexedDBService = indexedDBServiceInstance; // Helper functions for the ConfigContext export async function getItem(key: string): Promise {