Update API settings

This commit is contained in:
Richard Roberson 2025-03-13 21:00:34 -06:00
parent 12b46cc8e3
commit f464b059e4
7 changed files with 260 additions and 178 deletions

View file

@ -17,7 +17,7 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
- 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents
- 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download
- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates
- 📄 **Document formats**: EPUB, PDF, DOCX
- 📄 **Document formats**: EPUB, PDF, DOCX (with libreoffice installed)
- 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files with m4b format
- 📲 **Mobile Support**: Works on mobile devices, and can be added as a PWA web app
- 🎨 **Customizable Experience**:
@ -33,7 +33,7 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
- [x] **Get PDFs on iOS 17 and below working 🤞**
- [ ] **End-to-end Testing**: More playwright tests (in progress)
- [ ] **More document formats**: .txt, .md
- [ ] **Support more TTS APIs**: ElevenLabs, Ollama, etc.
- [ ] **Support more TTS APIs**: ElevenLabs, etc.
- [ ] **Accessibility Improvements**
## 🐳 Docker Quick Start

View file

@ -6,8 +6,8 @@ export async function POST(req: NextRequest) {
// Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const { text, voice, speed, format } = await req.json();
console.log('Received TTS request:', text, voice, speed, format);
const { text, voice, speed, format, model } = await req.json();
console.log('Received TTS request:', text, voice, speed, format, model);
if (!openApiKey) {
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 });
@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
// Request audio from OpenAI and pass along the abort signal
const response = await openai.audio.speech.create({
model: 'tts-1',
model: model || 'tts-1',
voice: voice as "alloy",
input: text,
speed: speed,

View file

@ -40,7 +40,6 @@ export const metadata: Metadata = {
},
},
verification: {
// Add your verification codes if you have them
google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U",
},
};

View file

