diff --git a/public/css/style.css b/public/css/style.css index e104c4a..8b2e925 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -723,7 +723,7 @@ ol.choice { } /* Options Modal Styling */ -.options-modal { +.modal { position: fixed; top: 0; left: 0; @@ -736,7 +736,7 @@ ol.choice { background-color: rgba(0, 0, 0, 0.5); } -.options-content { +.modal-content { background-color: rgba(255, 252, 245, 0.97); border-radius: 0.4rem; box-shadow: 0 0 1.5rem rgba(0, 0, 0, 0.4); @@ -754,7 +754,7 @@ ol.choice { transform-origin: center center; } -.options-header { +.modal-header { display: flex; justify-content: space-between; align-items: center; @@ -763,7 +763,7 @@ ol.choice { padding-bottom: 0.7rem; } -.options-header h2 { +.modal-header h2 { margin: 0; font-family: 'EB Garamond', var(--book-font), serif; font-weight: normal; @@ -772,7 +772,7 @@ ol.choice { letter-spacing: 0.02rem; } -.options-close { +.close { background: none; border: none; font-size: 1.4rem; @@ -781,7 +781,7 @@ ol.choice { font-family: 'EB Garamond', var(--book-font), serif; } -.options-close:hover { +.close:hover { color: #5a3921; } @@ -800,7 +800,7 @@ ol.choice { color: #5a3921; } -.options-row { +.option-item { display: flex; justify-content: space-between; align-items: center; @@ -808,14 +808,14 @@ ol.choice { padding: 0.25rem 0; } -.options-row label { +.option-item label { font-family: 'EB Garamond', var(--book-font), serif; font-size: 1rem; color: #4a4234; } /* Elegant checkboxes */ -.options-row input[type="checkbox"] { +.option-item input[type="checkbox"] { appearance: none; -webkit-appearance: none; width: 1rem; @@ -829,7 +829,7 @@ ol.choice { overflow: hidden; } -.options-row input[type="checkbox"]:checked::before { +.option-item input[type="checkbox"]:checked::before { content: "✓"; position: absolute; font-family: 'EB Garamond', var(--book-font), serif; @@ -840,7 +840,7 @@ ol.choice { } /* Elegant select dropdowns */ -.options-row select { +.option-item select { appearance: none; -webkit-appearance: none; background-color: transparent; @@ -858,12 +858,12 @@ ol.choice { min-width: 8rem; } -.options-row select:focus { +.option-item select:focus { outline: none; } /* Range inputs (sliders) - match the main menu style */ -.options-row input[type="range"] { +.option-item input[type="range"] { -webkit-appearance: none; appearance: none; width: 8rem; @@ -878,7 +878,7 @@ ol.choice { cursor: pointer; } -.options-row input[type="range"]::-webkit-slider-thumb { +.option-item input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 0.5rem; @@ -889,7 +889,7 @@ ol.choice { box-shadow: -407px 0 0 400px rgba(0,0,0,0.3); } -.options-row input[type="range"]::-moz-range-thumb { +.option-item input[type="range"]::-moz-range-thumb { height: 0.5rem; width: 0.5rem; border-radius: 0.25rem; @@ -912,7 +912,7 @@ ol.choice { text-align: center; } -/* API Settings in Options Panel */ +/* API Settings in options Panel */ .api-settings-container { margin-top: 10px; padding: 10px; diff --git a/public/index.html b/public/index.html index ee2784c..822f102 100644 --- a/public/index.html +++ b/public/index.html @@ -281,16 +281,20 @@ diff --git a/public/js/audio-manager-module.js b/public/js/audio-manager-module.js index 1faae4e..4244be6 100644 --- a/public/js/audio-manager-module.js +++ b/public/js/audio-manager-module.js @@ -13,6 +13,7 @@ class AudioManagerModule extends BaseModule { this.masterVolume = 1.0; this.musicVolume = 1.0; this.sfxVolume = 1.0; + this.ttsVolume = 1.0; // Add persistence-manager as a dependency this.dependencies = ['persistence-manager']; @@ -193,7 +194,19 @@ class AudioManagerModule extends BaseModule { this.masterVolume = Math.max(0, Math.min(1, volume)); this.updateVolumes(); } - + + /** + * Set the speech volume + * @param {number} volume - The volume level (0.0 to 1.0) + */ + setTtsVolume(volume) { + this.ttsVolume = Math.max(0, Math.min(1, volume)); + // Apply to current non-loop audio if it exists + if (this.currentAudio) { + this.currentAudio.volume = this.masterVolume * this.ttsVolume; + } + } + /** * Set the music volume * @param {number} volume - The volume level (0.0 to 1.0) @@ -318,7 +331,7 @@ class AudioManagerModule extends BaseModule { } // Apply master volume and speech volume - audio.volume = this.masterVolume * speechVolume; + audio.volume = this.masterVolume * speechVolume * this._ttsVolume; // Set up cleanup audio.onended = () => { diff --git a/public/js/browser-tts-module.js b/public/js/browser-tts-module.js index 4cc81fd..9a83a69 100644 --- a/public/js/browser-tts-module.js +++ b/public/js/browser-tts-module.js @@ -27,7 +27,7 @@ export class BrowserTTSModule extends TTSHandlerModule { this.currentUtterance = null; // Bind additional methods - this.bindMethods(['onVoicesChanged', 'handleVoicePreferenceChanged']); + this.bindMethods(['handleVoicePreferenceChanged']); } /** diff --git a/public/js/kokoro-tts-module.js b/public/js/kokoro-tts-module.js index a824e7e..7011e42 100644 --- a/public/js/kokoro-tts-module.js +++ b/public/js/kokoro-tts-module.js @@ -41,7 +41,6 @@ export class KokoroTTSModule extends TTSHandlerModule { async initialize() { try { console.log('Kokoro TTS: Initializing'); - this.state = 'INITIALIZING'; // Get dependencies this.reportProgress(10, 'Loading dependencies'); @@ -195,21 +194,21 @@ export class KokoroTTSModule extends TTSHandlerModule { case 'kokoro:error': console.error('Kokoro TTS: Error from iframe:', event.data.error); - this.state = 'ERROR'; + // this.changeState('ERROR'); break; - case 'kokoro:speech-generated': + case 'kokoro-generated': // Handle speech generation completion if (event.data.id !== undefined && this.pendingGenerations.has(event.data.id)) { const resolver = this.pendingGenerations.get(event.data.id); this.pendingGenerations.delete(event.data.id); - if (event.data.error) { - resolver.reject(new Error(event.data.error)); + if (!event.data.success || event.data.error) { + resolver.reject(new Error(event.data.error || 'Speech generation failed')); } else { resolver.resolve({ success: true, - audioData: event.data.audioData, + audioData: event.data.result && event.data.result.buffer, duration: event.data.duration || 0 }); } @@ -541,10 +540,10 @@ export class KokoroTTSModule extends TTSHandlerModule { // Send request to iframe this.iframe.contentWindow.postMessage({ - type: 'kokoro:generate-speech', + type: 'kokoro-generate', text: processedText, id, - voiceId: this.currentVoice ? this.currentVoice.id : null + voice: this.currentVoice ? this.currentVoice.id : null }, '*'); }); } diff --git a/public/js/loader.js b/public/js/loader.js index e8e63a1..e1ae211 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -51,20 +51,7 @@ const ModuleLoader = (function() { } console.log('Module Loader: Initialization started'); - - // Check for circular dependencies before proceeding - const circularDependencies = moduleRegistry.checkForCircularDependencies(); - if (circularDependencies) { - const errorMsg = `Circular dependency detected: ${circularDependencies.join(' -> ')} -> ${circularDependencies[0]}`; - console.error(errorMsg); - document.body.innerHTML = `
-

Fatal Error: Circular Module Dependency

-

${errorMsg}

-

Please check the browser console for more details.

-
`; - return; - } - + // Create the loading overlay createLoadingOverlay(); diff --git a/public/js/module-registry.js b/public/js/module-registry.js index f1f3d0e..a1abf10 100644 --- a/public/js/module-registry.js +++ b/public/js/module-registry.js @@ -7,9 +7,6 @@ export class ModuleRegistry { this.modules = {}; this.readyPromises = {}; this.moduleDependencies = new Map(); // Track module dependencies - this.visitedModules = new Set(); // For circular dependency detection - this.recursionStack = new Set(); // For circular dependency detection - this.untrackedDependencies = new Map(); // Track unregistered dependencies } /** @@ -42,16 +39,6 @@ export class ModuleRegistry { this.moduleDependencies.set(module.id, []); } - // Check for circular dependencies - this.visitedModules.clear(); - this.recursionStack.clear(); - const circularDependency = this.detectCircularDependency(module.id); - if (circularDependency) { - const errorMsg = `Circular dependency detected: ${circularDependency.join(' -> ')} -> ${circularDependency[0]}`; - console.error(errorMsg); - throw new Error(errorMsg); - } - // Create a promise that will resolve when this module is ready this.readyPromises[module.id] = new Promise((resolve) => { // Set up a state change listener for this module @@ -70,77 +57,7 @@ export class ModuleRegistry { } }); } - - /** - * Detect circular dependencies using DFS algorithm - * @param {string} moduleId - Starting module ID - * @param {Array} [path=[]] - Current dependency path - * @returns {Array|null} - Array representing the circular dependency path, or null if none - */ - detectCircularDependency(moduleId, path = []) { - // If we've already checked this module completely, no need to check again - if (this.visitedModules.has(moduleId)) { - return null; - } - // If we're already visiting this module in the current path, we found a cycle - if (this.recursionStack.has(moduleId)) { - // Return the path that forms the cycle - const cycleStartIndex = path.indexOf(moduleId); - if (cycleStartIndex >= 0) { - return path.slice(cycleStartIndex); - } - return path; - } - - // Add to recursion stack to mark as being visited - this.recursionStack.add(moduleId); - path.push(moduleId); - - // Get dependencies for this module - const dependencies = this.getDependencies(moduleId); - - // Check each dependency - for (const depId of dependencies) { - // Even if the dependency isn't registered yet, we need to track it - // for potential circular dependencies that will manifest later - // Create a temporary placeholder in the path for unregistered dependencies - const depPath = [...path]; - if (!this.modules[depId]) { - // Log that we're tracking an unregistered dependency - console.log(`Module Registry: Tracking potential circular dependency with unregistered module: ${depId}`); - // Add to the dependency tracking for future checks - this.trackDependency(moduleId, depId); - continue; - } - - const result = this.detectCircularDependency(depId, depPath); - if (result) { - return result; - } - } - - // Remove from recursion stack as we're done with this module - this.recursionStack.delete(moduleId); - - // Mark as fully visited - this.visitedModules.add(moduleId); - - return null; - } - - /** - * Track an unregistered dependency - * @param {string} moduleId - Module ID - * @param {string} depId - Unregistered dependency ID - */ - trackDependency(moduleId, depId) { - if (!this.untrackedDependencies.has(moduleId)) { - this.untrackedDependencies.set(moduleId, new Set()); - } - this.untrackedDependencies.get(moduleId).add(depId); - } - /** * Get a module by id * @param {string} id - Module id @@ -166,25 +83,7 @@ export class ModuleRegistry { getDependencies(id) { return this.moduleDependencies.get(id) || []; } - - /** - * Check if the dependency graph has any circular dependencies - * @returns {Array|null} - Array representing the circular dependency path, or null if none - */ - checkForCircularDependencies() { - this.visitedModules.clear(); - - for (const moduleId in this.modules) { - this.recursionStack.clear(); - const result = this.detectCircularDependency(moduleId); - if (result) { - return result; - } - } - - return null; - } - + /** * Wait for a module to be ready (in FINISHED state) * @param {string} id - Module id to wait for diff --git a/public/js/openai-tts-module.js b/public/js/openai-tts-module.js index 0ea4261..6ba2a25 100644 --- a/public/js/openai-tts-module.js +++ b/public/js/openai-tts-module.js @@ -11,7 +11,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase { // Voice options specific to OpenAI this.voiceOptions = { voice: 'alloy', // Default voice for OpenAI - model: 'tts-1', // Standard model + model: 'tts-1-hd', // Standard model speed: 1.0, response_format: 'mp3' // OpenAI supports mp3, opus, aac, and flac (not wav) }; @@ -19,11 +19,16 @@ export class OpenAITTSModule extends ApiTTSModuleBase { // Predefined voices - OpenAI has a fixed set this.voices = [ { id: 'alloy', name: 'Alloy', language: 'en' }, + { id: 'ash', name: 'Ash', language: 'en' }, + { id: 'ballad', name: 'Ballad', language: 'en' }, + { id: 'coral', name: 'Coral', language: 'en' }, { id: 'echo', name: 'Echo', language: 'en' }, { id: 'fable', name: 'Fable', language: 'en' }, { id: 'onyx', name: 'Onyx', language: 'en' }, { id: 'nova', name: 'Nova', language: 'en' }, - { id: 'shimmer', name: 'Shimmer', language: 'en' } + { id: 'sage', name: 'Sage', language: 'en' }, + { id: 'shimmer', name: 'Shimmer', language: 'en' }, + { id: 'verse', name: 'Verse', language: 'en' } ]; } @@ -208,7 +213,14 @@ export class OpenAITTSModule extends ApiTTSModuleBase { } if (typeof options.speed === 'number') { - this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed)); + // OpenAI API supports speed values from 0.25 to 4.0 with 1 as default + if (options.speed <= 0.5) { + // Map [0, 0.5] -> [0.25, 1] + this.voiceOptions.speed = 0.25 + (1 - 0.25) * (options.speed / 0.5); + } else { + // Map [0.5, 1] -> [1, 4] + this.voiceOptions.speed = 1 + (4 - 1) * ((options.speed - 0.5) / 0.5); + } } // Handle OpenAI-specific options diff --git a/public/js/options-ui-module.js b/public/js/options-ui-module.js index ee2034d..599904a 100644 --- a/public/js/options-ui-module.js +++ b/public/js/options-ui-module.js @@ -38,12 +38,9 @@ class OptionsUIModule extends BaseModule { 'populateVoices', 'populateLanguages', 'loadPreferences', - 'applySettings', - 'handleTtsSystemChanged', 'showReloadNotice', 'toggle', 'setupEventListeners', - 'saveCurrentSettings', 'setupApiUrlFields', 'setupInitialState', 'dispatchApiChangeEvent', @@ -109,8 +106,8 @@ class OptionsUIModule extends BaseModule { // Set up initial state await this.setupInitialState(); - // Set up immediate save listeners - this.setupImmediateSaveListeners(); + // Set up automatic bindings using the persistence manager + this.setupPreferenceBindings(); this.reportProgress(100, 'Options UI initialized'); return true; @@ -120,356 +117,420 @@ class OptionsUIModule extends BaseModule { * Create the options modal */ createModal() { - if (this.modal) return; - - const body = document.body; + console.log('Options UI: Creating options modal'); // Create modal container - this.modal = createUIElement('div', { className: 'options-modal', id: 'options-modal' }, null, body); - - // Create modal content - const modalContent = createUIElement('div', { className: 'options-content' }, null, this.modal); - - // Create header - const header = createUIElement('div', { className: 'options-header' }, null, modalContent); - createUIElement('h2', {}, 'Options', header); - this.elements.closeButton = createUIElement('button', { className: 'options-close', 'aria-label': 'Close' }, '×', header); - - // Create settings container - const settings = createUIElement('div', { className: 'options-settings' }, null, modalContent); - - // Language Section - const languageSection = createUIElement('div', { className: 'options-section' }, null, settings); - createUIElement('h3', {}, 'Language Settings', languageSection); - - // Language selection - const languageContainer = createUIElement('div', { className: 'options-row' }, null, languageSection); - createUIElement('label', {}, 'Language:', languageContainer); - this.elements.language = createUIElement('select', { id: 'app-language' }, null, languageContainer); - - // TTS Settings - const ttsSection = createUIElement('div', { className: 'options-section' }, null, settings); - createUIElement('h3', {}, 'Text-to-Speech', ttsSection); - - // TTS Toggle - const ttsSpeechToggleContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); - createUIElement('label', {}, 'Enable Text-to-Speech:', ttsSpeechToggleContainer); - this.elements.ttsEnabled = createUIElement('input', { type: 'checkbox', id: 'tts-enabled' }, null, ttsSpeechToggleContainer); - - // TTS System - const ttsSystemContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); - createUIElement('label', {}, 'TTS System:', ttsSystemContainer); - this.elements.ttsSystem = createUIElement('select', { id: 'tts-system' }, null, ttsSystemContainer); - - // TTS Voice - const ttsVoiceContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); - createUIElement('label', {}, 'Voice:', ttsVoiceContainer); - this.elements.ttsVoice = createUIElement('select', { id: 'tts-voice' }, null, ttsVoiceContainer); - - // TTS Speed - const speedContainer = createUIElement('div', { className: 'options-row' }, null, ttsSection); - createUIElement('label', {}, 'TTS Speed:', speedContainer); - this.elements.ttsSpeed = createUIElement('input', { - type: 'range', - id: 'tts-speed', - min: '0', - max: '100' - }, null, speedContainer); - - // Create API settings for each provider - const apiSettings = this.createApiSettings(ttsSection); - - // Audio Settings Section - const audioSection = createUIElement('div', { className: 'options-section' }, null, settings); - createUIElement('h3', {}, 'Audio', audioSection); - - // Master Volume - const masterVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); - createUIElement('label', {}, 'Master Volume:', masterVolumeContainer); - this.elements.masterVolume = createUIElement('input', { - type: 'range', - id: 'master-volume', - min: '0', - max: '100' - }, null, masterVolumeContainer); - - // Music Volume - const musicVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); - createUIElement('label', {}, 'Music Volume:', musicVolumeContainer); - this.elements.musicVolume = createUIElement('input', { - type: 'range', - id: 'music-volume', - min: '0', - max: '100' - }, null, musicVolumeContainer); - - // SFX Volume - const sfxVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); - createUIElement('label', {}, 'Sound Effects Volume:', sfxVolumeContainer); - this.elements.sfxVolume = createUIElement('input', { - type: 'range', - id: 'sfx-volume', - min: '0', - max: '100' - }, null, sfxVolumeContainer); - - // Ambience Volume - const ambienceVolumeContainer = createUIElement('div', { className: 'options-row' }, null, audioSection); - createUIElement('label', {}, 'Ambience Volume:', ambienceVolumeContainer); - this.elements.ambienceVolume = createUIElement('input', { - type: 'range', - id: 'ambience-volume', - min: '0', - max: '100' - }, null, ambienceVolumeContainer); - - // Initialize with display: none + this.modal = document.createElement('div'); + this.modal.id = 'options-modal'; + this.modal.className = 'modal'; this.modal.style.display = 'none'; - // Add event handlers - this.elements.closeButton.addEventListener('click', () => { - this.saveCurrentSettings(); - this.hide(); - }); - } - - /** - * Create API settings for TTS providers - * @param {HTMLElement} parentSection - Parent section for API settings - * @returns {Object} - Object with API settings elements - */ - createApiSettings(parentSection) { - // ElevenLabs settings - // API Key - const elevenLabsApiKeyContainer = createUIElement('div', { - className: 'options-row elevenlabs-tts-setting', - 'data-provider': 'elevenlabs-tts' - }, null, parentSection); + // Create modal content + const modalContent = document.createElement('div'); + modalContent.className = 'modal-content'; - createUIElement('label', {}, 'ElevenLabs API Key:', elevenLabsApiKeyContainer); - this.elements.elevenLabsApiKey = createUIElement('input', { - type: 'password', - placeholder: 'Enter your ElevenLabs API key' - }, null, elevenLabsApiKeyContainer); + // Create header + const header = document.createElement('div'); + header.className = 'modal-header'; - // API URL - const elevenLabsApiUrlContainer = createUIElement('div', { - className: 'options-row elevenlabs-tts-setting', - 'data-provider': 'elevenlabs-tts' - }, null, parentSection); + const title = document.createElement('h2'); + title.textContent = 'Options'; + header.appendChild(title); - createUIElement('label', {}, 'ElevenLabs API URL:', elevenLabsApiUrlContainer); - this.elements.elevenLabsApiUrl = createUIElement('input', { - type: 'text', - placeholder: 'https://api.elevenlabs.io/v1' - }, null, elevenLabsApiUrlContainer); + const closeButton = document.createElement('span'); + closeButton.className = 'close'; + closeButton.innerHTML = '×'; + closeButton.onclick = () => this.hide(); + header.appendChild(closeButton); - // OpenAI settings - // API Key - const openaiApiKeyContainer = createUIElement('div', { - className: 'options-row openai-tts-setting', - 'data-provider': 'openai-tts' - }, null, parentSection); + modalContent.appendChild(header); - createUIElement('label', {}, 'OpenAI API Key:', openaiApiKeyContainer); - this.elements.openaiApiKey = createUIElement('input', { - type: 'password', - placeholder: 'Enter your OpenAI API key' - }, null, openaiApiKeyContainer); + // Create body + const body = document.createElement('div'); + body.className = 'modal-body'; - // API URL - const openaiApiUrlContainer = createUIElement('div', { - className: 'options-row openai-tts-setting', - 'data-provider': 'openai-tts' - }, null, parentSection); + // Create sections + // App Settings Section (Language and Speed) + const appSettingsSection = document.createElement('div'); + appSettingsSection.className = 'options-section'; - createUIElement('label', {}, 'OpenAI API URL:', openaiApiUrlContainer); - this.elements.openaiApiUrl = createUIElement('input', { - type: 'text', - placeholder: 'https://api.openai.com/v1' - }, null, openaiApiUrlContainer); + const appSettingsTitle = document.createElement('h3'); + appSettingsTitle.textContent = 'Application Settings'; + appSettingsSection.appendChild(appSettingsTitle); - // Initially hide API settings - const apiSettings = document.querySelectorAll('.elevenlabs-tts-setting, .openai-tts-setting'); - apiSettings.forEach(setting => { - setting.style.display = 'none'; + // Language + const languageContainer = document.createElement('div'); + languageContainer.className = 'option-item'; + + const languageLabel = document.createElement('label'); + languageLabel.textContent = 'Language:'; + languageContainer.appendChild(languageLabel); + + this.elements.language = createUIElement('select', { + 'data-pref-bind': 'app.locale' + }, null, languageContainer); + + appSettingsSection.appendChild(languageContainer); + + // Speed + const speedContainer = document.createElement('div'); + speedContainer.className = 'option-item'; + + const speedLabel = document.createElement('label'); + speedLabel.textContent = 'Speed:'; + speedContainer.appendChild(speedLabel); + + const speedValue = document.createElement('span'); + speedValue.className = 'slider-value'; + speedValue.textContent = '100%'; + speedContainer.appendChild(speedValue); + + this.elements.ttsSpeed = createUIElement('input', { + type: 'range', + min: 50, + max: 200, + value: 100, + 'data-pref-bind': 'app.speed', + 'data-pref-transform': 'range:0.5,2.0' + }, null, speedContainer); + + // Update displayed value when slider changes + this.elements.ttsSpeed.addEventListener('input', () => { + speedValue.textContent = `${this.elements.ttsSpeed.value}%`; }); - return { elevenLabsApiKeyContainer, elevenLabsApiUrlContainer, openaiApiKeyContainer, openaiApiUrlContainer }; - } - - /** - * Set up event listeners for UI elements - */ - setupEventListeners() { - // TTS System change event - if (this.elements.ttsSystem) { - this.elements.ttsSystem.addEventListener('change', this.handleTtsSystemChanged); - } + appSettingsSection.appendChild(speedContainer); - // TTS Enable toggle event - if (this.elements.ttsEnabled) { - this.elements.ttsEnabled.addEventListener('change', (event) => { - const enabled = event.target.checked; - console.log('Options UI: TTS enabled changed to', enabled); - - // Save setting - this.updatePreference('tts', 'enabled', enabled); - - // Update TTS Factory - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory) { - ttsFactory.configure({ enabled }); - } - }); - } + body.appendChild(appSettingsSection); - // Voice change event - if (this.elements.ttsVoice) { - this.elements.ttsVoice.addEventListener('change', (event) => { - const voice = event.target.value; - console.log('Options UI: TTS voice changed to', voice); - - // Save setting - this.updatePreference('tts', 'voice', voice); - - // Update TTS Factory - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory) { - ttsFactory.configure({ voice }); - } - }); - } + // TTS Section + const ttsSection = document.createElement('div'); + ttsSection.className = 'options-section'; - // TTS Speed change event - if (this.elements.ttsSpeed) { - this.elements.ttsSpeed.addEventListener('input', (event) => { - const speed = parseInt(event.target.value) / 100; - console.log('Options UI: TTS speed changed to', speed); - - // Save setting - this.updatePreference('tts', 'speed', speed); - - // Update TTS Factory - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory) { - ttsFactory.configure({ speed }); - } - }); - } + const ttsTitle = document.createElement('h3'); + ttsTitle.textContent = 'Text-to-Speech'; + ttsSection.appendChild(ttsTitle); - // Language change event - if (this.elements.language) { - this.elements.language.addEventListener('change', (event) => { - const locale = event.target.value; - console.log('Options UI: Language changed to', locale); - - // Save settings - this.updatePreference('app', 'locale', locale); - - // Update Localization module - const localization = this.getModule('localization'); - if (localization) { - localization.setLocale(locale); - } - - // Show reload notice - this.showReloadNotice(); - }); - } + // TTS Enable + const ttsEnableContainer = document.createElement('div'); + ttsEnableContainer.className = 'option-item'; + + const ttsEnableLabel = document.createElement('label'); + ttsEnableLabel.textContent = 'Enable TTS:'; + ttsEnableContainer.appendChild(ttsEnableLabel); + + this.elements.ttsEnabled = createUIElement('input', { + type: 'checkbox', + 'data-pref-bind': 'tts.enabled' + }, null, ttsEnableContainer); + + ttsSection.appendChild(ttsEnableContainer); + + // TTS System + const ttsSystemContainer = document.createElement('div'); + ttsSystemContainer.className = 'option-item'; + + const ttsSystemLabel = document.createElement('label'); + ttsSystemLabel.textContent = 'TTS System:'; + ttsSystemContainer.appendChild(ttsSystemLabel); + + this.elements.ttsSystem = createUIElement('select', { + 'data-pref-bind': 'tts.preferred_handler' + }, null, ttsSystemContainer); + + ttsSection.appendChild(ttsSystemContainer); + + // TTS Voice + const ttsVoiceContainer = document.createElement('div'); + ttsVoiceContainer.className = 'option-item'; + + const ttsVoiceLabel = document.createElement('label'); + ttsVoiceLabel.textContent = 'Voice:'; + ttsVoiceContainer.appendChild(ttsVoiceLabel); + + this.elements.ttsVoice = createUIElement('select', { + 'data-pref-bind': 'tts.voice' + }, null, ttsVoiceContainer); + + ttsSection.appendChild(ttsVoiceContainer); + + // Add API Settings + const apiSettings = this.createApiSettings(); + ttsSection.appendChild(apiSettings); + + body.appendChild(ttsSection); + + // Audio Section + const audioSection = document.createElement('div'); + audioSection.className = 'options-section'; + + const audioTitle = document.createElement('h3'); + audioTitle.textContent = 'Audio'; + audioSection.appendChild(audioTitle); - // Audio Settings // Master Volume - if (this.elements.masterVolume) { - this.elements.masterVolume.addEventListener('input', (event) => { - const volume = parseInt(event.target.value) / 100; - console.log('Options UI: Master volume changed to', volume); - - // Save setting - this.updatePreference('audio', 'masterVolume', volume); - - // Update Audio Manager - const audioManager = this.getModule('audio-manager'); - if (audioManager) { - audioManager.setMasterVolume(volume); - } - }); - } + const masterVolumeContainer = document.createElement('div'); + masterVolumeContainer.className = 'option-item'; + + const masterVolumeLabel = document.createElement('label'); + masterVolumeLabel.textContent = 'Master Volume:'; + masterVolumeContainer.appendChild(masterVolumeLabel); + + const masterVolumeValue = document.createElement('span'); + masterVolumeValue.className = 'slider-value'; + masterVolumeValue.textContent = '100%'; + masterVolumeContainer.appendChild(masterVolumeValue); + + this.elements.masterVolume = createUIElement('input', { + type: 'range', + min: 0, + max: 100, + value: 100, + 'data-pref-bind': 'audio.masterVolume', + 'data-pref-transform': 'range:0,1' + }, null, masterVolumeContainer); + + // Update displayed value when slider changes + this.elements.masterVolume.addEventListener('input', () => { + masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`; + }); + + audioSection.appendChild(masterVolumeContainer); + + // Speech Volume + const ttsVolumeContainer = document.createElement('div'); + ttsVolumeContainer.className = 'option-item'; + + const ttsVolumeLabel = document.createElement('label'); + ttsVolumeLabel.textContent = 'Speech Volume:'; + ttsVolumeContainer.appendChild(ttsVolumeLabel); + + const ttsVolumeValue = document.createElement('span'); + ttsVolumeValue.className = 'slider-value'; + ttsVolumeValue.textContent = '100%'; + ttsVolumeContainer.appendChild(ttsVolumeValue); + + this.elements.ttsVolume = createUIElement('input', { + type: 'range', + min: 0, + max: 100, + value: 100, + 'data-pref-bind': 'audio.ttsVolume', + 'data-pref-transform': 'range:0,1' + }, null, ttsVolumeContainer); + + // Update displayed value when slider changes + this.elements.ttsVolume.addEventListener('input', () => { + ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`; + }); + + audioSection.appendChild(ttsVolumeContainer); // Music Volume - if (this.elements.musicVolume) { - this.elements.musicVolume.addEventListener('input', (event) => { - const volume = parseInt(event.target.value) / 100; - console.log('Options UI: Music volume changed to', volume); - - // Save setting - this.updatePreference('audio', 'musicVolume', volume); - - // Update Audio Manager - const audioManager = this.getModule('audio-manager'); - if (audioManager) { - audioManager.setMusicVolume(volume); - } - }); - } + const musicVolumeContainer = document.createElement('div'); + musicVolumeContainer.className = 'option-item'; - // SFX Volume - if (this.elements.sfxVolume) { - this.elements.sfxVolume.addEventListener('input', (event) => { - const volume = parseInt(event.target.value) / 100; - console.log('Options UI: SFX volume changed to', volume); - - // Save setting - this.updatePreference('audio', 'sfxVolume', volume); - - // Update Audio Manager - const audioManager = this.getModule('audio-manager'); - if (audioManager) { - audioManager.setSfxVolume(volume); - } - }); - } + const musicVolumeLabel = document.createElement('label'); + musicVolumeLabel.textContent = 'Music Volume:'; + musicVolumeContainer.appendChild(musicVolumeLabel); - } - - /** - * Handle TTS system change - * @param {Event} event - Change event - */ - async handleTtsSystemChanged(event) { - const selectedSystem = event.target.value; - console.log('Options UI: TTS system changed to', selectedSystem); + const musicVolumeValue = document.createElement('span'); + musicVolumeValue.className = 'slider-value'; + musicVolumeValue.textContent = '100%'; + musicVolumeContainer.appendChild(musicVolumeValue); - // Update API settings visibility - this.updateApiSettingsVisibility(selectedSystem); + this.elements.musicVolume = createUIElement('input', { + type: 'range', + min: 0, + max: 100, + value: 70, + 'data-pref-bind': 'audio.musicVolume', + 'data-pref-transform': 'range:0,1' + }, null, musicVolumeContainer); - // Save setting - this.updatePreference('tts', 'preferred_handler', selectedSystem); - - // Notify TTSFactory of handler change - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory) { - await ttsFactory.setActiveHandler(selectedSystem); - - // Now that the handler has changed, update voices for the selected system - await this.populateVoices(); - } - } - - /** - * Update API settings visibility based on selected TTS system - * @param {string} selectedSystem - Selected TTS system - */ - updateApiSettingsVisibility(selectedSystem) { - const elevenLabsSettings = document.querySelectorAll('.elevenlabs-tts-setting'); - const openaiSettings = document.querySelectorAll('.openai-tts-setting'); - - elevenLabsSettings.forEach(setting => { - setting.style.display = selectedSystem === 'elevenlabs-tts' ? 'flex' : 'none'; + // Update displayed value when slider changes + this.elements.musicVolume.addEventListener('input', () => { + musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`; }); - openaiSettings.forEach(setting => { - setting.style.display = selectedSystem === 'openai-tts' ? 'flex' : 'none'; + audioSection.appendChild(musicVolumeContainer); + + // SFX Volume + const sfxVolumeContainer = document.createElement('div'); + sfxVolumeContainer.className = 'option-item'; + + const sfxVolumeLabel = document.createElement('label'); + sfxVolumeLabel.textContent = 'Sound Effects Volume:'; + sfxVolumeContainer.appendChild(sfxVolumeLabel); + + const sfxVolumeValue = document.createElement('span'); + sfxVolumeValue.className = 'slider-value'; + sfxVolumeValue.textContent = '100%'; + sfxVolumeContainer.appendChild(sfxVolumeValue); + + this.elements.sfxVolume = createUIElement('input', { + type: 'range', + min: 0, + max: 100, + value: 100, + 'data-pref-bind': 'audio.sfxVolume', + 'data-pref-transform': 'range:0,1' + }, null, sfxVolumeContainer); + + // Update displayed value when slider changes + this.elements.sfxVolume.addEventListener('input', () => { + sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`; + }); + + audioSection.appendChild(sfxVolumeContainer); + + body.appendChild(audioSection); + + modalContent.appendChild(body); + + // Create footer + const footer = document.createElement('div'); + footer.className = 'modal-footer'; + + const closeModalButton = document.createElement('button'); + closeModalButton.textContent = 'Close'; + closeModalButton.onclick = () => this.hide(); + footer.appendChild(closeModalButton); + + modalContent.appendChild(footer); + + // Add modal content to modal + this.modal.appendChild(modalContent); + + // Add modal to document + document.body.appendChild(this.modal); + } + + /** + * Create API settings controls + * @returns {HTMLElement} - API settings element + */ + createApiSettings() { + console.log('Options UI: Creating API settings'); + const apiSettings = document.createElement('div'); + apiSettings.className = 'options-section'; + + // ElevenLabs API settings + const elevenLabsSettings = document.createElement('div'); + elevenLabsSettings.className = 'api-settings elevenlabs-tts-settings'; + elevenLabsSettings.style.display = 'none'; + + const elevenLabsTitle = document.createElement('h3'); + elevenLabsTitle.textContent = 'ElevenLabs API Settings'; + elevenLabsSettings.appendChild(elevenLabsTitle); + + // ElevenLabs API Key + const elevenLabsApiKeyContainer = document.createElement('div'); + elevenLabsApiKeyContainer.className = 'option-item'; + + const elevenLabsApiKeyLabel = document.createElement('label'); + elevenLabsApiKeyLabel.textContent = 'API Key:'; + elevenLabsApiKeyContainer.appendChild(elevenLabsApiKeyLabel); + + this.elements.elevenLabsApiKey = createUIElement('input', { + type: 'password', + 'data-pref-bind': 'tts.elevenlabs-tts_api_key' + }, null, elevenLabsApiKeyContainer); + + elevenLabsSettings.appendChild(elevenLabsApiKeyContainer); + + // ElevenLabs API URL + const elevenLabsApiUrlContainer = document.createElement('div'); + elevenLabsApiUrlContainer.className = 'option-item'; + + const elevenLabsApiUrlLabel = document.createElement('label'); + elevenLabsApiUrlLabel.textContent = 'API URL:'; + elevenLabsApiUrlContainer.appendChild(elevenLabsApiUrlLabel); + + this.elements.elevenLabsApiUrl = createUIElement('input', { + type: 'text', + 'data-pref-bind': 'tts.elevenlabs-tts_api_url' + }, null, elevenLabsApiUrlContainer); + + elevenLabsSettings.appendChild(elevenLabsApiUrlContainer); + + // OpenAI API settings + const openaiSettings = document.createElement('div'); + openaiSettings.className = 'api-settings openai-tts-settings'; + openaiSettings.style.display = 'none'; + + const openaiTitle = document.createElement('h3'); + openaiTitle.textContent = 'OpenAI API Settings'; + openaiSettings.appendChild(openaiTitle); + + // OpenAI API Key + const openaiApiKeyContainer = document.createElement('div'); + openaiApiKeyContainer.className = 'option-item'; + + const openaiApiKeyLabel = document.createElement('label'); + openaiApiKeyLabel.textContent = 'API Key:'; + openaiApiKeyContainer.appendChild(openaiApiKeyLabel); + + this.elements.openaiApiKey = createUIElement('input', { + type: 'password', + 'data-pref-bind': 'tts.openai-tts_api_key' + }, null, openaiApiKeyContainer); + + openaiSettings.appendChild(openaiApiKeyContainer); + + // OpenAI API URL + const openaiApiUrlContainer = document.createElement('div'); + openaiApiUrlContainer.className = 'option-item'; + + const openaiApiUrlLabel = document.createElement('label'); + openaiApiUrlLabel.textContent = 'API URL:'; + openaiApiUrlContainer.appendChild(openaiApiUrlLabel); + + this.elements.openaiApiUrl = createUIElement('input', { + type: 'text', + 'data-pref-bind': 'tts.openai-tts_api_url' + }, null, openaiApiUrlContainer); + + openaiSettings.appendChild(openaiApiUrlContainer); + + // Add all API settings to container + apiSettings.appendChild(elevenLabsSettings); + apiSettings.appendChild(openaiSettings); + + return apiSettings; + } + + /** + * Set up event listeners for options controls + */ + setupEventListeners() { + if (!this.modal) return; + + // TTS System change + if (this.elements.ttsSystem) { + this.elements.ttsSystem.addEventListener('change', async (event) => { + this.updateApiSettingsVisibility(event.target.value); + await this.populateVoices(); + }); + } + + // Close when clicking outside the modal content + this.modal.addEventListener('click', (event) => { + if (event.target === this.modal) { + this.hide(); + } + }); + } + + /** + * Update API settings visibility based on selected TTS system + * @param {string} handlerId - Selected TTS system + */ + updateApiSettingsVisibility(handlerId) { + const apiContainers = this.modal.querySelectorAll('.api-settings'); + apiContainers.forEach(container => { + const shouldShow = container.classList.contains(`${handlerId}-settings`); + container.style.display = shouldShow ? 'block' : 'none'; }); } @@ -586,234 +647,15 @@ class OptionsUIModule extends BaseModule { /** * Load user preferences from the persistence manager + * This is now handled by the persistence manager's setupBindings method */ loadPreferences() { - const persistenceManager = this.getModule('persistence-manager'); - if (!persistenceManager) return; - - console.log('Options UI: Loading preferences'); - - // TTS Settings - // TTS Enable - if (this.elements.ttsEnabled) { - this.elements.ttsEnabled.checked = this.getPreference('tts', 'enabled', true); - } - - // TTS System - if (this.elements.ttsSystem) { - const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none'); - if (this.elements.ttsSystem.querySelector(`option[value="${preferredHandler}"]`)) { - this.elements.ttsSystem.value = preferredHandler; - } - } - - // TTS Speed - if (this.elements.ttsSpeed) { - const speed = this.getPreference('tts', 'speed', 1); - this.elements.ttsSpeed.value = Math.round(speed * 100); - } - - // API Keys and URLs - // ElevenLabs API Key - if (this.elements.elevenLabsApiKey) { - this.elements.elevenLabsApiKey.value = this.getPreference('tts', 'elevenlabs-tts_api_key', ''); - } - - // ElevenLabs API URL - if (this.elements.elevenLabsApiUrl) { - this.elements.elevenLabsApiUrl.value = this.getPreference('tts', 'elevenlabs-tts_api_url', 'https://api.elevenlabs.io/v1'); - } - - // OpenAI API Key - if (this.elements.openaiApiKey) { - this.elements.openaiApiKey.value = this.getPreference('tts', 'openai-tts_api_key', ''); - } - - // OpenAI API URL - if (this.elements.openaiApiUrl) { - this.elements.openaiApiUrl.value = this.getPreference('tts', 'openai-tts_api_url', 'https://api.openai.com/v1'); - } - - // Audio Settings - // Master Volume - if (this.elements.masterVolume) { - const masterVolume = this.getPreference('audio', 'masterVolume', 1); - this.elements.masterVolume.value = Math.round(masterVolume * 100); - } - - // Music Volume - if (this.elements.musicVolume) { - const musicVolume = this.getPreference('audio', 'musicVolume', 1); - this.elements.musicVolume.value = Math.round(musicVolume * 100); - } - - // SFX Volume - if (this.elements.sfxVolume) { - const sfxVolume = this.getPreference('audio', 'sfxVolume', 1); - this.elements.sfxVolume.value = Math.round(sfxVolume * 100); - } - - // Ambience Volume - if (this.elements.ambienceVolume) { - const ambienceVolume = this.getPreference('audio', 'ambienceVolume', 1); - this.elements.ambienceVolume.value = Math.round(ambienceVolume * 100); - } - - // Language - if (this.elements.language) { - const locale = this.getPreference('app', 'locale', 'en'); - if (this.elements.language.querySelector(`option[value="${locale}"]`)) { - this.elements.language.value = locale; - } - } - - // Update API settings visibility + // Update API settings visibility based on current TTS system if (this.elements.ttsSystem) { this.updateApiSettingsVisibility(this.elements.ttsSystem.value); } } - - /** - * Set up two-way binding for TTS Enabled - * @param {HTMLElement} element - UI element - * @param {Object} persistenceManager - Persistence Manager module - * @param {string} category - Preference category - * @param {string} key - Preference key - * @param {*} defaultValue - Default value if preference doesn't exist - * @param {Function} [transform] - Optional transform function - */ - setupTtsEnabledBinding(element, persistenceManager, category, key, defaultValue, transform) { - createPreferenceBinding( - element, - persistenceManager, - category, - key, - defaultValue, - transform - ); - } - - /** - * Set up two-way binding for TTS Voice - * @param {HTMLElement} element - UI element - * @param {Object} persistenceManager - Persistence Manager module - * @param {string} category - Preference category - * @param {string} key - Preference key - * @param {*} defaultValue - Default value if preference doesn't exist - * @param {Function} [transform] - Optional transform function - */ - setupTtsVoiceBinding(element, persistenceManager, category, key, defaultValue, transform) { - createPreferenceBinding( - element, - persistenceManager, - category, - key, - defaultValue, - transform - ); - } - - /** - * Set up two-way binding for App Language - * @param {HTMLElement} element - UI element - * @param {Object} persistenceManager - Persistence Manager module - * @param {string} category - Preference category - * @param {string} key - Preference key - * @param {*} defaultValue - Default value if preference doesn't exist - * @param {Function} [transform] - Optional transform function - */ - setupLanguageBinding(element, persistenceManager, category, key, defaultValue, transform) { - createPreferenceBinding( - element, - persistenceManager, - category, - key, - defaultValue, - transform - ); - } - - /** - * Set up two-way binding for API settings - * @param {Object} persistenceManager - Persistence Manager module - */ - setupApiPreferenceBindings(persistenceManager) { - // ElevenLabs API Key - createPreferenceBinding( - this.elements.elevenLabsApiKey, - persistenceManager, - 'tts', - 'elevenlabs-tts_api_key', - null, - (value) => { - this.dispatchApiChangeEvent('api:keyChanged', 'elevenlabs-tts', 'key', value); - return value; - } - ); - // ElevenLabs API URL - createPreferenceBinding( - this.elements.elevenLabsApiUrl, - persistenceManager, - 'tts', - 'elevenlabs-tts_api_url', - null, - (value) => { - this.dispatchApiChangeEvent('api:urlChanged', 'elevenlabs-tts', 'url', value); - return value; - } - ); - - // OpenAI API Key - createPreferenceBinding( - this.elements.openaiApiKey, - persistenceManager, - 'tts', - 'openai-tts_api_key', - null, - (value) => { - this.dispatchApiChangeEvent('api:keyChanged', 'openai-tts', 'key', value); - return value; - } - ); - - // OpenAI API URL - createPreferenceBinding( - this.elements.openaiApiUrl, - persistenceManager, - 'tts', - 'openai-tts_api_url', - null, - (value) => { - this.dispatchApiChangeEvent('api:urlChanged', 'openai-tts', 'url', value); - return value; - } - ); - } - - /** - * Save current settings - */ - saveCurrentSettings() { - // With two-way binding, settings are saved automatically as they change - console.log('Options UI: Settings saved'); - } - - /** - * Apply settings - */ - applySettings() { - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory) { - // Apply TTS settings - const enabled = this.getPreference('tts', 'enabled', false); - const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none'); - - ttsFactory.configure({ enabled }); - ttsFactory.setActiveHandler(preferredHandler); - } - } - /** * Show a reload notice * @param {string} message - Message to show @@ -827,33 +669,7 @@ class OptionsUIModule extends BaseModule { * Set up listeners for settings that should save immediately */ setupImmediateSaveListeners() { - // Settings are saved immediately with two-way binding - } - - /** - * Update UI text based on current language - */ - updateUIText() { - // Update UI text based on current language - const localization = this.getModule('localization'); - if (!localization) return; - - // Update modal title - const modalTitle = this.modal.querySelector('h2'); - if (modalTitle) { - modalTitle.textContent = localization.translate('options.title', 'Options'); - } - - // Update section titles - const ttsSectionTitle = this.modal.querySelector('.options-section h3:first-child'); - if (ttsSectionTitle) { - ttsSectionTitle.textContent = localization.translate('options.tts.title', 'Text-to-Speech'); - } - - const langSectionTitle = this.modal.querySelector('.options-section:nth-child(2) h3'); - if (langSectionTitle) { - langSectionTitle.textContent = localization.translate('options.language.title', 'Language Settings'); - } + // Settings are saved immediately with two-way binding via data-pref-bind attributes } /** @@ -896,60 +712,106 @@ class OptionsUIModule extends BaseModule { /** * Set up the initial state of the Options UI - * @returns {Promise} - Promise resolves when setup is complete */ async setupInitialState() { - try { - console.log('Options UI: Setting up initial state'); - - // Add event listener for toggling options UI - document.addEventListener('ui:options:toggle', () => this.toggle()); - - // Set up key bindings - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && this.modal && this.modal.style.display === 'flex') { - this.saveCurrentSettings(); - this.hide(); - } - }); - - // Populate TTS systems - await this.populateTtsSystems(); - - // Populate languages + // Add event listener for toggling options UI + document.addEventListener('ui:options:toggle', () => this.toggle()); + + // Set up key bindings + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.modal && this.modal.style.display === 'flex') { + this.hide(); + } + }); + + // Populate TTS systems selector + await this.populateTtsSystems(); + + // Populate languages + await this.populateLanguages(); + + // Populate voices based on current TTS system + await this.populateVoices(); + + // Register for TTS events to update voices when they change + document.addEventListener('tts:voices:updated', () => { + console.log('Options UI: Received tts:voices:updated event, updating voice dropdown'); + this.populateVoices(); + }); + + // Set up language change listener + document.addEventListener('locale:changed', async () => { + this.updateUIText(); await this.populateLanguages(); - - // Populate voices based on current TTS system + }); + + // Register event listeners for TTS availability and voiceId changes + document.addEventListener('tts:engine:change', async (event) => { + console.log('Options UI: Received TTS engine change event:', event.detail); await this.populateVoices(); - - // Load current preferences - this.loadPreferences(); - - // Register for TTS events to update voices when they change - document.addEventListener('tts:voices:updated', () => { - console.log('Options UI: Received tts:voices:updated event, updating voice dropdown'); - this.populateVoices(); - }); - - // Set up language change listener - document.addEventListener('locale:changed', async () => { - this.updateUIText(); - await this.populateLanguages(); - }); - - // Register event listeners for TTS availability and voiceId changes - document.addEventListener('tts:engine:change', async (event) => { - console.log('Options UI: Received TTS engine change event:', event.detail); - await this.populateVoices(); - this.updateApiSettingsVisibility(this.elements.ttsSystem.value); - }); - - console.log('Options UI: Initial state setup complete'); - return true; - } catch (error) { - console.error('Options UI: Error setting up initial state', error); - return false; + this.updateApiSettingsVisibility(this.elements.ttsSystem.value); + }); + } + + /** + * Set up two-way bindings for preferences using data attributes + */ + setupPreferenceBindings() { + const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager || !persistenceManager.setupBindings) { + console.error('Options UI: Cannot set up preference bindings, persistence manager not available or missing setupBindings method'); + return; } + + // Setup all bindings in the modal + this.bindings = persistenceManager.setupBindings('#options-modal'); + console.log('Options UI: Preference bindings set up', this.bindings.length); + + // Add event listeners for side effects when preferences change + document.addEventListener('preference-updated', (event) => { + const { category, key, value } = event.detail; + + // Handle audio settings side effects + if (category === 'audio') { + const audioManager = this.getModule('audio-manager'); + if (!audioManager) return; + + if (key === 'masterVolume') { + audioManager.setMasterVolume(value); + } else if (key === 'musicVolume') { + audioManager.setMusicVolume(value); + } else if (key === 'sfxVolume') { + audioManager.setSfxVolume(value); + } else if (key === 'ttsVolume') { + audioManager.setTtsVolume(value); + } + } + + // Handle TTS settings side effects + if (category === 'tts') { + const ttsFactory = this.getModule('tts-factory'); + if (!ttsFactory) return; + + if (key === 'preferred_handler') { + this.populateVoices(); + this.updateApiSettingsVisibility(value); + } else if (key === 'voice') { + ttsFactory.configure({ voice: value }); + } else if (key === 'speed') { + ttsFactory.configure({ speed: value }); + } else if (key === 'enabled') { + ttsFactory.configure({ enabled: value }); + } + } + + // Handle locale changes + if (category === 'app' && key === 'locale') { + const localization = this.getModule('localization'); + if (localization) { + localization.setLocale(value); + } + } + }); } } diff --git a/public/js/persistence-manager-module.js b/public/js/persistence-manager-module.js index cba6f58..2b25e44 100644 --- a/public/js/persistence-manager-module.js +++ b/public/js/persistence-manager-module.js @@ -31,8 +31,12 @@ class PersistenceManagerModule extends BaseModule { this.defaultPreferences = { tts: { enabled: false, - provider: 'none', + preferred_handler: 'none', voice: '', + 'elevenlabs-tts_api_key': '', + 'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1', + 'openai-tts_api_key': '', + 'openai-tts_api_url': 'https://api.openai.com/v1' }, audio: { masterVolume: 1.0, @@ -58,7 +62,11 @@ class PersistenceManagerModule extends BaseModule { 'createSaveSlot', 'loadSaveSlot', 'deleteSaveSlot', - 'getAllSaveSlots' + 'getAllSaveSlots', + 'createBinding', + 'updateElementFromPreference', + 'updatePreferenceFromElement', + 'setupBindings' ]); // Remove circular dependency @@ -246,19 +254,14 @@ class PersistenceManagerModule extends BaseModule { * @returns {boolean} - Success status */ updatePreference(category, setting, value) { - if (!category || !setting) return false; + if (!this.preferences) return false; - // Ensure preferences are loaded - if (!this.preferences) { - this.loadPreferences(); - } - - // Create category if it doesn't exist + // Ensure category exists if (!this.preferences[category]) { this.preferences[category] = {}; } - // Update preference + // Store value this.preferences[category][setting] = value; // Save preferences @@ -268,7 +271,7 @@ class PersistenceManagerModule extends BaseModule { // Dispatch event this.dispatchEvent('preference-updated', { category, - setting, + key: setting, value, timestamp: new Date().toISOString() }); @@ -469,6 +472,201 @@ class PersistenceManagerModule extends BaseModule { return this.saveSlots; } + /** + * Create a binding between a DOM element and a preference + * @param {HTMLElement} element - Element to bind to + * @param {string} category - Preference category + * @param {string} key - Preference key + * @param {Object} options - Additional options (transformers, etc) + * @returns {Object} - Binding control object + */ + createBinding(element, category, key, options = {}) { + if (!element) return null; + + const transformer = options.transformer || { + toElement: (value) => value, + toPreference: (value) => value + }; + + // Store binding info on the element + element._prefBinding = { category, key, transformer }; + + // Set initial value + this.updateElementFromPreference(element); + + // Set up event listeners + const eventHandler = () => this.updatePreferenceFromElement(element); + + // Choose appropriate events based on element type + let events = ['change']; + if (element.type !== 'checkbox' && element.type !== 'radio' && element.tagName !== 'SELECT') { + events.push('input'); + } + + // Attach event listeners + events.forEach(event => { + element.addEventListener(event, eventHandler); + }); + + // Return control object + return { + update: () => this.updateElementFromPreference(element), + destroy: () => { + events.forEach(event => { + element.removeEventListener(event, eventHandler); + }); + delete element._prefBinding; + } + }; + } + + /** + * Update an element value from its bound preference + * @param {HTMLElement} element - The bound element + */ + updateElementFromPreference(element) { + if (!element || !element._prefBinding) return; + + const { category, key, transformer } = element._prefBinding; + const value = this.getPreference(category, key); + const transformedValue = transformer.toElement(value); + + // Set element value based on its type + if (element.type === 'checkbox') { + element.checked = !!transformedValue; + } else if (element.type === 'radio') { + element.checked = element.value === String(transformedValue); + } else if (element.tagName === 'SELECT') { + element.value = transformedValue; + } else { + element.value = transformedValue; + } + } + + /** + * Update a preference from its bound element + * @param {HTMLElement} element - The bound element + */ + updatePreferenceFromElement(element) { + if (!element || !element._prefBinding) return; + + const { category, key, transformer } = element._prefBinding; + let value; + + // Get element value based on its type + if (element.type === 'checkbox') { + value = element.checked; + } else if (element.type === 'radio') { + value = element.checked ? element.value : null; + } else { + value = element.value; + } + + const transformedValue = transformer.toPreference(value); + this.updatePreference(category, key, transformedValue); + } + + /** + * Set up bindings for all elements with data-pref-bind attributes + * @param {string} rootSelector - Root selector to search within + * @returns {Array} - Array of binding control objects + */ + setupBindings(rootSelector = 'body') { + const root = document.querySelector(rootSelector); + if (!root) return []; + + const bindings = []; + const elements = root.querySelectorAll('[data-pref-bind]'); + + elements.forEach(element => { + const bindingStr = element.dataset.prefBind; + if (!bindingStr) return; + + const [category, key] = bindingStr.split('.'); + if (!category || !key) return; + + // Parse transformer if specified + let transformer = { + toElement: (value) => value, + toPreference: (value) => value + }; + + // Handle range transformations + if (element.type === 'range' && element.hasAttribute('min') && element.hasAttribute('max')) { + const min = parseInt(element.getAttribute('min'), 10) || 0; + const max = parseInt(element.getAttribute('max'), 10) || 100; + + transformer = { + toElement: (value) => { + // Convert from 0-1 to min-max + return Math.round(value * (max - min) + min); + }, + toPreference: (value) => { + // Convert from min-max to 0-1 + return (parseInt(value, 10) - min) / (max - min); + } + }; + } + + // Custom transformer via data attribute + if (element.dataset.prefTransform) { + try { + // Check if it's a range transformer in format 'range:min,max' + if (element.dataset.prefTransform.startsWith('range:')) { + const rangeValues = element.dataset.prefTransform.substring(6).split(','); + if (rangeValues.length === 2) { + const min = parseFloat(rangeValues[0]); + const max = parseFloat(rangeValues[1]); + + if (!isNaN(min) && !isNaN(max)) { + transformer = { + toElement: (value) => { + // Convert from min-max to 0-100 for the slider + return Math.round(((value - min) / (max - min)) * 100); + }, + toPreference: (value) => { + // Convert from 0-100 to min-max + return min + (parseInt(value, 10) / 100) * (max - min); + } + }; + } + } + } else { + // Try to parse as JSON for backward compatibility + const customTransformer = JSON.parse(element.dataset.prefTransform); + if (customTransformer && typeof customTransformer === 'object') { + transformer = customTransformer; + } + } + } catch (e) { + console.warn('Invalid transformer data attribute', e); + } + } + + const binding = this.createBinding(element, category, key, { transformer }); + if (binding) { + bindings.push(binding); + } + }); + + // Set up event listener for preference changes from other sources + document.addEventListener('preference-updated', (event) => { + const { category, key } = event.detail; + + // Update any matching elements + elements.forEach(element => { + if (!element._prefBinding) return; + + if (element._prefBinding.category === category && + element._prefBinding.key === key) { + this.updateElementFromPreference(element); + } + }); + }); + + return bindings; + } + /** * Clean up when module is disposed */ diff --git a/public/js/tts-factory-module.js b/public/js/tts-factory-module.js index ac36c58..e95c775 100644 --- a/public/js/tts-factory-module.js +++ b/public/js/tts-factory-module.js @@ -676,9 +676,9 @@ class TTSFactoryModule extends BaseModule { * Speak text using the active TTS handler * @param {string} text - Text to speak * @param {Object} options - TTS options - * @returns {boolean} - Success status + * @returns {Promise} - Success status */ - speak(text, options = {}) { + async speak(text, options = {}) { // Check if we have an active handler if (!this.activeHandler) { console.warn('TTS Factory: No active handler set'); @@ -705,7 +705,39 @@ class TTSFactoryModule extends BaseModule { effectiveOptions.speed = this.speed; } - // Call the handler's speak method + // Check if we have this speech cached + const hash = await this.generateSpeechHash(text); + const cached = await this.getCachedSpeech(hash); + + if (cached && cached.success) { + console.log(`TTS Factory: Using cached speech for hash ${hash}`); + // Use cached speech + return handler.speakPreloaded(cached, result => { + document.dispatchEvent(new CustomEvent('tts:speechCompleted', { + detail: { success: result?.success === true, error: result?.error } + })); + }); + } + + // Not cached, generate and cache + if (typeof handler.preloadSpeech === 'function') { + console.log(`TTS Factory: Generating and caching speech for hash ${hash}`); + const preloadData = await handler.preloadSpeech(text); + if (preloadData && preloadData.success) { + // Cache the speech + await this.cacheSpeech(hash, preloadData); + + // Speak the preloaded speech + return handler.speakPreloaded(preloadData, result => { + document.dispatchEvent(new CustomEvent('tts:speechCompleted', { + detail: { success: result?.success === true, error: result?.error } + })); + }); + } + } + + // Fallback to direct speak if preloading failed or not supported + console.log(`TTS Factory: Falling back to direct speak (no caching)`); return handler.speak(text, result => { // Forward speech completion event document.dispatchEvent(new CustomEvent('tts:speechCompleted', { diff --git a/public/kokoro-loader.html b/public/kokoro-loader.html index 436c58d..7f9c7aa 100644 --- a/public/kokoro-loader.html +++ b/public/kokoro-loader.html @@ -95,8 +95,8 @@ // Initialize the model const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX"; this.instance = await this.kokoroTTS.from_pretrained(model_id, { - dtype: "q8", // Use quantized model for better performance - device: "webgpu", // Use WebGL for better performance + dtype: "q8", + device: "webgpu", progress_callback: (progress) => { // Skip progress updates if progress is NaN/undefined (cache loading) if (progress === undefined || isNaN(progress)) { diff --git a/public/templates/options-modal.html b/public/templates/options-modal.html new file mode 100644 index 0000000..3e39f18 --- /dev/null +++ b/public/templates/options-modal.html @@ -0,0 +1,110 @@ +