@ -1,7 +1,24 @@
'use client';
import { Fragment, useState, useEffect, useCallback } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button, Input } from '@headlessui/react';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
Button,
Input,
TabGroup,
TabList,
Tab,
TabPanels,
TabPanel,
} from '@headlessui/react';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
@ -22,10 +39,11 @@ export function SettingsModal() {
const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme();
const { apiKey, baseUrl, updateConfig } = useConfig();
const { apiKey, baseUrl, ttsModel, updateConfig, updateConfigKey } = useConfig();
const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localTTSModel, setLocalTTSModel] = useState(ttsModel);
const [isSyncing, setIsSyncing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
@ -46,7 +64,8 @@ export function SettingsModal() {
checkFirstVist();
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
}, [apiKey, baseUrl, checkFirstVist]);
setLocalTTSModel(ttsModel);
}, [apiKey, baseUrl, ttsModel, checkFirstVist]);
const handleSync = async () => {
try {
@ -91,14 +110,22 @@ export function SettingsModal() {
setShowClearServerConfirm(false);
};
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
const handleInputChange = (type: 'apiKey' | 'baseUrl' | 'ttsModel', value: string) => {
if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value);
} else {
} else if (type === 'baseUrl') {
setLocalBaseUrl(value === '' ? '' : value);
} else if (type === 'ttsModel') {
setLocalTTSModel(value === '' ? 'tts-1' : value);
}
};
const tabs = [
{ name: 'Appearance', icon: '✨' },
{ name: 'API', icon: '🔑' },
{ name: 'Documents', icon: '📄' }
];
return (
<Button
onClick={() => setIsOpen(true)}
@ -140,173 +167,218 @@ export function SettingsModal() {
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
className="text-lg font-semibold leading-6 text-foreground mb-4"
>
Settings
</DialogTitle>
<div className="mt-4">
<div className="relative space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
<span className="block truncate">{selectedTheme.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 max-h-40 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{themes.map((theme) => (
<ListboxOption
key={theme.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
}`
}
value={theme}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{theme.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
<TabGroup>
<TabList className="flex space-x-1 rounded-xl bg-background p-1 mb-4">
{tabs.map((tab) => (
<Tab
key={tab.name}
className={({ selected }) =>
`w-full rounded-lg py-1 text-sm font-medium
ring-accent/60 ring-offset-2 ring-offset-base
${selected
? 'bg-accent text-white shadow'
: 'text-foreground hover:bg-accent/[0.12] hover:text-accent'
}`
}
>
<span className="flex items-center justify-center gap-2">
<span>{tab.icon}</span>
{tab.name}
</span>
</Tab>
))}
</TabList>
<TabPanels className="mt-2">
<TabPanel className="space-y-4 pb-3">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
<span className="block truncate">{selectedTheme.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 max-h-40 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{themes.map((theme) => (
<ListboxOption
key={theme.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
}`
}
value={theme}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{theme.name}
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
OpenAI API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
</div>
</div>
</TabPanel>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
OpenAI API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="text"
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<TabPanel className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
OpenAI API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
</div>
{isDev && <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Document Sync</label>
<div className="flex gap-2">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
OpenAI API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="text"
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
OpenAI TTS Model
{localTTSModel !== 'tts-1' && <span className="ml-2 text-xs text-accent">(Custom model)</span>}
</label>
<div className="flex gap-2">
<Input
type="text"
value={localTTSModel}
onChange={(e) => handleInputChange('ttsModel', e.target.value)}
placeholder="tts-1"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
onClick={handleLoad}
disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Load docs from Server'}
</Button>
<Button
onClick={handleSync}
disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isSyncing ? 'Saving...' : 'Save local to Server'}
</Button>
</div>
</div>}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
<div className="flex gap-2">
<Button
onClick={() => setShowClearLocalConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete local docs
</Button>
{isDev && <Button
onClick={() => setShowClearServerConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete server docs
</Button>}
</div>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={async () => {
setLocalApiKey('');
setLocalBaseUrl('');
}}
>
Reset
</Button>
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
onClick={async () => {
setLocalApiKey('');
setLocalBaseUrl('');
setLocalTTSModel('tts-1');
}}
>
Reset
</Button>
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-white hover:bg-accent/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
onClick={async () => {
await updateConfig({
apiKey: localApiKey || '',
baseUrl: localBaseUrl || '',
});
setIsOpen(false);
}}
>
Done
</Button>
</div>
onClick={async () => {
await updateConfig({
apiKey: localApiKey || '',
baseUrl: localBaseUrl || '',
});
await updateConfigKey('ttsModel', localTTSModel || 'tts-1');
setIsOpen(false);
}}
>
Done
</Button>
</div>
</TabPanel>
<TabPanel className="space-y-4">
{isDev && <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Document Sync</label>
<div className="flex gap-2">
<Button
onClick={handleLoad}
disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Load docs from Server'}
</Button>
<Button
onClick={handleSync}
disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isSyncing ? 'Saving...' : 'Save local to Server'}
</Button>
</div>
</div>}
<div className="space-y-2 pb-3">
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
<div className="flex gap-2">
<Button
onClick={() => setShowClearLocalConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete local docs
</Button>
{isDev && <Button
onClick={() => setShowClearServerConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete server docs
</Button>}
</div>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</DialogPanel>
</TransitionChild>
</div>

View file

@ -15,11 +15,11 @@ type ConfigValues = {
voice: string;
skipBlank: boolean;
epubTheme: boolean;
textExtractionMargin: number;
headerMargin: number;
footerMargin: number;
leftMargin: number;
rightMargin: number;
ttsModel: string;
};
/** Interface defining the configuration context shape and functionality */
@ -31,11 +31,11 @@ interface ConfigContextType {
voice: string;
skipBlank: boolean;
epubTheme: boolean;
textExtractionMargin: number;
headerMargin: number;
footerMargin: number;
leftMargin: number;
rightMargin: number;
ttsModel: string;
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;
@ -59,11 +59,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [voice, setVoice] = useState<string>('af_sarah');
const [skipBlank, setSkipBlank] = useState<boolean>(true);
const [epubTheme, setEpubTheme] = useState<boolean>(false);
const [textExtractionMargin, setTextExtractionMargin] = useState<number>(0.07);
const [headerMargin, setHeaderMargin] = useState<number>(0.07);
const [footerMargin, setFooterMargin] = useState<number>(0.07);
const [leftMargin, setLeftMargin] = useState<number>(0.07);
const [rightMargin, setRightMargin] = useState<number>(0.07);
const [ttsModel, setTTSModel] = useState<string>('tts-1');
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
@ -83,11 +83,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const cachedVoice = await getItem('voice');
const cachedSkipBlank = await getItem('skipBlank');
const cachedEpubTheme = await getItem('epubTheme');
const cachedMargin = await getItem('textExtractionMargin');
const cachedHeaderMargin = await getItem('headerMargin');
const cachedFooterMargin = await getItem('footerMargin');
const cachedLeftMargin = await getItem('leftMargin');
const cachedRightMargin = await getItem('rightMargin');
const cachedTTSModel = await getItem('ttsModel');
// Only set API key and base URL if they were explicitly saved by the user
if (cachedApiKey) {
@ -105,11 +105,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
setVoice(cachedVoice || 'af_sarah');
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
setEpubTheme(cachedEpubTheme === 'true');
setTextExtractionMargin(parseFloat(cachedMargin || '0.07'));
setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07'));
setFooterMargin(parseFloat(cachedFooterMargin || '0.07'));
setLeftMargin(parseFloat(cachedLeftMargin || '0.07'));
setRightMargin(parseFloat(cachedRightMargin || '0.07'));
setTTSModel(cachedTTSModel || 'tts-1');
// Only save non-sensitive settings by default
if (!cachedViewType) {
@ -121,13 +121,13 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (cachedEpubTheme === null) {
await setItem('epubTheme', 'false');
}
if (cachedMargin === null) {
await setItem('textExtractionMargin', '0.07');
}
if (cachedHeaderMargin === null) await setItem('headerMargin', '0.07');
if (cachedFooterMargin === null) await setItem('footerMargin', '0.07');
if (cachedLeftMargin === null) await setItem('leftMargin', '0.0');
if (cachedRightMargin === null) await setItem('rightMargin', '0.0');
if (cachedTTSModel === null) {
await setItem('ttsModel', 'tts-1');
}
} catch (error) {
console.error('Error initializing:', error);
@ -145,6 +145,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
*/
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
try {
setIsLoading(true);
if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') {
// Only save API key to IndexedDB if it's different from env default
await setItem('apiKey', newConfig.apiKey!);
@ -169,6 +170,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
} catch (error) {
console.error('Error updating config:', error);
throw error;
} finally {
setIsLoading(false);
}
};
@ -179,6 +182,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
*/
const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
try {
setIsLoading(true);
await setItem(key, value.toString());
switch (key) {
case 'apiKey':
@ -202,9 +206,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
case 'epubTheme':
setEpubTheme(value as boolean);
break;
case 'textExtractionMargin':
setTextExtractionMargin(value as number);
break;
case 'headerMargin':
setHeaderMargin(value as number);
break;
@ -217,10 +218,15 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
case 'rightMargin':
setRightMargin(value as number);
break;
case 'ttsModel':
setTTSModel(value as string);
break;
}
} catch (error) {
console.error(`Error updating config key ${key}:`, error);
throw error;
} finally {
setIsLoading(false);
}
};
@ -233,11 +239,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
voice,
skipBlank,
epubTheme,
textExtractionMargin,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
ttsModel,
updateConfig,
updateConfigKey,
isLoading,

View file

@ -97,6 +97,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
isLoading: configIsLoading,
voiceSpeed,
voice: configVoice,
ttsModel: configTTSModel,
updateConfigKey,
skipBlank,
} = useConfig();
@ -138,6 +139,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
const [speed, setSpeed] = useState(voiceSpeed);
const [voice, setVoice] = useState(configVoice);
const [ttsModel, setTTSModel] = useState(configTTSModel);
// Track pending preload requests
const preloadRequests = useRef<Map<string, Promise<string>>>(new Map());
@ -386,8 +388,9 @@ export function TTSProvider({ children }: { children: ReactNode }) {
if (!configIsLoading) {
fetchVoices();
updateVoiceAndSpeed();
setTTSModel(configTTSModel);
}
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices]);
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel]);
/**
* Generates and plays audio for the current sentence
@ -423,6 +426,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
text: sentence,
voice: voice,
speed: speed,
model: ttsModel
}),
signal: controller.signal,
});
@ -466,7 +470,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
});
throw error;
}
}, [voice, speed, audioCache, openApiKey, openApiBaseUrl]);
}, [voice, speed, ttsModel, audioCache, openApiKey, openApiBaseUrl]);
/**
* Processes and plays the current sentence

View file

@ -75,5 +75,6 @@ export async function setupTest(page: Page) {
await page.waitForLoadState('networkidle');
// Click the "done" button to dismiss the welcome message
await page.getByText('Done').click();
await page.getByRole('tab', { name: '🔑 API' }).click();
await page.getByRole('button', { name: 'Done' }).click();
}