From b50f60e1951e11904d2173ba8767f7025afdd591 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 5 Apr 2025 09:24:24 +0000 Subject: [PATCH] Completed options menu and got kokoro to load. --- public/css/style.css | 140 ++- public/js/kokoro-handler.js | 1294 ++++++++++++------------ public/js/kokoro-worker.js | 123 --- public/js/localization.js | 63 +- public/js/options-ui.js | 1636 +++++++++++++------------------ public/js/text-processor.js | 10 +- public/js/tts-factory.js | 247 +++-- public/js/ui-controller.js | 267 +++-- public/js/ui-display-handler.js | 1 + public/kokoro-loader.html | 275 ++++++ public/locales/de-de.json | 1 + public/locales/en-gb.json | 1 + public/locales/en-us.json | 1 + 13 files changed, 2170 insertions(+), 1889 deletions(-) delete mode 100644 public/js/kokoro-worker.js create mode 100644 public/kokoro-loader.html create mode 100644 public/locales/de-de.json create mode 100644 public/locales/en-gb.json create mode 100644 public/locales/en-us.json diff --git a/public/css/style.css b/public/css/style.css index d461c72..64df7a3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -722,34 +722,134 @@ ol.choice { margin: 0 5px; } -/* UI Effects */ -.effects-overlay { +/* Options Modal Styling */ +.options-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; - pointer-events: none; /* Allow clicks to pass through */ - z-index: 998; /* Below lighting but above other elements */ + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + background-color: rgba(0, 0, 0, 0.5); } -.candle-effect { - position: absolute; - top: 0; - left: 0; +.options-content { + background-color: rgba(255, 255, 255, 0.95); + border-radius: 5px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.3); + width: 80%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + padding: 20px; + font-family: var(--book-font); + color: #333; + position: relative; +} + +.options-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid #ccc; + padding-bottom: 10px; +} + +.options-header h2 { + margin: 0; + font-family: var(--book-font); + font-weight: normal; + font-size: 1.5rem; +} + +.options-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; +} + +.options-close:hover { + color: #000; +} + +.options-section { + margin-bottom: 20px; +} + +.options-section h3 { + font-family: var(--book-font); + font-weight: normal; + font-size: 1.2rem; + margin-top: 0; + margin-bottom: 10px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} + +.options-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding: 5px 0; +} + +.options-row label { + flex: 1; +} + +.options-row select, +.options-row input[type="range"] { + flex: 1; +} + +/* Style range inputs to match the top-left menu */ +.options-row input[type="range"] { + -webkit-appearance: none; + appearance: none; width: 100%; - height: 100%; - opacity: 0.3; - pointer-events: none; - mix-blend-mode: screen; - background: radial-gradient(circle at center, rgba(255,230,150,0.2) 0%, rgba(0,0,0,0) 70%); - animation: candle-flicker 4s infinite alternate; + height: 0.5rem; + background-color: transparent; + box-sizing: border-box; + border: 1px solid black; + border-radius: 0.25rem; + overflow: hidden; } -@keyframes candle-flicker { - 0% { opacity: 0.2; transform: scale(1.02); } - 25% { opacity: 0.3; } - 50% { opacity: 0.25; transform: scale(0.98); } - 75% { opacity: 0.3; } - 100% { opacity: 0.35; transform: scale(1); } +.options-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + height: 0.5rem; + width: 0.5rem; + border-radius: 0.25rem; + background-color: rgba(0,0,0,0.9); + border: none; + box-shadow: -407px 0 0 400px rgba(0,0,0,0.3); +} + +.options-row input[type="range"]::-webkit-slider-runnable-track { + -webkit-appearance: none; + appearance: none; + height: 0.5rem; + border-radius: 0.25rem; +} + +/* Reload notice styled like the book theme */ +.reload-notice { + margin-top: 20px; + padding: 10px; + font-style: italic; + color: #666; + text-align: center; +} + +.reload-notice span { + font-family: var(--book-font); + font-size: 0.9rem; } diff --git a/public/js/kokoro-handler.js b/public/js/kokoro-handler.js index c9dd1fc..ef682fd 100644 --- a/public/js/kokoro-handler.js +++ b/public/js/kokoro-handler.js @@ -1,471 +1,567 @@ /** * Kokoro TTS Handler - * Provides neural TTS via Kokoro.js + * Handles text-to-speech using the Kokoro library */ + import { TTSHandler } from './tts-handler.js'; import { moduleRegistry } from './module-registry.js'; export class KokoroHandler extends TTSHandler { - constructor() { - super(); - this.id = 'kokoro'; - this.name = 'Kokoro TTS Handler'; + /** + * Constructor + * @param {Object} options - Options for the handler + */ + constructor(options = {}) { + super(options); - // Kokoro instance - this.kokoro = null; - - // Available voices - this.voices = [ - { id: 'de_DE-neural', name: 'German (Neural)', lang: 'de-DE' }, - { id: 'en_US-neural', name: 'English (Neural)', lang: 'en-US' } - ]; - - // Current voice - this.currentVoice = null; - - // Voice options + // Set default options this.options = { - volume: 1.0, rate: 1.0, - pitch: 1.0 + volume: 1.0, + ...options }; - // State + // Initialize properties + this.id = 'kokoro'; + this.name = 'Kokoro TTS Handler'; this.available = false; this.loading = false; + this.iframe = null; this.currentAudio = null; + this.currentVoice = null; this.preloadCache = new Map(); - this.worker = null; - this.pendingGeneration = null; + this.pendingGenerations = new Map(); + this.generationCounter = 0; + + // Default voices (will be replaced by dynamically fetched voices) + this.voices = []; // Dependencies this.dependencies = ['localization', 'persistence-manager']; // Bind methods - this.bindMethods([ - 'initialize', - 'speak', - 'speakPreloaded', - 'preloadSpeech', - 'stop', - 'pause', - 'resume', - 'getVoices', - 'setVoice', - 'setOptions', - 'setupVoiceFromPreferences', - 'getId', - 'getModule' - ]); + this.initialize = this.initialize.bind(this); + this.speak = this.speak.bind(this); + this.stop = this.stop.bind(this); + this.getVoices = this.getVoices.bind(this); + this.setVoice = this.setVoice.bind(this); + this.generateSpeech = this.generateSpeech.bind(this); + this.preprocessText = this.preprocessText.bind(this); + this.speakPreloaded = this.speakPreloaded.bind(this); + this.preloadSpeech = this.preloadSpeech.bind(this); + this.pause = this.pause.bind(this); + this.resume = this.resume.bind(this); + this.setOptions = this.setOptions.bind(this); + this.setupVoiceFromPreferences = this.setupVoiceFromPreferences.bind(this); + this.getId = this.getId.bind(this); + this.handleIframeMessage = this.handleIframeMessage.bind(this); } /** - * Initialize the Kokoro TTS handler - * @param {Function} progressCallback - Callback for progress updates - * @returns {Promise} - Resolves with success status + * Get the ID of the handler + * @returns {string} - Handler ID */ - async initialize(progressCallback = null) { - if (this.loading) { - return new Promise((resolve) => { - // Wait for loading to complete - this.addEventListener(document, 'kokoro-loading-complete', (event) => { - resolve(event.detail?.success || false); - }, { once: true }); - }); - } - - if (this.available) { - return true; - } - - this.loading = true; - - try { - // Report progress - if (progressCallback) { - progressCallback(10, "Loading Kokoro TTS"); - } - - // Initialize web worker - try { - if (progressCallback) { - progressCallback(20, "Initializing Kokoro worker"); - } - - // Create worker - this.worker = new Worker('/js/kokoro-worker.js'); - - // Set up message handler - this.worker.onmessage = (e) => { - const message = e.data; - - switch (message.type) { - case 'ready': - console.log('Kokoro worker is ready'); - break; - - case 'generated': - // Handle generated speech - if (this.pendingGeneration) { - const { text, resolve, reject } = this.pendingGeneration; - this.pendingGeneration = null; - - try { - // Create audio from the returned buffer - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - const audioBuffer = audioContext.createBuffer( - 1, // mono - message.result.audio.length, - message.result.sampling_rate - ); - - // Copy the audio data to the buffer - const channelData = audioBuffer.getChannelData(0); - channelData.set(new Float32Array(message.result.audio)); - - // Create audio element - const audio = new Audio(); - const source = audioContext.createBufferSource(); - source.buffer = audioBuffer; - - // Connect to destination - source.connect(audioContext.destination); - - // Create a play function - const play = () => { - source.start(0); - }; - - resolve({ audio, play, buffer: audioBuffer }); - } catch (error) { - console.error('Error processing Kokoro audio:', error); - reject(error); - } - } - break; - - case 'error': - console.error('Kokoro worker error:', message.error); - if (this.pendingGeneration) { - const { reject } = this.pendingGeneration; - this.pendingGeneration = null; - reject(new Error(message.error)); - } - break; - - case 'progress': - if (progressCallback) { - const progress = 20 + Math.round(message.progress * 80); - progressCallback(progress, `Loading Kokoro model: ${Math.round(message.progress * 100)}%`); - } - break; - } - }; - - // Set up error handler - this.worker.onerror = (error) => { - console.error('Kokoro worker error:', error); - if (this.pendingGeneration) { - const { reject } = this.pendingGeneration; - this.pendingGeneration = null; - reject(error); - } - }; - - // Initialize the worker - this.worker.postMessage({ type: 'init' }); - - // Wait for worker to be ready - await new Promise((resolve, reject) => { - // Set up message handler for initialization - const messageHandler = (e) => { - const message = e.data; - - if (message.type === 'ready') { - this.worker.removeEventListener('message', messageHandler); - resolve(); - } else if (message.type === 'error') { - this.worker.removeEventListener('message', messageHandler); - reject(new Error(message.error)); - } - }; - - this.worker.addEventListener('message', messageHandler); - - // Set timeout for initialization - setTimeout(() => { - this.worker.removeEventListener('message', messageHandler); - reject(new Error('Timeout initializing Kokoro worker')); - }, 30000); - }); - - console.log('Kokoro worker initialized successfully'); - - // Mark as available - this.available = true; - - // Set up voice based on preferences and locale - await this.setupVoiceFromPreferences(); - - // Report progress - if (progressCallback) { - progressCallback(100, "Kokoro TTS ready"); - } - - // Dispatch event - this.dispatchEvent('kokoro-loading-complete', { success: true }); - - this.loading = false; - return true; - } catch (error) { - console.error('Error initializing Kokoro worker:', error); - - // Try to fall back to direct method - try { - if (progressCallback) { - progressCallback(20, "Initializing Kokoro directly"); - } - - // Load Kokoro script - await new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = '/js/kokoro.js'; - script.onload = () => { - if (window.kokoro) { - this.kokoro = window.kokoro; - resolve(); - } else { - reject(new Error('Kokoro not found after script load')); - } - }; - script.onerror = (e) => { - reject(new Error('Error loading Kokoro script')); - }; - document.head.appendChild(script); - - // Set timeout - setTimeout(() => { - reject(new Error('Timeout loading Kokoro script')); - }, 10000); - }); - - // Function to report progress - const progress = (progress) => { - if (progressCallback) { - const scaledProgress = 20 + Math.round(progress * 80); - progressCallback(scaledProgress, `Loading Kokoro model: ${Math.round(progress * 100)}%`); - } - }; - - // Initialize Kokoro - await this.kokoro.init({ - progress - }); - - console.log('Kokoro initialized successfully'); - - // Mark as available - this.available = true; - - // Set up voice based on preferences and locale - await this.setupVoiceFromPreferences(); - - // Report progress - if (progressCallback) { - progressCallback(100, "Kokoro TTS ready"); - } - - // Dispatch event - this.dispatchEvent('kokoro-loading-complete', { success: true }); - - this.loading = false; - return true; - } catch (directError) { - console.error('Error initializing Kokoro directly:', directError); - - // Report progress - if (progressCallback) { - progressCallback(100, "Kokoro TTS failed to initialize"); - } - - // Dispatch event - this.dispatchEvent('kokoro-loading-complete', { success: false, error: directError }); - - this.loading = false; - return false; - } - } - } catch (error) { - console.error('Error initializing Kokoro:', error); - - // Report progress - if (progressCallback) { - progressCallback(100, "Kokoro TTS failed to initialize"); - } - - // Dispatch event - this.dispatchEvent('kokoro-loading-complete', { success: false, error }); - - this.loading = false; - return false; - } + getId() { + return 'kokoro'; } /** * Get a module from the registry - * @param {string} moduleId - ID of the module to get - * @returns {Object|null} - The module or null if not found + * @param {string} id - Module ID + * @returns {Object} - Module instance */ - getModule(moduleId) { - return moduleRegistry.getModule(moduleId); + getModule(id) { + return moduleRegistry.getModule(id); } /** - * Set up voice based on preferences and locale + * Initialize the handler + * @param {Function} progressCallback - Callback for progress updates * @returns {Promise} - Resolves with success status */ + async initialize(progressCallback) { + try { + console.log('Kokoro TTS: Initializing...'); + + // Check if already initialized + if (this.available && this.isReady) { + console.log('Kokoro TTS: Already initialized and ready'); + return true; + } + + // Set loading flag + this.loading = true; + this.isReady = false; // Explicitly set to false during initialization + + // Create iframe if not already created + if (!this.iframe) { + console.log('Kokoro TTS: Creating iframe'); + // Create iframe + this.iframe = document.createElement('iframe'); + this.iframe.style.display = 'none'; + this.iframe.src = '/kokoro-loader.html'; + document.body.appendChild(this.iframe); + + // Add message listener - IMPORTANT: Use an arrow function to preserve 'this' + window.addEventListener('message', (event) => this.handleIframeMessage(event)); + } + + // Set up event handler for configuration changes + document.addEventListener('tts:configure', (event) => { + if (event.detail) { + if (typeof event.detail.rate === 'number') { + this.options.rate = event.detail.rate; + console.log(`Kokoro TTS: Rate updated to ${this.options.rate}`); + } + if (typeof event.detail.volume === 'number') { + this.options.volume = event.detail.volume; + console.log(`Kokoro TTS: Volume updated to ${this.options.volume}`); + } + } + }); + + // Wait for Kokoro to load + return new Promise((resolve) => { + // Set a timeout to prevent hanging indefinitely + const timeout = setTimeout(() => { + console.error('Kokoro TTS: Initialization timed out'); + this.loading = false; + this.isReady = false; + this.available = false; + resolve(false); + }, 30000); // 30 second timeout + + // Handle progress updates + const handleProgress = (progress, message) => { + console.log(`Kokoro TTS: Progress ${progress * 100}% - ${message}`); + if (progressCallback) { + progressCallback(progress, message); + } + }; + + // Handle message events + const messageHandler = (event) => { + if (event.source !== this.iframe.contentWindow) { + return; + } + + const data = event.data; + + if (data.type === 'kokoro-progress') { + handleProgress(data.progress, data.message); + } else if (data.type === 'kokoro-ready') { + console.log('Kokoro TTS: Received ready message from iframe', data); + // Remove the message listener + window.removeEventListener('message', messageHandler); + + // Clear the timeout + clearTimeout(timeout); + + // Set availability based on success + this.available = data.success; + this.loading = false; + this.isReady = data.success; // Set isReady flag based on success + + // Store voices if provided + if (data.success && data.voices && Array.isArray(data.voices)) { + console.log(`Kokoro TTS: Received ${data.voices.length} voices from Kokoro iframe during initialization`); + this.voices = data.voices; + } else { + console.warn('Kokoro TTS: No voices received during initialization or invalid voices data'); + if (data.success) { + // If initialization was successful but no voices were received, + // use default voices + this.voices = this.getDefaultVoices(); + console.log('Kokoro TTS: Using default voices as fallback'); + } + } + + // Set up voice from preferences + if (data.success) { + this.setupVoiceFromPreferences().then(() => { + console.log('Kokoro TTS: Voice set up from preferences during initialization'); + }).catch(error => { + console.error('Kokoro TTS: Error setting up voice from preferences during initialization:', + error ? (error.message || JSON.stringify(error)) : 'Unknown error'); + }); + } + + // Resolve with success status + resolve(data.success); + } + }; + + // Add the message listener + window.addEventListener('message', messageHandler); + + // Send initialization message to iframe + if (this.iframe.contentWindow) { + console.log('Kokoro TTS: Sending init message to iframe'); + setTimeout(() => { + this.iframe.contentWindow.postMessage({ + type: 'kokoro-init' + }, '*'); + }, 500); // Add a small delay to ensure iframe is ready + } else { + console.error('Kokoro TTS: Cannot access iframe content window'); + this.loading = false; + this.isReady = false; + this.available = false; + resolve(false); + } + }); + } catch (error) { + console.error('Kokoro TTS: Error initializing:', error ? (error.message || JSON.stringify(error)) : 'Unknown error'); + this.loading = false; + this.available = false; + this.isReady = false; + return false; + } + } + + /** + * Handle messages from the iframe + * @param {MessageEvent} event - Message event + */ + handleIframeMessage(event) { + // Ignore messages from other sources + if (!this.iframe || event.source !== this.iframe.contentWindow) { + return; + } + + const data = event.data; + console.log('Kokoro TTS: Received message from iframe:', JSON.stringify(data)); + + switch (data.type) { + case 'kokoro-ready': + console.log('Kokoro TTS: Received ready message with success =', data.success); + + // Store voices if provided + if (data.success && data.voices && Array.isArray(data.voices)) { + console.log(`Kokoro TTS: Received ${data.voices.length} voices from Kokoro iframe`); + this.voices = data.voices; + + // Set availability and ready flags + this.available = true; + this.loading = false; + this.isReady = true; + + // Set up voice from preferences after voices are loaded + this.setupVoiceFromPreferences().then(() => { + console.log('Kokoro TTS: Voice set up from preferences after receiving voices'); + + // Notify TTS Factory that we're ready now + document.dispatchEvent(new CustomEvent('kokoro:ready', { + detail: { success: true } + })); + }).catch(error => { + console.error('Kokoro TTS: Error setting up voice from preferences after receiving voices:', + error ? (error.message || JSON.stringify(error)) : 'Unknown error'); + + // Still notify as ready since we have voices, even if preference setup failed + document.dispatchEvent(new CustomEvent('kokoro:ready', { + detail: { success: true } + })); + }); + + // Notify about voices being updated + document.dispatchEvent(new CustomEvent('kokoro:voices-updated', { + detail: { voices: this.voices } + })); + } else { + console.warn('Kokoro TTS: No voices received from iframe or invalid voices data'); + // Even with no voices, mark as ready if success is true + if (data.success) { + this.voices = this.getDefaultVoices(); + this.available = true; + this.loading = false; + this.isReady = true; + console.log('Kokoro TTS: Using default voices as fallback'); + + // Notify TTS Factory that we're ready + document.dispatchEvent(new CustomEvent('kokoro:ready', { + detail: { success: true } + })); + + // Notify about voices being available + document.dispatchEvent(new CustomEvent('kokoro:voices-updated', { + detail: { voices: this.voices } + })); + } else { + this.available = false; + this.loading = false; + this.isReady = false; + console.error('Kokoro TTS: Initialization failed:', data.error || 'Unknown error'); + + // Notify TTS Factory about failure + document.dispatchEvent(new CustomEvent('kokoro:ready', { + detail: { success: false, error: data.error || 'Unknown error' } + })); + } + } + break; + + case 'kokoro-generated': + // Handle generated speech + const pendingGeneration = this.pendingGenerations.get(data.id); + if (pendingGeneration) { + this.pendingGenerations.delete(data.id); + + if (data.success) { + // Create audio element from the result + try { + // Create a blob from the buffer + const blob = new Blob([data.result.buffer], { type: 'audio/wav' }); + + // Create audio element + const audio = new Audio(URL.createObjectURL(blob)); + + // Create a play function + const play = () => { + audio.play().catch(error => { + console.error('Error playing Kokoro audio:', error); + }); + }; + + pendingGeneration.resolve({ audio, play, blob }); + } catch (error) { + console.error('Error processing Kokoro audio:', error); + pendingGeneration.reject(error); + } + } else { + pendingGeneration.reject(new Error(data.error || 'Unknown error')); + } + } + break; + + case 'kokoro-log': + // Log messages from the iframe + if (data.logType === 'error') { + console.error(`Kokoro iframe: ${data.message}`); + } else { + console.log(`Kokoro iframe: ${data.message}`); + } + break; + + case 'kokoro-progress': + // Progress updates are handled during initialization + break; + } + } + + /** + * Set up the voice from preferences + * @returns {Promise} + */ async setupVoiceFromPreferences() { try { - // Get localization and persistence manager modules - const localization = this.getModule('localization'); + console.log('Kokoro TTS: Setting up voice from preferences, available voices:', this.voices ? this.voices.length : 0); + + // If no voices are available yet, use default voice + if (!this.voices || this.voices.length === 0) { + console.warn('Kokoro TTS: No voices available yet, using default voice'); + return; + } + + // Get persistence manager const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager) { + console.warn('Kokoro TTS: Persistence manager not available'); + this.currentVoice = this.voices[0]; // Default to first voice + return; + } - // Get current locale and preferred voice - let currentLocale = 'en-us'; - let preferredVoice = ''; + // Get localization + const localization = this.getModule('localization'); + if (!localization) { + console.warn('Kokoro TTS: Localization not available'); + this.currentVoice = this.voices[0]; // Default to first voice + return; + } - if (localization) { + // Get current locale + let currentLocale = 'en-us'; // Default locale + if (localization && typeof localization.getLocale === 'function') { currentLocale = localization.getLocale(); + console.log('Kokoro TTS: Current locale from localization:', currentLocale); } else { - console.warn("Kokoro TTS: Localization module not found"); + console.warn('Kokoro TTS: getLocale method not available, using default locale'); } - if (persistenceManager) { - preferredVoice = persistenceManager.getPreference('tts', 'voice', ''); + // Get voice preference + const voiceId = persistenceManager.getPreference('tts-voice-kokoro'); + console.log('Kokoro TTS: Preferred voice ID:', voiceId); + + // Find voice + if (voiceId) { + const voice = this.voices.find(v => v.id === voiceId); + if (voice) { + console.log('Kokoro TTS: Found preferred voice:', voice.id, voice.name); + this.currentVoice = voice; + return; + } else { + console.warn('Kokoro TTS: Preferred voice not found:', voiceId); + } + } + + // Find voice for current locale + if (currentLocale) { + // Standardize locale format (compare lowercase and handle hyphens/underscores) + const normalizedLocale = currentLocale.toLowerCase().replace('_', '-'); + const localePrefix = normalizedLocale.split('-')[0]; // Get language prefix (en, de, etc.) + + // First try exact locale match + let localeVoice = this.voices.find(v => v.lang && v.lang.toLowerCase().replace('_', '-') === normalizedLocale); + + // If no exact match, try prefix match (en-US with en-GB for example) + if (!localeVoice) { + localeVoice = this.voices.find(v => { + if (!v.lang) return false; + const voiceLocale = v.lang.toLowerCase().replace('_', '-'); + return voiceLocale.startsWith(localePrefix + '-'); + }); + } + + if (localeVoice) { + console.log('Kokoro TTS: Found locale voice:', localeVoice.id, localeVoice.name, 'for locale:', normalizedLocale); + this.currentVoice = localeVoice; + return; + } else { + console.warn('Kokoro TTS: No voice found for locale:', normalizedLocale); + } + } + + // Default to first voice if available + if (this.voices.length > 0) { + console.log('Kokoro TTS: Using first available voice:', this.voices[0].id, this.voices[0].name); + this.currentVoice = this.voices[0]; } else { - console.warn("Kokoro TTS: Persistence Manager module not found"); + console.warn('Kokoro TTS: No voices available after all checks'); } - - // If we have a preferred voice, use it - if (preferredVoice) { - const success = this.setVoice(preferredVoice); - if (success) return true; - } - - // Otherwise select based on locale - return this.selectVoiceForLocale(currentLocale); } catch (error) { - console.error("Error setting up voice from preferences:", error); - return this.selectDefaultVoice(); + // Log detailed error information + console.error('Kokoro TTS: Error setting up voice from preferences:', error ? error.message || JSON.stringify(error) : 'Unknown error'); + + // Default to first voice if available + if (this.voices && this.voices.length > 0) { + console.log('Kokoro TTS: Falling back to first voice after error'); + this.currentVoice = this.voices[0]; + } else { + console.warn('Kokoro TTS: No voices available to fall back to after error'); + } } } /** - * Select a voice for the given locale - * @param {string} locale - Locale code + * Set voice for TTS + * @param {Object} voice - Voice to set * @returns {boolean} - Success status */ - selectVoiceForLocale(locale) { - if (!locale || this.voices.length === 0) { - return this.selectDefaultVoice(); - } - - // Normalize locale - const normalizedLocale = locale.toLowerCase(); - - // Try to find a voice for the exact locale - let matchingVoice = this.voices.find(voice => - voice.lang && voice.lang.toLowerCase() === normalizedLocale - ); - - // If no exact match, try to find a voice for the language part - if (!matchingVoice) { - const langPart = normalizedLocale.split('-')[0]; - matchingVoice = this.voices.find(voice => - voice.lang && voice.lang.toLowerCase().startsWith(langPart) - ); - } - - // If still no match, use default - if (!matchingVoice) { - return this.selectDefaultVoice(); - } - - // Set the matching voice - this.currentVoice = matchingVoice; - console.log(`Kokoro TTS: Selected voice ${matchingVoice.name} for locale ${locale}`); - - // Update preference - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'voice', matchingVoice.id); - } - - return true; - } - - /** - * Select a default voice - * @returns {boolean} - Success status - */ - selectDefaultVoice() { - if (this.voices.length === 0) { - console.warn("Kokoro TTS: No voices available for default selection"); + setVoice(voice) { + if (!voice || !voice.id) { return false; } - // Prefer English voices if available - const englishVoice = this.voices.find(voice => - voice.lang && voice.lang.toLowerCase().startsWith('en') - ); - - if (englishVoice) { - this.currentVoice = englishVoice; - console.log(`Kokoro TTS: Selected default English voice ${englishVoice.name}`); - } else { - // Otherwise use the first available voice - this.currentVoice = this.voices[0]; - console.log(`Kokoro TTS: Selected first available voice ${this.voices[0].name}`); + // Find voice + const foundVoice = this.voices.find(v => v.id === voice.id); + if (!foundVoice) { + return false; } - // Update preference - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'voice', this.currentVoice.id); + // Set voice + this.currentVoice = foundVoice; + + // Save preference + try { + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.setPreference('tts-voice-kokoro', foundVoice.id); + } + } catch (error) { + console.error('Kokoro TTS: Error saving voice preference:', error); } return true; } /** - * Preload speech for a text + * Set options for TTS + * @param {Object} options - Options to set + * @returns {boolean} - Success status + */ + setOptions(options) { + if (!options) { + return false; + } + + // Update options + this.options = { + ...this.options, + ...options + }; + + return true; + } + + /** + * Get available voices + * @returns {Array} - Array of available voices + */ + getVoices() { + return this.voices; + } + + /** + * Preprocess text for TTS + * @param {string} text - Text to preprocess + * @returns {string} - Preprocessed text + */ + preprocessText(text) { + if (!text) { + return ''; + } + + // Remove HTML tags + let processed = text.replace(/<[^>]*>/g, ''); + + // Replace special characters + processed = processed.replace(/ /g, ' '); + processed = processed.replace(/&/g, '&'); + processed = processed.replace(/</g, '<'); + processed = processed.replace(/>/g, '>'); + processed = processed.replace(/"/g, '"'); + processed = processed.replace(/'/g, "'"); + + return processed; + } + + /** + * Preload speech for later playback * @param {string} text - Text to preload - * @returns {Promise} - Preloaded audio data + * @returns {Promise} - Resolves with preloaded audio data */ async preloadSpeech(text) { - if (!this.available || !text) { + if (!this.available) { + console.warn('Kokoro TTS: Not available'); return null; } try { - // Preprocess text - const processedText = this.preprocessText(text); - - // Check if already cached - const cacheKey = `${this.currentVoice?.id || 'default'}_${processedText}`; + // Check if already in cache + const cacheKey = `${this.currentVoice?.id || 'af_heart'}-${this.options.rate}-${text}`; if (this.preloadCache.has(cacheKey)) { return this.preloadCache.get(cacheKey); } // Generate speech - const result = await this.generateSpeech(processedText); + const result = await this.generateSpeech(text); - // Cache result + // Store in cache this.preloadCache.set(cacheKey, result); return result; } catch (error) { - console.error("Kokoro: Error preloading speech:", error); + console.error('Kokoro TTS: Error preloading speech:', error); return null; } } @@ -477,10 +573,8 @@ export class KokoroHandler extends TTSHandler { * @returns {boolean} - Success status */ speakPreloaded(preloadData, callback = null) { - if (!this.available || !preloadData) { - if (callback) { - setTimeout(() => callback({ success: false, reason: 'not_available' }), 0); - } + if (!this.available) { + console.warn('Kokoro TTS: Not available'); return false; } @@ -488,53 +582,41 @@ export class KokoroHandler extends TTSHandler { // Stop any current speech this.stop(); - // Play the audio + // Create audio element if not already created + const audio = preloadData.audio; + + // Set up event handlers + audio.onended = () => { + this.currentAudio = null; + if (callback) callback(); + }; + + audio.onerror = (error) => { + console.error('Kokoro TTS: Audio playback error:', error); + this.currentAudio = null; + if (callback) callback(error); + }; + + // Set volume + audio.volume = this.options.volume; + + // Store current audio + this.currentAudio = audio; + + // Play audio if (preloadData.play) { - // Use the play function if available preloadData.play(); - - // Call callback after audio duration - if (callback) { - setTimeout(() => { - callback({ success: true }); - }, preloadData.buffer.duration * 1000); - } - } else if (preloadData.audio) { - // Set up event handlers - preloadData.audio.onended = () => { - if (callback) { - callback({ success: true }); - } - }; - - preloadData.audio.onerror = (error) => { - console.error("Kokoro: Audio playback error:", error); - if (callback) { - callback({ success: false, reason: 'playback_error', error }); - } - }; - - // Store reference to current audio - this.currentAudio = preloadData.audio; - - // Play the audio - preloadData.audio.play(); } else { - console.error("Kokoro: Invalid preload data"); - if (callback) { - setTimeout(() => callback({ success: false, reason: 'invalid_data' }), 0); - } - return false; + audio.play().catch(error => { + console.error('Kokoro TTS: Error playing audio:', error); + this.currentAudio = null; + if (callback) callback(error); + }); } return true; } catch (error) { - console.error("Kokoro: Error playing preloaded speech:", error); - - if (callback) { - setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0); - } - + console.error('Kokoro TTS: Error speaking preloaded audio:', error); return false; } } @@ -546,248 +628,202 @@ export class KokoroHandler extends TTSHandler { * @returns {Promise} - Resolves with success status */ async speak(text, options = {}) { - if (!this.available || !text) return false; + if (!this.available) { + console.warn('Kokoro TTS: Not available'); + return false; + } try { // Stop any current speech this.stop(); - // Preprocess text - const processedText = this.preprocessText(text); + console.log('Kokoro TTS: Generating speech for:', text); // Generate speech - const result = await this.generateSpeech(processedText); + const result = await this.generateSpeech(text); - // Create audio element - const audio = new Audio(); + if (!result || !result.audio) { + console.error('Kokoro TTS: Invalid speech generation result'); + return false; + } // Set up event handlers - return new Promise((resolve) => { - audio.onended = () => { - this.currentAudio = null; - resolve(true); - }; - - audio.onerror = (error) => { - console.error("Kokoro TTS: Audio playback error:", error); - this.currentAudio = null; - resolve(false); - }; - - // Store reference to current audio - this.currentAudio = audio; - - // Play the audio - if (result.play) { - // Use the play function if available - result.play(); - // Resolve after duration - setTimeout(() => { - this.currentAudio = null; - resolve(true); - }, result.buffer.duration * 1000); - } else if (result.audio) { - // Play the audio element - result.audio.play().catch(error => { - console.error("Kokoro TTS: Failed to play audio:", error); - this.currentAudio = null; - resolve(false); - }); + result.audio.onended = () => { + console.log('Kokoro TTS: Audio playback ended'); + this.currentAudio = null; + // Dispatch event for completion + window.dispatchEvent(new CustomEvent('tts:speak-completed')); + }; + + result.audio.onerror = (error) => { + console.error('Kokoro TTS: Audio playback error:', error); + this.currentAudio = null; + // Dispatch event for error + window.dispatchEvent(new CustomEvent('tts:speak-error', { + detail: { error: error } + })); + }; + + // Set volume + result.audio.volume = this.options.volume; + + // Store current audio + this.currentAudio = result.audio; + + console.log('Kokoro TTS: Attempting to play audio'); + + // Play audio with better error handling + try { + if (result.play && typeof result.play === 'function') { + await result.play(); } else { - console.error("Kokoro TTS: Invalid result data"); - this.currentAudio = null; - resolve(false); + await result.audio.play(); } - }); + console.log('Kokoro TTS: Audio playback started successfully'); + return true; + } catch (playError) { + console.error('Error playing Kokoro audio:', playError); + this.currentAudio = null; + return false; + } } catch (error) { - console.error("Error speaking text with Kokoro TTS:", error); + console.error('Kokoro TTS: Error speaking:', error); return false; } } /** - * Generate speech using the web worker or direct method + * Generate speech using the iframe * @param {string} text - Text to generate speech for * @returns {Promise} - Resolves with audio data */ async generateSpeech(text) { - if (this.worker) { - return new Promise((resolve, reject) => { - this.pendingGeneration = { text, resolve, reject }; - - // Send message to worker to generate speech - this.worker.postMessage({ - type: 'generate', - text, - voice: this.currentVoice?.id || 'en_US-neural', - speed: this.options.rate - }); - }); - } else if (this.kokoro) { - // Fallback to direct method if worker is not available - return this.kokoro(text, { - voice: this.currentVoice?.id || 'en_US-neural', - speed: this.options.rate, - autoPlay: false - }); - } else { - throw new Error('No Kokoro implementation available'); + if (!this.iframe || !this.iframe.contentWindow) { + throw new Error('Kokoro iframe not initialized'); } + + // Preprocess text + const processedText = this.preprocessText(text); + + // Ensure we have a valid voice + let voiceId = 'af_heart'; // Default fallback + if (this.currentVoice && this.currentVoice.id) { + voiceId = this.currentVoice.id; + } else if (this.voices && this.voices.length > 0) { + // If currentVoice is not set but we have voices, use the first one + voiceId = this.voices[0].id; + this.currentVoice = this.voices[0]; + } + + return new Promise((resolve, reject) => { + // Generate unique ID for this request + const id = `gen-${++this.generationCounter}`; + + // Store the pending generation + this.pendingGenerations.set(id, { resolve, reject }); + + // Send the generation request to the iframe + this.iframe.contentWindow.postMessage({ + type: 'kokoro-generate', + id: id, + text: processedText, + voice: voiceId, + speed: this.options.rate + }, '*'); + }); } /** - * Preprocess text for TTS - * @param {string} text - Text to preprocess - * @returns {string} - Processed text - */ - preprocessText(text) { - if (!text) return ''; - - // Trim whitespace - let processed = text.trim(); - - // Replace multiple spaces with a single space - processed = processed.replace(/\s+/g, ' '); - - // Add a period at the end if there's no punctuation - if (!/[.!?]$/.test(processed)) { - processed += '.'; - } - - return processed; - } - - /** - * Stop speaking + * Stop current speech * @returns {boolean} - Success status */ stop() { - if (!this.currentAudio) return true; - - try { - this.currentAudio.pause(); - this.currentAudio.currentTime = 0; - this.currentAudio = null; - return true; - } catch (error) { - console.error("Kokoro: Error stopping speech:", error); - return false; + if (this.currentAudio) { + try { + this.currentAudio.pause(); + this.currentAudio.currentTime = 0; + this.currentAudio = null; + return true; + } catch (error) { + console.error('Kokoro TTS: Error stopping speech:', error); + return false; + } } + return true; } /** - * Pause speaking + * Pause current speech * @returns {boolean} - Success status */ pause() { - if (!this.currentAudio) return false; - - try { - this.currentAudio.pause(); - return true; - } catch (error) { - console.error("Kokoro: Error pausing speech:", error); - return false; + if (this.currentAudio) { + try { + this.currentAudio.pause(); + return true; + } catch (error) { + console.error('Kokoro TTS: Error pausing speech:', error); + return false; + } } + return true; } /** - * Resume speaking + * Resume current speech * @returns {boolean} - Success status */ resume() { - if (!this.currentAudio) return false; - - try { - this.currentAudio.play(); - return true; - } catch (error) { - console.error("Kokoro: Error resuming speech:", error); - return false; - } - } - - /** - * Check if TTS is available - * @returns {boolean} - True if TTS is available - */ - isAvailable() { - return this.available; - } - - /** - * Get handler ID - * @returns {string} - Handler ID - */ - getId() { - return this.id; - } - - /** - * Get available voices - * @returns {Array} - Array of voice objects - */ - getVoices() { - return this.voices.map(voice => ({ - id: voice.id, - name: voice.name, - lang: voice.lang - })); - } - - /** - * Set the voice by ID or name - * @param {string} voiceId - Voice ID or name - * @returns {boolean} - Success status - */ - setVoice(voiceId) { - if (!voiceId) return false; - - const voice = this.voices.find(v => v.id === voiceId || v.name === voiceId); - if (!voice) { - console.warn(`Kokoro TTS: Voice '${voiceId}' not found`); - return false; - } - - this.currentVoice = voice; - console.log(`Kokoro TTS: Set voice to ${voice.name}`); - - // Update preference - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - persistenceManager.updatePreference('tts', 'voice', voice.id); - } - - return true; - } - - /** - * Set voice options - * @param {Object} options - Voice options - * @returns {boolean} - Success status - */ - setOptions(options = {}) { - if (!options) return false; - - // Update options - if (typeof options.volume === 'number') this.options.volume = options.volume; - if (typeof options.rate === 'number') { - // Clamp rate between 0.5 and 2.0 - this.options.rate = Math.max(0.5, Math.min(2.0, options.rate)); - } - if (typeof options.pitch === 'number') this.options.pitch = options.pitch; - - // Update preferences - const persistenceManager = this.getModule('persistence-manager'); - if (persistenceManager) { - if (typeof options.volume === 'number') { - persistenceManager.updatePreference('tts', 'volume', options.volume); - } - if (typeof options.rate === 'number') { - persistenceManager.updatePreference('tts', 'rate', options.rate); + if (this.currentAudio) { + try { + this.currentAudio.play(); + return true; + } catch (error) { + console.error('Kokoro TTS: Error resuming speech:', error); + return false; } } - - return true; + return false; + } + + /** + * Get default voices + * @returns {Array} - Array of default voices + * @private + */ + getDefaultVoices() { + return [ + // American Female voices + { id: 'af_heart', name: 'Heart', lang: 'en-US', gender: 'female' }, + { id: 'af_daisy', name: 'Daisy', lang: 'en-US', gender: 'female' }, + { id: 'af_soft', name: 'Soft', lang: 'en-US', gender: 'female' }, + { id: 'af_glados', name: 'GLaDOS', lang: 'en-US', gender: 'female' }, + { id: 'af_southern_belle', name: 'Southern Belle', lang: 'en-US', gender: 'female' }, + { id: 'af_dramatic', name: 'Dramatic', lang: 'en-US', gender: 'female' }, + { id: 'af_valley_girl', name: 'Valley Girl', lang: 'en-US', gender: 'female' }, + { id: 'af_british', name: 'British', lang: 'en-US', gender: 'female' }, + { id: 'af_russian', name: 'Russian', lang: 'en-US', gender: 'female' }, + { id: 'af_german', name: 'German', lang: 'en-US', gender: 'female' }, + { id: 'af_cheeky_cute', name: 'Cheeky Cute', lang: 'en-US', gender: 'female' }, + + // American Male voices + { id: 'am_bruce', name: 'Bruce', lang: 'en-US', gender: 'male' }, + { id: 'am_announcer', name: 'Announcer', lang: 'en-US', gender: 'male' }, + { id: 'am_radio_host', name: 'Radio Host', lang: 'en-US', gender: 'male' }, + + // British Female voices + { id: 'bf_charlotte', name: 'Charlotte', lang: 'en-GB', gender: 'female' }, + { id: 'bf_elizabeth', name: 'Elizabeth', lang: 'en-GB', gender: 'female' }, + { id: 'bf_lily', name: 'Lily', lang: 'en-GB', gender: 'female' }, + { id: 'bf_olivia', name: 'Olivia', lang: 'en-GB', gender: 'female' }, + { id: 'bf_victoria', name: 'Victoria', lang: 'en-GB', gender: 'female' }, + + // British Male voices + { id: 'bm_william', name: 'William', lang: 'en-GB', gender: 'male' }, + { id: 'bm_arthur', name: 'Arthur', lang: 'en-GB', gender: 'male' }, + { id: 'bm_george', name: 'George', lang: 'en-GB', gender: 'male' }, + { id: 'bm_harry', name: 'Harry', lang: 'en-GB', gender: 'male' }, + { id: 'bm_jack', name: 'Jack', lang: 'en-GB', gender: 'male' } + ]; } } \ No newline at end of file diff --git a/public/js/kokoro-worker.js b/public/js/kokoro-worker.js deleted file mode 100644 index 53d736a..0000000 --- a/public/js/kokoro-worker.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Kokoro Web Worker - * Handles TTS processing in a separate thread to keep UI responsive - */ - -// Global variables -let kokoroLoaded = false; -let isProcessing = false; -let voiceOptions = { - voice: 'bf_alice', - speed: 1.0 -}; - -// Initialize when receiving init message -self.onmessage = function(e) { - const message = e.data; - - try { - switch (message.type) { - case 'init': - // Just acknowledge initialization - actual model loading happens on first generate call - self.postMessage({ type: 'ready' }); - break; - - case 'generate': - if (!message.text) { - self.postMessage({ - type: 'error', - error: 'No text provided for generation' - }); - return; - } - - // Store voice options - if (message.voice) voiceOptions.voice = message.voice; - if (message.speed) voiceOptions.speed = message.speed; - - // Generate speech - generateSpeech(message.text) - .then(result => { - self.postMessage({ - type: 'generated', - result: result - }, [result.audio.buffer]); - }) - .catch(error => { - self.postMessage({ - type: 'error', - error: `Generation error: ${error.message || error}` - }); - }); - break; - - default: - self.postMessage({ - type: 'error', - error: `Unknown message type: ${message.type}` - }); - } - } catch (error) { - self.postMessage({ - type: 'error', - error: `Worker error: ${error.message || error}` - }); - } -}; - -/** - * Generate speech from text - * @param {string} text - Text to convert to speech - */ -async function generateSpeech(text) { - if (isProcessing) { - throw new Error('Already processing another request'); - } - - isProcessing = true; - - try { - // Load Kokoro if not already loaded - if (!kokoroLoaded) { - try { - // Load the Kokoro script - self.importScripts('/js/kokoro.js'); - - if (!self.Kokoro) { - throw new Error('Kokoro failed to load correctly'); - } - - kokoroLoaded = true; - console.log('Kokoro loaded in worker'); - } catch (loadError) { - console.error('Error loading Kokoro in worker:', loadError); - throw new Error(`Failed to load Kokoro: ${loadError.message}`); - } - } - - // Generate speech using Kokoro - const result = await self.Kokoro(text, { - voice: voiceOptions.voice, - speed: voiceOptions.speed, - autoPlay: false - }); - - // Extract audio data - const audioContext = new (self.AudioContext || self.webkitAudioContext)(); - const audioBuffer = await audioContext.decodeAudioData(result.buffer); - - // Get audio data as Float32Array - const audioData = audioBuffer.getChannelData(0); - - // Return the result - return { - audio: audioData, - sampling_rate: audioBuffer.sampleRate - }; - } catch (error) { - console.error('Error generating speech in worker:', error); - throw error; - } finally { - isProcessing = false; - } -} \ No newline at end of file diff --git a/public/js/localization.js b/public/js/localization.js index 07bca87..8cee21f 100644 --- a/public/js/localization.js +++ b/public/js/localization.js @@ -22,20 +22,7 @@ class LocalizationModule extends BaseModule { this.languageNames = { 'en-us': 'English (US)', 'en-gb': 'English (UK)', - 'de': 'Deutsch', - 'de-de': 'Deutsch (Deutschland)', - 'fr': 'Français', - 'fr-fr': 'Français (France)', - 'es': 'Español', - 'es-es': 'Español (España)', - 'it': 'Italiano', - 'ja': 'Japanese', - 'ko': 'Korean', - 'zh': 'Chinese (Simplified)', - 'zh-tw': 'Chinese (Traditional)', - 'ru': 'Russian', - 'pt': 'Portuguese', - 'pt-br': 'Portuguese (Brazil)' + 'de-de': 'Deutsch (Deutschland)' }; // Bind methods @@ -57,23 +44,47 @@ class LocalizationModule extends BaseModule { try { this.reportProgress(10, "Initializing localization"); - // Load default translations - await this.loadTranslations('en-us'); - // Try to load browser locale if available - const browserLocale = navigator.language.toLowerCase(); - if (browserLocale && browserLocale !== 'en-us') { + // Get stored locale from persistence manager if available + const persistenceManager = this.getModule('persistence-manager'); + let storedLocale = null; + + if (persistenceManager) { try { - this.reportProgress(50, `Loading browser locale: ${browserLocale}`); - await this.loadTranslations(browserLocale); - this.currentLocale = browserLocale; - } catch (localeError) { - console.warn(`Failed to load browser locale ${browserLocale}:`, localeError); + storedLocale = persistenceManager.getPreference('app', 'locale'); + if (storedLocale) { + console.log(`Localization: Found stored locale: ${storedLocale}`); + await this.loadTranslations(storedLocale); + this.currentLocale = storedLocale; + } else { + // If no stored locale, ensure English is the default and persist it + console.log('Localization: No stored locale found, defaulting to en-us'); + await this.loadTranslations('en-us'); + persistenceManager.updatePreference('app', 'locale', 'en-us'); + persistenceManager.updatePreference('tts', 'language', 'en-us'); + this.currentLocale = 'en-us'; + } + } catch (persistError) { + console.warn(`Failed to load stored locale:`, persistError); + } + } else { + // If browser locale is available, just load it as a fallback but keep English as default + const browserLocale = navigator.language.toLowerCase(); + if (browserLocale && browserLocale !== 'en-us') { + try { + this.reportProgress(50, `Loading browser locale as fallback: ${browserLocale}`); + await this.loadTranslations(browserLocale); + // Do NOT set browser locale as current - keep English as default + } catch (localeError) { + console.warn(`Failed to load browser locale ${browserLocale}:`, localeError); + } } } - // We don't check for persistence manager here to avoid circular dependency - // The persistence manager will update our locale after it initializes if needed + // Dispatch event to notify about loaded locale + document.dispatchEvent(new CustomEvent('localization:languageChanged', { + detail: { locale: this.currentLocale } + })); this.reportProgress(100, "Localization ready"); return true; diff --git a/public/js/options-ui.js b/public/js/options-ui.js index ef06182..a853f36 100644 --- a/public/js/options-ui.js +++ b/public/js/options-ui.js @@ -1,121 +1,139 @@ /** - * Options UI Module for AI Interactive Fiction - * Provides a user interface for adjusting game settings, TTS options, etc. + * Options UI Module + * Provides the options UI for the game */ import { BaseModule } from './base-module.js'; import { moduleRegistry } from './module-registry.js'; class OptionsUIModule extends BaseModule { /** - * Create new options UI + * Create a new options UI module */ constructor() { super('options-ui', 'Options UI'); - // Dependencies - this.dependencies = ['persistence-manager', 'localization']; - - this.persistenceManager = null; - this.ttsPlayer = null; - this.audioManager = null; - this.ttsFactory = null; - this.localization = null; + // Modal element this.modal = null; - this.isOpen = false; - // Configuration - this.config = { - modalClass: 'options-modal', - modalContentClass: 'options-content', - backdrop: true - }; - - // Elements reference + // UI elements this.elements = null; - // Bound event handlers for proper this context + // Settings that require reload + this.reloadRequired = false; + + // Bind methods this.bindMethods([ - 'handleTtsSystemChanged', - 'loadPreferences', + 'show', + 'hide', + 'createModal', 'populateTtsSystems', 'populateVoices', - 'resetToDefaults', - 'saveAndClose', - 'applySettings' + 'populateLanguages', + 'loadPreferences', + 'applySettings', + 'handleTtsSystemChanged', + 'showReloadNotice', + 'toggle', + 'setupEventListeners' ]); } /** - * Initialize the module - * @returns {Promise} - Resolves with success status + * Initialize the options UI + * @returns {boolean} - True if initialization was successful */ - async initialize() { - try { - // Set up event listeners - window.addEventListener('tts-system-changed', this.handleTtsSystemChanged); + initialize() { + console.log('Initializing Options UI Module'); + + // Set up dependencies + this.dependencies = [ + 'persistence-manager', + 'localization', + 'tts-factory', + 'audio-manager' + ]; + + // Create the options modal + this.createModal(); + + // Set up event listeners + this.setupEventListeners(); + + // Add event listener for showing options UI + document.addEventListener('ui:showOptions', () => this.show()); + + // Add event listener for toggling options UI + document.addEventListener('ui:options:toggle', () => this.toggle()); + + // Wait for dependencies and populate UI with delay to ensure TTS handlers are registered + this.waitForDependencies().then(() => { + console.log('Options UI: Dependencies loaded, initializing UI with delay'); - // The option modal will be created on demand - this.reportProgress(100, "Options UI ready"); - return true; - } catch (error) { - console.error("Error initializing options UI:", error); - return false; - } + // Add a delay to ensure all TTS handlers are registered and initialized + setTimeout(() => { + // Populate TTS systems + this.populateTtsSystems(); + + // Populate languages + this.populateLanguages(); + + // Load current preferences + this.loadPreferences(); + + // Apply settings + this.applySettings(); + + console.log('Options UI: Initialization complete'); + }, 1000); // 1 second delay + }); + + return true; } /** - * Wait for dependencies to be ready - * @returns {Promise} - Resolves when dependencies are ready + * Wait for dependencies to be available + * @returns {Promise} - Resolves when dependencies are available */ - async waitForDependencies() { - try { - // Get required modules - this.persistenceManager = this.getModule('persistence-manager'); - if (!this.persistenceManager) { - console.warn("Options UI: Persistence Manager not found"); - } + waitForDependencies() { + return new Promise((resolve) => { + const checkDependencies = () => { + const persistenceManager = this.getModule('persistence-manager'); + const localization = this.getModule('localization'); + const ttsFactory = this.getModule('tts-factory'); + const audioManager = this.getModule('audio-manager'); + + if (persistenceManager && localization && ttsFactory && audioManager) { + this.persistenceManager = persistenceManager; + this.localization = localization; + this.ttsFactory = ttsFactory; + this.audioManager = audioManager; + resolve(); + } else { + setTimeout(checkDependencies, 100); + } + }; - this.localization = this.getModule('localization'); - if (!this.localization) { - console.warn("Options UI: Localization module not found"); - } - - // These dependencies are optional - UI will adapt if not available - this.ttsFactory = this.getModule('tts-factory'); - this.ttsPlayer = this.getModule('tts'); - this.audioManager = this.getModule('audio-manager'); - - return true; - } catch (error) { - console.error("Error waiting for options UI dependencies:", error); - return true; // Non-critical, can continue - } + checkDependencies(); + }); } /** - * Create the options UI elements + * Create the options modal */ createModal() { if (this.modal) return; // Create modal container this.modal = document.createElement('div'); - this.modal.className = this.config.modalClass; + this.modal.id = 'options-modal'; + this.modal.className = 'options-modal'; this.modal.style.display = 'none'; - // Create backdrop if enabled - if (this.config.backdrop) { - this.backdrop = document.createElement('div'); - this.backdrop.className = 'modal-backdrop'; - this.backdrop.addEventListener('click', () => this.hide()); - this.modal.appendChild(this.backdrop); - } - - // Create content container + // Create modal content const content = document.createElement('div'); - content.className = this.config.modalContentClass; + content.className = 'options-content'; - // Add header with title and close button + // Create header const header = document.createElement('div'); header.className = 'options-header'; @@ -123,555 +141,369 @@ class OptionsUIModule extends BaseModule { title.textContent = 'Options'; header.appendChild(title); - const closeBtn = document.createElement('button'); - closeBtn.className = 'close-button'; - closeBtn.textContent = '×'; - closeBtn.setAttribute('aria-label', 'Close options'); - closeBtn.addEventListener('click', () => this.hide()); - header.appendChild(closeBtn); + const closeButton = document.createElement('button'); + closeButton.className = 'options-close'; + closeButton.innerHTML = '×'; + closeButton.addEventListener('click', () => this.hide()); + header.appendChild(closeButton); content.appendChild(header); - // Create tabs - const tabContainer = document.createElement('div'); - tabContainer.className = 'tabs-container'; - - const tabs = document.createElement('div'); - tabs.className = 'tabs'; - - const tabGeneral = document.createElement('button'); - tabGeneral.className = 'tab active'; - tabGeneral.textContent = 'General'; - tabGeneral.dataset.tab = 'general'; - - const tabVoice = document.createElement('button'); - tabVoice.className = 'tab'; - tabVoice.textContent = 'Voice'; - tabVoice.dataset.tab = 'voice'; - - const tabAudio = document.createElement('button'); - tabAudio.className = 'tab'; - tabAudio.textContent = 'Audio'; - tabAudio.dataset.tab = 'audio'; - - const tabAccess = document.createElement('button'); - tabAccess.className = 'tab'; - tabAccess.textContent = 'Accessibility'; - tabAccess.dataset.tab = 'accessibility'; - - tabs.appendChild(tabGeneral); - tabs.appendChild(tabVoice); - tabs.appendChild(tabAudio); - tabs.appendChild(tabAccess); - - tabContainer.appendChild(tabs); - content.appendChild(tabContainer); - - // Create tab content sections - const tabContent = document.createElement('div'); - tabContent.className = 'tab-content'; - - // General tab content - const generalContent = document.createElement('div'); - generalContent.className = 'tab-pane active'; - generalContent.dataset.tab = 'general'; - - const animSpeedSection = document.createElement('div'); - animSpeedSection.className = 'option-section'; - - const animSpeedLabel = document.createElement('label'); - animSpeedLabel.textContent = 'Animation Speed'; - animSpeedLabel.htmlFor = 'option-anim-speed'; - - const animSpeedSlider = document.createElement('input'); - animSpeedSlider.type = 'range'; - animSpeedSlider.id = 'option-anim-speed'; - animSpeedSlider.min = '0'; - animSpeedSlider.max = '100'; - animSpeedSlider.value = '50'; // Will be updated from preferences - - const animSpeedValue = document.createElement('span'); - animSpeedValue.className = 'range-value'; - animSpeedValue.textContent = '50%'; - - animSpeedSlider.addEventListener('input', () => { - const val = animSpeedSlider.value; - animSpeedValue.textContent = `${val}%`; - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('animation', 'speed', parseInt(val, 10)); - } - - // Update animation queue speed if available - const animQueue = moduleRegistry.getModule('animation-queue'); - if (animQueue) { - const speed = Math.pow(100.0 - val, 3) / 10000 * 10 + 0.01; - animQueue.setSpeed(speed); - } - }); - - animSpeedSection.appendChild(animSpeedLabel); - animSpeedSection.appendChild(animSpeedSlider); - animSpeedSection.appendChild(animSpeedValue); - generalContent.appendChild(animSpeedSection); - - // Voice tab content - const voiceContent = document.createElement('div'); - voiceContent.className = 'tab-pane'; - voiceContent.dataset.tab = 'voice'; - - const ttsSysSection = document.createElement('div'); - ttsSysSection.className = 'option-section'; - - const ttsSysLabel = document.createElement('label'); - ttsSysLabel.textContent = 'TTS System'; - ttsSysLabel.htmlFor = 'option-tts-system'; - - const ttsSysSelect = document.createElement('select'); - ttsSysSelect.id = 'option-tts-system'; - - // Will populate systems dynamically later - ttsSysSection.appendChild(ttsSysLabel); - ttsSysSection.appendChild(ttsSysSelect); - voiceContent.appendChild(ttsSysSection); - - // Voice selection section - const voiceSection = document.createElement('div'); - voiceSection.className = 'option-section'; - - const voiceLabel = document.createElement('label'); - voiceLabel.textContent = 'Voice'; - voiceLabel.htmlFor = 'option-voice'; - - const voiceSelect = document.createElement('select'); - voiceSelect.id = 'option-voice'; - - // Will populate voices dynamically later - voiceSection.appendChild(voiceLabel); - voiceSection.appendChild(voiceSelect); - voiceContent.appendChild(voiceSection); - - // Voice rate section - const rateSection = document.createElement('div'); - rateSection.className = 'option-section'; - - const rateLabel = document.createElement('label'); - rateLabel.textContent = 'Speech Rate'; - rateLabel.htmlFor = 'option-speech-rate'; - - const rateSlider = document.createElement('input'); - rateSlider.type = 'range'; - rateSlider.id = 'option-speech-rate'; - rateSlider.min = '50'; - rateSlider.max = '200'; - rateSlider.value = '100'; // Will be updated from preferences - - const rateValue = document.createElement('span'); - rateValue.className = 'range-value'; - rateValue.textContent = '1.0x'; - - rateSlider.addEventListener('input', () => { - const val = rateSlider.value; - const rate = val / 100; - rateValue.textContent = `${rate.toFixed(1)}x`; - - if (this.ttsPlayer) { - this.ttsPlayer.setSpeed(rate); - } - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('tts', 'rate', rate); - } - }); - - rateSection.appendChild(rateLabel); - rateSection.appendChild(rateSlider); - rateSection.appendChild(rateValue); - voiceContent.appendChild(rateSection); - - // Audio tab content - const audioContent = document.createElement('div'); - audioContent.className = 'tab-pane'; - audioContent.dataset.tab = 'audio'; - - // Master volume section - const masterVolSection = document.createElement('div'); - masterVolSection.className = 'option-section'; - - const masterVolLabel = document.createElement('label'); - masterVolLabel.textContent = 'Master Volume'; - masterVolLabel.htmlFor = 'option-master-vol'; - - const masterVolSlider = document.createElement('input'); - masterVolSlider.type = 'range'; - masterVolSlider.id = 'option-master-vol'; - masterVolSlider.min = '0'; - masterVolSlider.max = '100'; - masterVolSlider.value = '100'; // Will be updated from preferences - - const masterVolValue = document.createElement('span'); - masterVolValue.className = 'range-value'; - masterVolValue.textContent = '100%'; - - masterVolSlider.addEventListener('input', () => { - const val = masterVolSlider.value; - masterVolValue.textContent = `${val}%`; - - if (this.audioManager) { - this.audioManager.setMasterVolume(val / 100); - } - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('audio', 'masterVolume', val / 100); - } - }); - - masterVolSection.appendChild(masterVolLabel); - masterVolSection.appendChild(masterVolSlider); - masterVolSection.appendChild(masterVolValue); - audioContent.appendChild(masterVolSection); - - // TTS volume section - const ttsVolSection = document.createElement('div'); - ttsVolSection.className = 'option-section'; - - const ttsVolLabel = document.createElement('label'); - ttsVolLabel.textContent = 'Speech Volume'; - ttsVolLabel.htmlFor = 'option-tts-vol'; - - const ttsVolSlider = document.createElement('input'); - ttsVolSlider.type = 'range'; - ttsVolSlider.id = 'option-tts-vol'; - ttsVolSlider.min = '0'; - ttsVolSlider.max = '100'; - ttsVolSlider.value = '100'; // Will be updated from preferences - - const ttsVolValue = document.createElement('span'); - ttsVolValue.className = 'range-value'; - ttsVolValue.textContent = '100%'; - - ttsVolSlider.addEventListener('input', () => { - const val = ttsVolSlider.value; - ttsVolValue.textContent = `${val}%`; - - if (this.ttsPlayer) { - this.ttsPlayer.setVolume(val / 100); - } - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('tts', 'volume', val / 100); - } - }); - - ttsVolSection.appendChild(ttsVolLabel); - ttsVolSection.appendChild(ttsVolSlider); - ttsVolSection.appendChild(ttsVolValue); - audioContent.appendChild(ttsVolSection); - - // Music volume section (for future use) - const musicVolSection = document.createElement('div'); - musicVolSection.className = 'option-section'; - - const musicVolLabel = document.createElement('label'); - musicVolLabel.textContent = 'Music Volume'; - musicVolLabel.htmlFor = 'option-music-vol'; - - const musicVolSlider = document.createElement('input'); - musicVolSlider.type = 'range'; - musicVolSlider.id = 'option-music-vol'; - musicVolSlider.min = '0'; - musicVolSlider.max = '100'; - musicVolSlider.value = '70'; // Will be updated from preferences - - const musicVolValue = document.createElement('span'); - musicVolValue.className = 'range-value'; - musicVolValue.textContent = '70%'; - - musicVolSlider.addEventListener('input', () => { - const val = musicVolSlider.value; - musicVolValue.textContent = `${val}%`; - - if (this.audioManager) { - this.audioManager.setMusicVolume(val / 100); - } - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('audio', 'musicVolume', val / 100); - } - }); - - musicVolSection.appendChild(musicVolLabel); - musicVolSection.appendChild(musicVolSlider); - musicVolSection.appendChild(musicVolValue); - audioContent.appendChild(musicVolSection); - - // SFX volume section (for future use) - const sfxVolSection = document.createElement('div'); - sfxVolSection.className = 'option-section'; - - const sfxVolLabel = document.createElement('label'); - sfxVolLabel.textContent = 'Effects Volume'; - sfxVolLabel.htmlFor = 'option-sfx-vol'; - - const sfxVolSlider = document.createElement('input'); - sfxVolSlider.type = 'range'; - sfxVolSlider.id = 'option-sfx-vol'; - sfxVolSlider.min = '0'; - sfxVolSlider.max = '100'; - sfxVolSlider.value = '100'; // Will be updated from preferences - - const sfxVolValue = document.createElement('span'); - sfxVolValue.className = 'range-value'; - sfxVolValue.textContent = '100%'; - - sfxVolSlider.addEventListener('input', () => { - const val = sfxVolSlider.value; - sfxVolValue.textContent = `${val}%`; - - if (this.audioManager) { - this.audioManager.setSfxVolume(val / 100); - } - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('audio', 'sfxVolume', val / 100); - } - }); - - sfxVolSection.appendChild(sfxVolLabel); - sfxVolSection.appendChild(sfxVolSlider); - sfxVolSection.appendChild(sfxVolValue); - audioContent.appendChild(sfxVolSection); - - // Accessibility tab content - const accessContent = document.createElement('div'); - accessContent.className = 'tab-pane'; - accessContent.dataset.tab = 'accessibility'; - - // High contrast toggle - const contrastSection = document.createElement('div'); - contrastSection.className = 'option-section checkbox-section'; - - const contrastCheckbox = document.createElement('input'); - contrastCheckbox.type = 'checkbox'; - contrastCheckbox.id = 'option-high-contrast'; - - const contrastLabel = document.createElement('label'); - contrastLabel.textContent = 'High Contrast Mode'; - contrastLabel.htmlFor = 'option-high-contrast'; - - contrastCheckbox.addEventListener('change', () => { - const isEnabled = contrastCheckbox.checked; - - // Apply high contrast class to body - if (isEnabled) { - document.body.classList.add('high-contrast'); - } else { - document.body.classList.remove('high-contrast'); - } - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('accessibility', 'highContrast', isEnabled); - } - }); - - contrastSection.appendChild(contrastCheckbox); - contrastSection.appendChild(contrastLabel); - accessContent.appendChild(contrastSection); - - // Larger text toggle - const largerTextSection = document.createElement('div'); - largerTextSection.className = 'option-section checkbox-section'; - - const largerTextCheckbox = document.createElement('input'); - largerTextCheckbox.type = 'checkbox'; - largerTextCheckbox.id = 'option-larger-text'; - - const largerTextLabel = document.createElement('label'); - largerTextLabel.textContent = 'Larger Text'; - largerTextLabel.htmlFor = 'option-larger-text'; - - largerTextCheckbox.addEventListener('change', () => { - const isEnabled = largerTextCheckbox.checked; - - // Apply larger text class to body - if (isEnabled) { - document.body.classList.add('larger-text'); - } else { - document.body.classList.remove('larger-text'); - } - - if (this.persistenceManager) { - this.persistenceManager.updatePreference('accessibility', 'largerText', isEnabled); - } - }); - - largerTextSection.appendChild(largerTextCheckbox); - largerTextSection.appendChild(largerTextLabel); - accessContent.appendChild(largerTextSection); - - // Add tab content to container - tabContent.appendChild(generalContent); - tabContent.appendChild(voiceContent); - tabContent.appendChild(audioContent); - tabContent.appendChild(accessContent); - - content.appendChild(tabContent); - - // Add buttons at the bottom - const buttons = document.createElement('div'); - buttons.className = 'options-buttons'; - - const resetButton = document.createElement('button'); - resetButton.textContent = 'Reset to Defaults'; - resetButton.className = 'reset-button'; - resetButton.addEventListener('click', () => this.resetToDefaults()); - - const saveButton = document.createElement('button'); - saveButton.textContent = 'Save & Close'; - saveButton.className = 'save-button'; - saveButton.addEventListener('click', () => this.saveAndClose()); - - buttons.appendChild(resetButton); - buttons.appendChild(saveButton); - content.appendChild(buttons); - - // Set up tab switching - tabs.addEventListener('click', (e) => { - if (e.target.classList.contains('tab')) { - // Deactivate all tabs and tab panes - Array.from(tabs.querySelectorAll('.tab')).forEach(tab => { - tab.classList.remove('active'); - }); - Array.from(tabContent.querySelectorAll('.tab-pane')).forEach(pane => { - pane.classList.remove('active'); - }); + // Create settings container + const settings = document.createElement('div'); + settings.className = 'options-settings'; + + // TTS Settings + const ttsSection = document.createElement('div'); + ttsSection.className = 'options-section'; + + const ttsTitle = document.createElement('h3'); + ttsTitle.textContent = 'Text-to-Speech'; + ttsSection.appendChild(ttsTitle); + + // TTS Toggle + const ttsSpeechToggleContainer = document.createElement('div'); + ttsSpeechToggleContainer.className = 'options-row'; + + const ttsSpeechToggleLabel = document.createElement('label'); + ttsSpeechToggleLabel.textContent = 'Enable Speech:'; + ttsSpeechToggleContainer.appendChild(ttsSpeechToggleLabel); + + const ttsSpeechToggle = document.createElement('input'); + ttsSpeechToggle.type = 'checkbox'; + ttsSpeechToggle.id = 'tts-speech-toggle'; + ttsSpeechToggle.addEventListener('change', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + const enabled = e.target.checked; + persistenceManager.updatePreference('tts', 'enabled', enabled); - // Activate clicked tab and corresponding pane - e.target.classList.add('active'); - const tabName = e.target.dataset.tab; - const pane = tabContent.querySelector(`.tab-pane[data-tab="${tabName}"]`); - if (pane) { - pane.classList.add('active'); - } - - // If switching to voice tab, ensure voices are updated - if (tabName === 'voice') { - this.populateTtsSystems(); - this.populateVoices(); - } + // Dispatch event for TTS state change + document.dispatchEvent(new CustomEvent('tts:stateChange', { + detail: { enabled: enabled } + })); } }); + ttsSpeechToggleContainer.appendChild(ttsSpeechToggle); + ttsSection.appendChild(ttsSpeechToggleContainer); + + // TTS System + const ttsSystemContainer = document.createElement('div'); + ttsSystemContainer.className = 'options-row'; + + const ttsSystemLabel = document.createElement('label'); + ttsSystemLabel.textContent = 'TTS System:'; + ttsSystemContainer.appendChild(ttsSystemLabel); + + const ttsSystem = document.createElement('select'); + ttsSystem.id = 'tts-system'; + ttsSystem.addEventListener('change', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + const ttsFactory = this.getModule('tts-factory'); + if (persistenceManager && ttsFactory) { + const provider = e.target.value; + persistenceManager.updatePreference('tts', 'provider', provider); + ttsFactory.setActiveHandler(provider); + + // Update TTS enabled state based on provider + const enabled = provider !== 'none'; + persistenceManager.updatePreference('tts', 'enabled', enabled); + + // Dispatch event for TTS state change + document.dispatchEvent(new CustomEvent('tts:stateChange', { + detail: { enabled: enabled } + })); + + this.populateVoices(); + } + }); + ttsSystemContainer.appendChild(ttsSystem); + + ttsSection.appendChild(ttsSystemContainer); + + // TTS Voice + const ttsVoiceContainer = document.createElement('div'); + ttsVoiceContainer.className = 'options-row'; + + const ttsVoiceLabel = document.createElement('label'); + ttsVoiceLabel.textContent = 'Voice:'; + ttsVoiceContainer.appendChild(ttsVoiceLabel); + + const ttsVoice = document.createElement('select'); + ttsVoice.id = 'tts-voice'; + ttsVoice.addEventListener('change', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'voice', e.target.value); + } + }); + ttsVoiceContainer.appendChild(ttsVoice); + + ttsSection.appendChild(ttsVoiceContainer); + + // Speed controls + const speedContainer = document.createElement('div'); + speedContainer.className = 'options-row'; + + const speedLabel = document.createElement('label'); + speedLabel.textContent = 'Speed:'; + speedContainer.appendChild(speedLabel); + + const speedSlider = document.createElement('input'); + speedSlider.type = 'range'; + speedSlider.min = '0'; + speedSlider.max = '100'; + speedSlider.value = '50'; // Default to 0.5 speed (50 out of 100) + speedSlider.id = 'speech-rate'; + speedSlider.addEventListener('input', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + const ttsFactory = this.getModule('tts-factory'); + if (persistenceManager && ttsFactory) { + // Convert to normalized speed (0-1 range) + const speed = parseInt(e.target.value) / 100; + + // Update persistence manager + persistenceManager.updatePreference('tts', 'speed', speed); + + // Configure the TTS factory + ttsFactory.configure({ speed: speed }); + + // Broadcast the speed change event for other components + document.dispatchEvent(new CustomEvent('tts:speed:change', { + detail: { speed: speed } + })); + } + }); + speedContainer.appendChild(speedSlider); + + ttsSection.appendChild(speedContainer); + + // Language + const languageContainer = document.createElement('div'); + languageContainer.className = 'options-row'; + + const languageLabel = document.createElement('label'); + languageLabel.textContent = 'Language:'; + languageContainer.appendChild(languageLabel); + + const language = document.createElement('select'); + language.id = 'language'; + language.addEventListener('change', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + const localization = this.getModule('localization'); + if (persistenceManager && localization) { + persistenceManager.updatePreference('app', 'locale', e.target.value); + persistenceManager.updatePreference('tts', 'language', e.target.value); + localization.setLocale(e.target.value); + this.showReloadNotice(); + } + }); + languageContainer.appendChild(language); + + ttsSection.appendChild(languageContainer); + + // Text Speed + const textSpeedContainer = document.createElement('div'); + textSpeedContainer.className = 'options-row'; + + const textSpeedLabel = document.createElement('label'); + textSpeedLabel.textContent = 'Text Speed:'; + textSpeedContainer.appendChild(textSpeedLabel); + + const textSpeed = document.createElement('input'); + textSpeed.type = 'range'; + textSpeed.min = '0'; + textSpeed.max = '100'; + textSpeed.value = '50'; + textSpeed.id = 'text-speed'; + textSpeed.addEventListener('input', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('animation', 'speed', parseInt(e.target.value)); + } + }); + textSpeedContainer.appendChild(textSpeed); + + ttsSection.appendChild(textSpeedContainer); + + settings.appendChild(ttsSection); + + // Audio Settings + const audioSection = document.createElement('div'); + audioSection.className = 'options-section'; + + const audioTitle = document.createElement('h3'); + audioTitle.textContent = 'Audio'; + audioSection.appendChild(audioTitle); + + // Master Volume + const masterVolumeContainer = document.createElement('div'); + masterVolumeContainer.className = 'options-row'; + + const masterVolumeLabel = document.createElement('label'); + masterVolumeLabel.textContent = 'Master Volume:'; + masterVolumeContainer.appendChild(masterVolumeLabel); + + const masterVolume = document.createElement('input'); + masterVolume.type = 'range'; + masterVolume.min = '0'; + masterVolume.max = '100'; + masterVolume.value = '100'; + masterVolume.id = 'master-volume'; + masterVolume.addEventListener('input', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + const audioManager = this.getModule('audio-manager'); + if (persistenceManager && audioManager) { + const volume = parseInt(e.target.value) / 100; + persistenceManager.updatePreference('audio', 'masterVolume', volume); + audioManager.setMasterVolume(volume); + } + }); + masterVolumeContainer.appendChild(masterVolume); + + audioSection.appendChild(masterVolumeContainer); + + // Speech Volume + const speechVolumeContainer = document.createElement('div'); + speechVolumeContainer.className = 'options-row'; + + const speechVolumeLabel = document.createElement('label'); + speechVolumeLabel.textContent = 'Speech Volume:'; + speechVolumeContainer.appendChild(speechVolumeLabel); + + const speechVolume = document.createElement('input'); + speechVolume.type = 'range'; + speechVolume.min = '0'; + speechVolume.max = '100'; + speechVolume.value = '100'; + speechVolume.id = 'speech-volume'; + speechVolume.addEventListener('input', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + const volume = parseInt(e.target.value) / 100; + persistenceManager.updatePreference('tts', 'volume', volume); + } + }); + speechVolumeContainer.appendChild(speechVolume); + + audioSection.appendChild(speechVolumeContainer); + + // Music Volume + const musicVolumeContainer = document.createElement('div'); + musicVolumeContainer.className = 'options-row'; + + const musicVolumeLabel = document.createElement('label'); + musicVolumeLabel.textContent = 'Music Volume:'; + musicVolumeContainer.appendChild(musicVolumeLabel); + + const musicVolume = document.createElement('input'); + musicVolume.type = 'range'; + musicVolume.min = '0'; + musicVolume.max = '100'; + musicVolume.value = '70'; + musicVolume.id = 'music-volume'; + musicVolume.addEventListener('input', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + const audioManager = this.getModule('audio-manager'); + if (persistenceManager && audioManager) { + const volume = parseInt(e.target.value) / 100; + persistenceManager.updatePreference('audio', 'musicVolume', volume); + audioManager.setMusicVolume(volume); + } + }); + musicVolumeContainer.appendChild(musicVolume); + + audioSection.appendChild(musicVolumeContainer); + + // Effects Volume + const effectsVolumeContainer = document.createElement('div'); + effectsVolumeContainer.className = 'options-row'; + + const effectsVolumeLabel = document.createElement('label'); + effectsVolumeLabel.textContent = 'Effects Volume:'; + effectsVolumeContainer.appendChild(effectsVolumeLabel); + + const effectsVolume = document.createElement('input'); + effectsVolume.type = 'range'; + effectsVolume.min = '0'; + effectsVolume.max = '100'; + effectsVolume.value = '100'; + effectsVolume.id = 'effects-volume'; + effectsVolume.addEventListener('input', (e) => { + const persistenceManager = this.getModule('persistence-manager'); + const audioManager = this.getModule('audio-manager'); + if (persistenceManager && audioManager) { + const volume = parseInt(e.target.value) / 100; + persistenceManager.updatePreference('audio', 'sfxVolume', volume); + audioManager.setSfxVolume(volume); + } + }); + effectsVolumeContainer.appendChild(effectsVolume); + + audioSection.appendChild(effectsVolumeContainer); + + settings.appendChild(audioSection); + + // Reload notice + const reloadNotice = document.createElement('div'); + reloadNotice.id = 'reload-notice'; + reloadNotice.className = 'reload-notice'; + reloadNotice.style.display = 'none'; + reloadNotice.innerHTML = '* Changes to language or speech system require a page reload to take full effect.'; + + settings.appendChild(reloadNotice); + + content.appendChild(settings); this.modal.appendChild(content); document.body.appendChild(this.modal); - // Store references to UI elements for later use + // Store references to elements this.elements = { - animSpeed: animSpeedSlider, - animSpeedValue: animSpeedValue, - ttsSystem: ttsSysSelect, - voiceSelect: voiceSelect, - speechRate: rateSlider, - speechRateValue: rateValue, - masterVolume: masterVolSlider, - masterVolumeValue: masterVolValue, - ttsVolume: ttsVolSlider, - ttsVolumeValue: ttsVolValue, - musicVolume: musicVolSlider, - musicVolumeValue: musicVolValue, - sfxVolume: sfxVolSlider, - sfxVolumeValue: sfxVolValue, - highContrast: contrastCheckbox, - largerText: largerTextCheckbox + ttsSystem, + ttsVoice, + language, + textSpeed, + masterVolume, + speechVolume, + musicVolume, + effectsVolume, + reloadNotice, + speechRate: speedSlider, + ttsSpeechToggle }; } /** - * Load current preferences into UI + * Show the options modal */ - loadPreferences() { - if (!this.persistenceManager || !this.elements) return; + show() { + if (!this.modal) return; - // Wait for dependencies - this.waitForDependencies().then(() => { - // Get current preferences - const prefs = this.persistenceManager.getAllPreferences(); - - // Animation speed - if (this.elements.animationSpeed) { - this.elements.animationSpeed.value = prefs.animation.speed; - this.elements.animationSpeedValue.textContent = prefs.animation.speed; - } - - // TTS enabled - if (this.elements.ttsEnabled) { - this.elements.ttsEnabled.checked = prefs.tts.enabled; - - // Show/hide TTS options based on enabled state - const ttsOptionsContainer = document.querySelector('.tts-options-container'); - if (ttsOptionsContainer) { - ttsOptionsContainer.style.display = prefs.tts.enabled ? 'block' : 'none'; - } - } - - // TTS system - this.populateTtsSystems(); - - // TTS volume - if (this.elements.ttsVolume) { - this.elements.ttsVolume.value = prefs.tts.volume * 100; - this.elements.ttsVolumeValue.textContent = Math.round(prefs.tts.volume * 100); - } - - // TTS rate - if (this.elements.ttsRate) { - this.elements.ttsRate.value = prefs.tts.rate * 100; - this.elements.ttsRateValue.textContent = Math.round(prefs.tts.rate * 100); - } - - // Language selection - if (this.elements.language && this.localization) { - const currentLocale = this.localization.getLocale(); - const availableLocales = this.localization.getAvailableLocales(); - - // Clear existing options - this.elements.language.innerHTML = ''; - - // Add options for each available locale - availableLocales.forEach(locale => { - const option = document.createElement('option'); - option.value = locale; - option.textContent = this.localization.getLanguageName(locale); - option.selected = locale === currentLocale; - this.elements.language.appendChild(option); - }); - } - - // Audio volumes - if (this.elements.masterVolume) { - this.elements.masterVolume.value = prefs.audio.masterVolume * 100; - this.elements.masterVolumeValue.textContent = Math.round(prefs.audio.masterVolume * 100); - } - - if (this.elements.musicVolume) { - this.elements.musicVolume.value = prefs.audio.musicVolume * 100; - this.elements.musicVolumeValue.textContent = Math.round(prefs.audio.musicVolume * 100); - } - - if (this.elements.sfxVolume) { - this.elements.sfxVolume.value = prefs.audio.sfxVolume * 100; - this.elements.sfxVolumeValue.textContent = Math.round(prefs.audio.sfxVolume * 100); - } - - // Accessibility options - if (this.elements.highContrast) { - this.elements.highContrast.checked = prefs.accessibility.highContrast; - } - - if (this.elements.largerText) { - this.elements.largerText.checked = prefs.accessibility.largerText; - } - }); + // Reload preferences before showing + this.loadPreferences(); + + // Show modal + this.modal.style.display = 'flex'; + } + + /** + * Hide the options modal + */ + hide() { + if (!this.modal) return; + this.modal.style.display = 'none'; + } + + /** + * Toggle the options modal + */ + toggle() { + if (this.modal.style.display === 'flex') { + this.hide(); + } else { + this.show(); + } } /** @@ -680,378 +512,329 @@ class OptionsUIModule extends BaseModule { populateTtsSystems() { if (!this.elements || !this.elements.ttsSystem) return; + const ttsFactory = this.getModule('tts-factory'); + if (!ttsFactory) return; + // Clear existing options this.elements.ttsSystem.innerHTML = ''; - // Get current TTS preferences - const currentProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser'); + // Add "None" option first + const noneOption = document.createElement('option'); + noneOption.value = 'none'; + noneOption.textContent = 'None (Disable TTS)'; + this.elements.ttsSystem.appendChild(noneOption); - // Get available handlers from TTS factory - let availableHandlers = {}; - if (this.ttsFactory) { - availableHandlers = this.ttsFactory.getAvailableHandlers(); - } else { - // Fallback if TTS factory not available - availableHandlers = { - browser: true, // Assume browser TTS is available - api: false, // Assume API TTS is not available - kokoro: false // Assume Kokoro is not available - }; + // Debug log for troubleshooting + console.log('Options UI: Populating TTS systems'); + + // Get available handlers + const handlers = ttsFactory.getAvailableHandlers(); + console.log('Options UI: Available handlers:', handlers); + + // Add all registered handlers + for (const id in handlers) { + // Always add the handler, even if not initialized yet + console.log(`Options UI: Adding TTS option for ${id}`); + const option = document.createElement('option'); + option.value = id; + option.textContent = this.getTtsSystemName(id); + this.elements.ttsSystem.appendChild(option); } - // Add option for each handler - const handlers = [ - { id: 'browser', name: 'Browser TTS', description: 'Uses your browser\'s built-in speech synthesis' }, - { id: 'api', name: 'API TTS', description: 'Uses a remote API for higher quality voices' }, - { id: 'kokoro', name: 'Kokoro TTS', description: 'Uses local AI-powered speech synthesis' } - ]; - - handlers.forEach(handler => { + // If no handlers available, add a disabled option + if (this.elements.ttsSystem.options.length === 1) { + console.log('Options UI: No TTS systems available, adding disabled option'); const option = document.createElement('option'); - option.value = handler.id; - - // Check if handler is available - const isAvailable = availableHandlers[handler.id] === true; - - // Format option text - option.textContent = `${handler.name}${isAvailable ? '' : ' (unavailable)'}`; - option.title = handler.description; - - // Disable option if handler is not available - option.disabled = !isAvailable; - - // Select if this is the current provider - option.selected = handler.id === currentProvider; - + option.value = ''; + option.textContent = 'No TTS systems available'; + option.disabled = true; this.elements.ttsSystem.appendChild(option); - }); - - // Populate voices for the selected system - this.populateVoices(); + } } /** - * Populate voices dropdown for current TTS system + * Get a user-friendly name for a TTS system + * @param {string} id - TTS system ID + * @returns {string} - User-friendly name + */ + getTtsSystemName(id) { + switch (id) { + case 'browser': return 'Browser TTS'; + case 'api': return 'API TTS'; + case 'kokoro': return 'Kokoro TTS'; + default: return id; + } + } + + /** + * Populate voices dropdown for the current TTS system */ populateVoices() { if (!this.elements || !this.elements.ttsVoice) return; + const ttsFactory = this.getModule('tts-factory'); + const localization = this.getModule('localization'); + if (!ttsFactory || !localization) return; + // Clear existing options this.elements.ttsVoice.innerHTML = ''; - // Get current preferences - const currentVoice = this.persistenceManager.getPreference('tts', 'voice', ''); - const currentProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser'); - // Get current locale - const currentLocale = this.localization ? this.localization.getLocale() : 'en-us'; + const currentLocale = localization.getLocale(); + const languageCode = currentLocale.split('-')[0].toLowerCase(); - // Get voices from TTS factory - let voices = []; - if (this.ttsFactory) { - // Get active handler - const activeHandler = this.ttsFactory.getActiveHandler(); - if (activeHandler) { - voices = activeHandler.getVoices(); - } - } + // Get voices from active handler + const allVoices = ttsFactory.getVoices(); - // If no voices available, add a placeholder - if (voices.length === 0) { + // Filter voices by current locale + const filteredVoices = allVoices.filter(voice => { + if (!voice.lang) return true; // Include voices without language info + const voiceLang = voice.lang.toLowerCase(); + return voiceLang.startsWith(languageCode) || languageCode.startsWith(voiceLang.split('-')[0]); + }); + + if (filteredVoices && filteredVoices.length > 0) { + // Add options for each voice + filteredVoices.forEach(voice => { + const option = document.createElement('option'); + option.value = voice.id || voice.name; + option.textContent = voice.name; + if (voice.lang) { + option.textContent += ` (${voice.lang})`; + } + this.elements.ttsVoice.appendChild(option); + }); + } else { + // No voices available for current locale const option = document.createElement('option'); option.value = ''; - option.textContent = 'No voices available'; + option.textContent = `No voices available for ${currentLocale}`; + option.disabled = true; this.elements.ttsVoice.appendChild(option); - return; } + } + + /** + * Populate languages dropdown + */ + populateLanguages() { + if (!this.elements || !this.elements.language) return; - // Sort voices by language and name - voices.sort((a, b) => { - // First sort by matching current locale - const aMatchesLocale = a.lang && a.lang.toLowerCase().startsWith(currentLocale.split('-')[0]); - const bMatchesLocale = b.lang && b.lang.toLowerCase().startsWith(currentLocale.split('-')[0]); + const localization = this.getModule('localization'); + if (!localization) return; + + // Clear existing options + this.elements.language.innerHTML = ''; + + // Get available locales from the localization module + const availableLocales = localization.getAvailableLocales(); + + // Add options for each language + availableLocales.forEach(localeCode => { + const option = document.createElement('option'); + option.value = localeCode; + option.textContent = localization.getLanguageName(localeCode); + this.elements.language.appendChild(option); + }); + + // Set current locale as selected + const currentLocale = localization.getLocale(); + if (currentLocale && this.elements.language.querySelector(`option[value="${currentLocale}"]`)) { + this.elements.language.value = currentLocale; + } + } + + /** + * Load current preferences into the UI + */ + loadPreferences() { + if (!this.persistenceManager || !this.elements) return; + + // Wait for dependencies + this.waitForDependencies().then(() => { + const prefs = this.persistenceManager.getAllPreferences(); - if (aMatchesLocale && !bMatchesLocale) return -1; - if (!aMatchesLocale && bMatchesLocale) return 1; - - // Then sort by language name - const aLang = this.getLanguageNameFromCode(a.lang); - const bLang = this.getLanguageNameFromCode(b.lang); - - if (aLang !== bLang) { - return aLang.localeCompare(bLang); + // TTS System + if (this.elements.ttsSystem) { + const provider = prefs.tts.provider; + if (provider) { + // Check if the option exists + const option = Array.from(this.elements.ttsSystem.options).find(opt => opt.value === provider); + if (option) { + this.elements.ttsSystem.value = provider; + } + } } - // Finally sort by voice name - return a.name.localeCompare(b.name); - }); - - // Group voices by language - const voicesByLang = {}; - voices.forEach(voice => { - const langCode = voice.lang || 'unknown'; - const langName = this.getLanguageNameFromCode(langCode); - - if (!voicesByLang[langName]) { - voicesByLang[langName] = []; + // TTS Voice + if (this.elements.ttsVoice) { + const voice = prefs.tts.voice; + if (voice) { + // Check if the option exists + const option = Array.from(this.elements.ttsVoice.options).find(opt => opt.value === voice); + if (option) { + this.elements.ttsVoice.value = voice; + } + } } - voicesByLang[langName].push(voice); - }); - - // Add voices grouped by language - Object.keys(voicesByLang).sort().forEach(langName => { - // Create optgroup for language - const optgroup = document.createElement('optgroup'); - optgroup.label = langName; + // Language + if (this.elements.language) { + const locale = prefs.app.locale; + if (locale) { + // Check if the option exists + const option = Array.from(this.elements.language.options).find(opt => opt.value === locale); + if (option) { + this.elements.language.value = locale; + } + } + } - // Add voices for this language - voicesByLang[langName].forEach(voice => { - const option = document.createElement('option'); - option.value = voice.name || voice.id; - option.textContent = voice.name; - option.selected = voice.name === currentVoice || voice.id === currentVoice; - optgroup.appendChild(option); - }); + // Text Speed + if (this.elements.textSpeed) { + this.elements.textSpeed.value = prefs.animation.speed; + } - this.elements.ttsVoice.appendChild(optgroup); + // Master Volume + if (this.elements.masterVolume) { + this.elements.masterVolume.value = Math.round(prefs.audio.masterVolume * 100); + } + + // Speech Volume + if (this.elements.speechVolume) { + this.elements.speechVolume.value = Math.round(prefs.tts.volume * 100); + } + + // Music Volume + if (this.elements.musicVolume) { + this.elements.musicVolume.value = Math.round(prefs.audio.musicVolume * 100); + } + + // Effects Volume + if (this.elements.effectsVolume) { + this.elements.effectsVolume.value = Math.round(prefs.audio.sfxVolume * 100); + } + + // Speech Rate + if (this.elements.speechRate) { + this.elements.speechRate.value = Math.round(prefs.tts.speed * 100); + } + + // TTS Speech Toggle + if (this.elements.ttsSpeechToggle) { + this.elements.ttsSpeechToggle.checked = prefs.tts.enabled; + } }); } /** - * Get language name from language code - * @param {string} code - Language code (e.g., 'en', 'de') - * @returns {string} - Language name - */ - getLanguageNameFromCode(code) { - // Use localization module if available - if (this.localization && typeof this.localization.getLanguageName === 'function') { - return this.localization.getLanguageName(code); - } - - // Fallback language names - const languageNames = { - 'en': 'English', - 'de': 'German', - 'fr': 'French', - 'es': 'Spanish', - 'it': 'Italian', - 'ja': 'Japanese', - 'ko': 'Korean', - 'zh': 'Chinese', - 'ru': 'Russian', - 'ar': 'Arabic', - 'hi': 'Hindi', - 'pt': 'Portuguese', - 'nl': 'Dutch', - 'pl': 'Polish', - 'sv': 'Swedish', - 'tr': 'Turkish', - 'uk': 'Ukrainian' - }; - - return languageNames[code] || code.toUpperCase(); - } - - /** - * Show the options UI - */ - show() { - if (!this.modal) { - this.createModal(); - } - - // Load current preferences - this.loadPreferences(); - - // Populate TTS systems and voices - this.populateTtsSystems(); - this.populateVoices(); - - // Show the modal - this.modal.style.display = 'flex'; - this.isOpen = true; - } - - /** - * Hide the options UI - */ - hide() { - if (this.modal) { - this.modal.style.display = 'none'; - this.isOpen = false; - } - } - - /** - * Toggle the options UI visibility - */ - toggle() { - if (this.isOpen) { - this.hide(); - } else { - this.show(); - } - } - - /** - * Handle TTS system changes - * @param {CustomEvent} event - The event containing TTS system change details - */ - handleTtsSystemChanged(event) { - console.log("TTS system changed:", event.detail); - - if (this.isOpen) { - // Refresh the voices list if the options UI is currently open - this.populateVoices(); - } - } - - /** - * Reset all options to defaults - */ - resetToDefaults() { - if (!this.persistenceManager) return; - - const confirmed = confirm('Reset all options to default values?'); - if (confirmed) { - // Reset preferences - this.persistenceManager.resetPreferences(); - - // Update UI - this.loadPreferences(); - - // Apply changes - this.applySettings(); - - // Refresh voice list - this.populateVoices(); - } - } - - /** - * Save settings and close modal - */ - saveAndClose() { - if (this.persistenceManager && this.elements) { - // Save preferences - already saved as they change - - // Apply settings - this.applySettings(); - } - - this.hide(); - } - - /** - * Apply current settings to the app + * Apply settings to the game */ applySettings() { if (!this.persistenceManager) return; - // Apply animation speed - const animSpeed = this.persistenceManager.getPreference('animation', 'speed', 50); - const animQueue = moduleRegistry.getModule('animation-queue'); - if (animQueue) { - const speed = Math.pow(100.0 - animSpeed, 3) / 10000 * 10 + 0.01; - animQueue.setSpeed(speed); - } - - // Apply TTS settings - const ttsEnabled = this.persistenceManager.getPreference('tts', 'enabled', false); - const ttsProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser'); - const ttsVoice = this.persistenceManager.getPreference('tts', 'voice', ''); - const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0); - const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0); - - if (this.ttsFactory) { - // Set TTS provider if it's available - const availableHandlers = this.ttsFactory.getAvailableHandlers(); - if (ttsProvider && availableHandlers[ttsProvider]) { - this.ttsFactory.setActiveHandler(ttsProvider); + this.waitForDependencies().then(() => { + const prefs = this.persistenceManager.getAllPreferences(); + + // Apply TTS settings + const ttsFactory = this.getModule('tts-factory'); + if (ttsFactory) { + // Set active handler + const provider = this.elements.ttsSystem.value; + ttsFactory.setActiveHandler(provider); + + // Update TTS state + const enabled = provider !== 'none'; + document.dispatchEvent(new CustomEvent('tts:stateChange', { + detail: { enabled: enabled } + })); + + // Update persistence + this.persistenceManager.updatePreference('tts', 'provider', provider); + this.persistenceManager.updatePreference('tts', 'enabled', enabled); } - // Get the active handler - const activeHandler = this.ttsFactory.getActiveHandler(); - if (activeHandler) { - // Set voice if specified - if (ttsVoice) { - activeHandler.setVoice(ttsVoice); + // Apply language settings + const localization = this.getModule('localization'); + if (localization && this.elements.language) { + const currentLocale = localization.getLocale(); + // Update the UI to match the current locale + if (currentLocale && this.elements.language.value !== currentLocale) { + this.elements.language.value = currentLocale; } - - // Set options - activeHandler.setOptions({ - volume: ttsVolume, - rate: ttsRate - }); } - } - - // Apply language settings - if (this.localization && this.elements && this.elements.language) { - const selectedLocale = this.elements.language.value; - if (selectedLocale && selectedLocale !== this.localization.getLocale()) { - this.localization.setLocale(selectedLocale); + + // Apply audio settings + const audioManager = this.getModule('audio-manager'); + if (audioManager) { + audioManager.setMasterVolume(prefs.audio.masterVolume); + audioManager.setMusicVolume(prefs.audio.musicVolume); + audioManager.setSfxVolume(prefs.audio.sfxVolume); } - } - - // Apply audio volume settings - const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0); - const musicVolume = this.persistenceManager.getPreference('audio', 'musicVolume', 0.7); - const sfxVolume = this.persistenceManager.getPreference('audio', 'sfxVolume', 1.0); - - if (this.audioManager) { - this.audioManager.setMasterVolume(masterVolume); - this.audioManager.setMusicVolume(musicVolume); - this.audioManager.setSfxVolume(sfxVolume); - } - - // Apply accessibility settings - const highContrast = this.persistenceManager.getPreference('accessibility', 'highContrast', false); - const largerText = this.persistenceManager.getPreference('accessibility', 'largerText', false); - - if (highContrast) { - document.body.classList.add('high-contrast'); - } else { - document.body.classList.remove('high-contrast'); - } - - if (largerText) { - document.body.classList.add('larger-text'); - } else { - document.body.classList.remove('larger-text'); - } + }); } /** - * Set the TTS factory reference - * @param {Object} factory - The TTS factory instance + * Handle TTS system changed event */ - setTtsFactory(factory) { - this.ttsFactory = factory; + handleTtsSystemChanged() { + this.populateVoices(); } /** - * Update available TTS systems info - * @param {Object} systemsInfo - Information about available TTS systems + * Show reload notice */ - updateAvailableSystems(systemsInfo) { - // Will repopulate next time UI is opened - console.log("TTS systems info updated:", systemsInfo); + showReloadNotice() { + if (!this.elements || !this.elements.reloadNotice) return; - // If the options UI is currently open, update it - if (this.isOpen) { - this.populateTtsSystems(); + this.elements.reloadNotice.style.display = 'block'; + this.reloadRequired = true; + } + + setupEventListeners() { + // Listen for language change events + document.addEventListener('localization:languageChanged', () => { + // Update the language selection in options panel + const localization = this.getModule('localization'); + if (localization && this.elements && this.elements.language) { + const currentLocale = localization.getLocale(); + if (currentLocale && this.elements.language.value !== currentLocale) { + this.elements.language.value = currentLocale; + } + } + + // Re-populate TTS voices for new language this.populateVoices(); - } - } - - /** - * Clean up when module is disposed - */ - dispose() { - // Remove event listeners - window.removeEventListener('tts-system-changed', this.handleTtsSystemChanged); + }); + + // Listen for TTS availability events + document.addEventListener('tts:availability', (event) => { + if (!this.elements) return; + + const available = event.detail?.available || false; + + // Update the TTS options visibility + if (this.elements.ttsSection) { + this.elements.ttsSection.style.display = available ? 'block' : 'none'; + } + + // Update the TTS system dropdown + this.populateTtsSystems(); + }); + + // Listen for Kokoro voice updates + document.addEventListener('kokoro:voices-updated', () => { + // Repopulate the voices dropdown when Kokoro voices become available + this.populateVoices(); + }); + + // Browser window resize event + window.addEventListener('resize', () => { + // Update modal positioning + if (this.modal && this.modal.style.display === 'block') { + this.positionModal(); + } + }); } } @@ -1062,7 +845,4 @@ const OptionsUI = new OptionsUIModule(); moduleRegistry.register(OptionsUI); // Export the module -export { OptionsUI }; - -// Keep a reference in window for loader system -window.OptionsUI = OptionsUI; \ No newline at end of file +export { OptionsUI }; \ No newline at end of file diff --git a/public/js/text-processor.js b/public/js/text-processor.js index 2471cc9..41cfca2 100644 --- a/public/js/text-processor.js +++ b/public/js/text-processor.js @@ -181,7 +181,15 @@ class TextProcessorModule extends BaseModule { // Define a custom loader for the patterns loader: (file) => { return new Promise((resolve, reject) => { - const patternPath = `/js/patterns/${file}`; + // Determine correct pattern file based on locale + let patternFile = file; + + // Special handling for 'en' locale - use en-us.wasm if available + if (file === 'en.wasm') { + patternFile = 'en-us.wasm'; + } + + const patternPath = `/js/patterns/${patternFile}`; console.log(`Loading hyphenation pattern: ${patternPath}`); fetch(patternPath) diff --git a/public/js/tts-factory.js b/public/js/tts-factory.js index 4160573..1cf70a0 100644 --- a/public/js/tts-factory.js +++ b/public/js/tts-factory.js @@ -15,21 +15,33 @@ class TTSFactoryModule extends BaseModule { constructor() { super('tts-factory', 'TTS Factory'); - // Available TTS handlers + this.dependencies = ['persistence-manager', 'localization']; this.handlers = {}; - - // Current active handler + this.initStatus = {}; this.activeHandler = null; - - // Handler initialization status - this.initStatus = { - browser: false, - api: false, - kokoro: false - }; - - // TTS availability flag this.ttsAvailable = false; + this.speed = 1; // Default speed + + // Listen for kokoro:ready event + document.addEventListener('kokoro:ready', (event) => { + if (event.detail && typeof event.detail.success === 'boolean') { + console.log('TTS Factory: Received kokoro:ready event with success =', event.detail.success); + this.initStatus['kokoro'] = event.detail.success; + + // If this is the current active handler or we don't have an active handler yet, + // try to activate Kokoro if it's now ready + if ((this.activeHandler === 'kokoro' || !this.activeHandler) && event.detail.success) { + // Only attempt to set active handler if TTS is enabled + const ttsEnabled = this.getPreference('tts', 'enabled', false); + if (ttsEnabled) { + this.setActiveHandler('kokoro'); + } + } + + // Update overall TTS availability + this.updateTTSAvailability(); + } + }); // Bind methods this.bindMethods([ @@ -45,11 +57,9 @@ class TTSFactoryModule extends BaseModule { 'resume', 'getVoices', 'getPreference', - 'isSpeaking' + 'isSpeaking', + 'configure' ]); - - // Add dependencies - this.dependencies = ['persistence-manager', 'localization']; } /** @@ -75,19 +85,37 @@ class TTSFactoryModule extends BaseModule { this.registerHandler('api', new ApiTTSHandler()); this.registerHandler('kokoro', new KokoroHandler()); + console.log('TTS Factory: Registered handlers:', Object.keys(this.handlers)); this.reportProgress(30, "Registered TTS handlers"); + // Force the initialization of all handlers for diagnostics + // This ensures they're all initialized even if not selected + const initPromises = []; + for (const id of Object.keys(this.handlers)) { + console.log(`TTS Factory: Initializing handler ${id}`); + initPromises.push(this.initializeHandler(id).then(success => { + console.log(`TTS Factory: Handler ${id} initialization ${success ? 'succeeded' : 'failed'}`); + return { id, success }; + })); + } + + // Wait for all handlers to initialize + const results = await Promise.all(initPromises); + console.log('TTS Factory: All handler initialization results:', results); + // Get user preferences const ttsEnabled = this.getPreference('tts', 'enabled', false); const preferredProvider = this.getPreference('tts', 'provider', 'browser'); + console.log(`TTS Factory: User preferences - enabled: ${ttsEnabled}, provider: ${preferredProvider}`); + // Initialize handlers based on preferences let initSuccess = false; if (ttsEnabled) { // Try to initialize preferred handler first this.reportProgress(50, `Initializing preferred TTS handler: ${preferredProvider}`); - initSuccess = await this.initializeHandler(preferredProvider); + initSuccess = this.initStatus[preferredProvider] || false; if (initSuccess) { this.setActiveHandler(preferredProvider); @@ -96,71 +124,44 @@ class TTSFactoryModule extends BaseModule { console.warn(`Failed to initialize preferred TTS handler: ${preferredProvider}, trying alternatives`); // Try Kokoro TTS as fallback if not already tried - if (preferredProvider !== 'kokoro') { - this.reportProgress(60, "Trying Kokoro TTS as fallback"); - initSuccess = await this.initializeHandler('kokoro'); - if (initSuccess) { - this.setActiveHandler('kokoro'); - // Update preference to Kokoro since it worked - this.getModule('persistence-manager').updatePreference('tts', 'provider', 'kokoro'); - } + if (preferredProvider !== 'kokoro' && this.initStatus.kokoro) { + this.reportProgress(60, "Using Kokoro TTS as fallback"); + this.setActiveHandler('kokoro'); + // Update preference to Kokoro since it worked + this.getModule('persistence-manager').updatePreference('tts', 'provider', 'kokoro'); + initSuccess = true; } - - // If Kokoro TTS failed, try Browser TTS - if (!initSuccess && preferredProvider !== 'browser') { - this.reportProgress(70, "Trying Browser TTS as fallback"); - initSuccess = await this.initializeHandler('browser'); - if (initSuccess) { - this.setActiveHandler('browser'); - // Update preference to browser since it worked - this.getModule('persistence-manager').updatePreference('tts', 'provider', 'browser'); - } + // Try Browser TTS as fallback if not already tried + else if (preferredProvider !== 'browser' && this.initStatus.browser) { + this.reportProgress(70, "Using Browser TTS as fallback"); + this.setActiveHandler('browser'); + // Update preference to Browser since it worked + this.getModule('persistence-manager').updatePreference('tts', 'provider', 'browser'); + initSuccess = true; + } + else { + // If all failed, disable TTS + this.reportProgress(80, "All TTS handlers failed, disabling TTS"); + this.getModule('persistence-manager').updatePreference('tts', 'enabled', false); + this.getModule('persistence-manager').updatePreference('tts', 'provider', 'none'); } - - // Note: API TTS is not used as a fallback as it requires manual configuration } - } else { - // Even if TTS is disabled, initialize handlers in the background - // so they're ready if the user enables TTS later - this.reportProgress(50, "TTS disabled, initializing handlers in background"); - - // Initialize Kokoro and Browser handlers in parallel (not API as it requires configuration) - const initPromises = [ - this.initializeHandler('kokoro'), - this.initializeHandler('browser') - ]; - - // Wait for all handlers to initialize - await Promise.allSettled(initPromises); - - // Check if any handler initialized successfully - initSuccess = this.initStatus.kokoro || this.initStatus.browser; } - // Set TTS availability flag and dispatch event - this.ttsAvailable = initSuccess; + // Determine overall TTS availability + this.ttsAvailable = this.initStatus.kokoro || this.initStatus.browser; - // Dispatch event to notify UI about TTS availability - document.dispatchEvent(new CustomEvent('tts:availability', { + // Dispatch TTS availability event + window.dispatchEvent(new CustomEvent('tts:availability', { detail: { available: this.ttsAvailable } })); - this.reportProgress(100, initSuccess ? "TTS factory ready" : "TTS factory ready (no handlers available)"); - - // Always return true since TTS is optional for the application - return true; + this.reportProgress(100, "TTS factory initialized"); + return true; // TTS is optional, so always return true } catch (error) { - console.error("Error initializing TTS factory:", error); + console.error("TTS Factory: Error during initialization:", error); this.reportProgress(100, "TTS factory failed"); - - // Set TTS availability to false and dispatch event - this.ttsAvailable = false; - document.dispatchEvent(new CustomEvent('tts:availability', { - detail: { available: false } - })); - - // Still return true since TTS is optional - return true; + return true; // TTS is optional, so always return true } } @@ -228,27 +229,43 @@ class TTSFactoryModule extends BaseModule { * @returns {boolean} - Success status */ setActiveHandler(id) { - if (!id || !this.handlers[id] || !this.initStatus[id]) { - console.warn(`Cannot set active handler to ${id}: handler not found or not initialized`); + // Handle 'none' option specially + if (id === 'none') { + this.activeHandler = null; + + // Update TTS availability state + this.ttsAvailable = false; + + // Notify about TTS availability change + document.dispatchEvent(new CustomEvent('tts:availability', { + detail: { available: false } + })); + + console.log("TTS Factory: TTS disabled (none selected)"); + return true; + } + + if (!this.handlers[id]) { + console.error(`TTS Factory: Handler not found: ${id}`); return false; } - // Stop current handler if active - if (this.activeHandler) { - this.handlers[this.activeHandler].stop(); + if (!this.initStatus[id]) { + console.error(`TTS Factory: Handler not initialized: ${id}`); + return false; } - // Set new active handler this.activeHandler = id; - // Update preference - this.getModule('persistence-manager').updatePreference('tts', 'provider', id); + // Update TTS availability state + this.ttsAvailable = true; - // Dispatch event - this.dispatchEvent('tts-handler-changed', { - handler: id - }); + // Notify about TTS availability change + document.dispatchEvent(new CustomEvent('tts:availability', { + detail: { available: true } + })); + console.log(`TTS Factory: Active handler set to ${id}`); return true; } @@ -268,8 +285,16 @@ class TTSFactoryModule extends BaseModule { getAvailableHandlers() { const available = {}; + // Debug logging for diagnostic purposes + console.log('TTS Factory: getAvailableHandlers called'); + console.log('TTS Factory: Current initialization status:', this.initStatus); + console.log('TTS Factory: Registered handlers:', Object.keys(this.handlers).join(', ')); + for (const id in this.handlers) { - available[id] = this.initStatus[id]; + // Add the handler to the available list even if it's not initialized yet + // This ensures all registered handlers appear in the options + available[id] = true; + console.log(`TTS Factory: Including handler ${id} in options`); } return available; @@ -387,6 +412,60 @@ class TTSFactoryModule extends BaseModule { } } + /** + * Update overall TTS availability + */ + updateTTSAvailability() { + this.ttsAvailable = this.initStatus.kokoro || this.initStatus.browser; + + // Dispatch TTS availability event + window.dispatchEvent(new CustomEvent('tts:availability', { + detail: { available: this.ttsAvailable } + })); + } + + /** + * Configure TTS settings for all handlers + * @param {Object} options - TTS options + * @param {number} [options.speed] - Normalized speech rate (0-1 range) + */ + configure(options = {}) { + // If speed is provided, convert the normalized speed (0-1) to the appropriate scale for each handler + if (typeof options.speed === 'number') { + const normalizedSpeed = Math.max(0, Math.min(1, options.speed)); + + // Scale for each handler type + for (const id in this.handlers) { + // Ensure the handler exists and has the setVoiceOptions method + if (this.handlers[id] && typeof this.handlers[id].setVoiceOptions === 'function') { + let scaledOptions = {}; + + // Scale the speed value appropriately for each handler type + if (id === 'browser') { + // Browser TTS uses rate from 0.1 to 2.0 + scaledOptions.rate = 0.1 + (normalizedSpeed * 1.9); + } else if (id === 'kokoro') { + // Kokoro uses rate from 0.5 to 1.5 + scaledOptions.rate = 0.5 + (normalizedSpeed); + } else if (id === 'api') { + // API uses speed from 0.5 to 2.0 + scaledOptions.speed = 0.5 + (normalizedSpeed * 1.5); + } + + // Apply the scaled options to the handler + this.handlers[id].setVoiceOptions(scaledOptions); + } + } + + // Store the normalized value + this.speed = normalizedSpeed; + + console.log(`TTS Factory: Speed set to ${normalizedSpeed} (normalized), ${Math.round(normalizedSpeed * 100)}/100`); + } + + return true; + } + /** * Clean up when module is disposed */ diff --git a/public/js/ui-controller.js b/public/js/ui-controller.js index 2f770a9..26522b7 100644 --- a/public/js/ui-controller.js +++ b/public/js/ui-controller.js @@ -166,41 +166,102 @@ class UIController extends BaseModule { } setupEventListeners() { - // Listen for command events from input handler - use arrow function to preserve context - document.addEventListener('ui:command', (event) => { - this.handleCommand(event.detail); - }); + // Set up event listeners for menu buttons + const saveButton = document.getElementById('save'); + const loadButton = document.getElementById('reload'); + const restartButton = document.getElementById('rewind'); + const speechToggle = document.getElementById('speech'); + const optionsButton = document.getElementById('options'); - // Listen for text display events - use arrow function to preserve context - document.addEventListener('ui:text:complete', (event) => { - console.log('UIController: Text complete event received, ready for next text'); - }); + // Get persistence manager module + const persistenceManager = this.getModule('persistence-manager'); - // Listen for socket connection events - document.addEventListener('socket:connected', () => { - console.log('UIController: Socket connected'); - this.updateButtonStates(); - }); + // Set up save button + if (saveButton) { + saveButton.addEventListener('click', () => { + document.dispatchEvent(new CustomEvent('ui:game:save')); + }); + } - document.addEventListener('socket:disconnected', () => { - console.log('UIController: Socket disconnected'); - this.updateButtonStates(); - }); + // Set up load button + if (loadButton) { + loadButton.addEventListener('click', () => { + document.dispatchEvent(new CustomEvent('ui:game:load')); + }); + } - // Listen for TTS state change events - document.addEventListener('tts:stateChange', (event) => { - if (event.detail) { - if (typeof event.detail.enabled === 'boolean') { - this.ttsEnabled = event.detail.enabled; - } - if (typeof event.detail.available === 'boolean') { - this.ttsAvailable = event.detail.available; - } + // Set up restart button + if (restartButton) { + restartButton.addEventListener('click', () => { + document.dispatchEvent(new CustomEvent('ui:game:restart')); + }); + } + + // Set up speech toggle button + if (speechToggle) { + // Initialize ttsEnabled from persistence manager + if (persistenceManager) { + const prefs = persistenceManager.getAllPreferences(); + this.ttsEnabled = prefs.tts?.enabled ?? false; + + // Update button state this.updateButtonStates(); } + + speechToggle.addEventListener('click', () => { + // Toggle TTS state + this.ttsEnabled = !this.ttsEnabled; + + // Update UI + this.updateButtonStates(); + + // Save preference + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled); + } + + // Notify other components + document.dispatchEvent(new CustomEvent('ui:tts:toggle', { + detail: { enabled: this.ttsEnabled } + })); + }); + } + + // Set up options button + if (optionsButton) { + optionsButton.addEventListener('click', () => { + document.dispatchEvent(new CustomEvent('ui:options:toggle')); + }); + } + + // Listen for book events + document.addEventListener('book:ready', () => { + this.updateButtonStates({ + canSave: true, + canLoad: true, + canRestart: true + }); }); - // Listen for TTS availability events + // Listen for restart events + document.addEventListener('story:restart', () => { + this.updateButtonStates({ + canSave: true, + canLoad: false, + canRestart: false + }); + }); + + // Listen for save events + document.addEventListener('story:save', () => { + this.updateButtonStates({ + canSave: true, + canLoad: true, + canRestart: true + }); + }); + + // Listen for TTS availability changes document.addEventListener('tts:availability', (event) => { if (event.detail && typeof event.detail.available === 'boolean') { this.ttsAvailable = event.detail.available; @@ -208,45 +269,92 @@ class UIController extends BaseModule { } }); - // Add options button to controls section - const controlsSection = document.getElementById('controls'); - if (controlsSection) { - // Check if options button already exists - if (!document.getElementById('options-button')) { - const optionsButton = document.createElement('a'); - optionsButton.id = 'options-button'; - optionsButton.href = '#'; - optionsButton.textContent = 'options'; - optionsButton.title = 'Show game options'; - optionsButton.className = 'control-button'; - optionsButton.addEventListener('click', (e) => { - e.preventDefault(); - document.dispatchEvent(new CustomEvent('ui:showOptions')); - }); - controlsSection.appendChild(optionsButton); - } - - // Add speech toggle button - const speechToggle = document.getElementById('speech-toggle'); - if (speechToggle) { - speechToggle.addEventListener('click', (e) => { - e.preventDefault(); - // Dispatch an event for the TTS module to handle instead of calling directly - document.dispatchEvent(new CustomEvent('tts:toggle')); - }); - } - } - - // Listen for window resize events - window.addEventListener('resize', () => { - this.applyBookSizing(); + // Listen for TTS engine changes + document.addEventListener('tts:engine:change', (event) => { + // Update button states since TTS engine changed + this.updateButtonStates(); }); - // Listen for key events - document.addEventListener('keydown', (event) => { - // Pass to input handler - if (this.inputHandler) { - this.inputHandler.handleKeyboardInput(event); + // Listen for TTS toggle events from other components + document.addEventListener('tts:enabled:change', (event) => { + if (event.detail && typeof event.detail.enabled === 'boolean') { + this.ttsEnabled = event.detail.enabled; + this.updateButtonStates(); + + // Ensure persistence is updated + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled); + } + } + }); + + // Set up speed slider in main UI + const speedSlider = document.getElementById('speed'); + const speedReset = document.getElementById('speed_reset'); + + if (speedSlider) { + // Initialize speed from persistence manager + if (persistenceManager) { + const prefs = persistenceManager.getAllPreferences(); + // Get the unified speed value (0-1 range) + const speed = prefs.tts?.speed ?? 0.5; + // Convert to slider range (0-100) + speedSlider.value = Math.round(speed * 100); + } + + speedSlider.addEventListener('input', (e) => { + // Convert slider value (0-100) to normalized speed (0-1) + const speed = parseInt(e.target.value) / 100; + + // Scale for different TTS engines + // This value is used for real-time preview only + const rate = this.ttsEnabled ? speed * 2 : 1; + + // Update animation speed + document.dispatchEvent(new CustomEvent('animation:speed:change', { + detail: { speed: rate } + })); + + // Update TTS speed + document.dispatchEvent(new CustomEvent('tts:speed:change', { + detail: { speed: speed } + })); + + // Save preference + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'speed', speed); + } + }); + } + + if (speedReset) { + speedReset.addEventListener('click', () => { + // Reset to default speed (0.5) + if (speedSlider) { + // Default value is 0.5 in normalized form (0-1), + // which is 50 in slider range (0-100) + speedSlider.value = 50; + + // Trigger the input event to update all components + speedSlider.dispatchEvent(new Event('input')); + } + }); + } + + // Listen for speed change events from other components + document.addEventListener('tts:speed:change', (event) => { + if (event.detail && typeof event.detail.speed === 'number') { + // Update the main UI speed slider + const speedSlider = document.getElementById('speed'); + if (speedSlider) { + // Convert normalized speed (0-1) to slider range (0-100) + speedSlider.value = Math.round(event.detail.speed * 100); + } + + // Save to persistence manager + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'speed', event.detail.speed); + } } }); } @@ -342,7 +450,6 @@ class UIController extends BaseModule { /** * Update UI button states based on game state - * @param {Object} state - Game state information */ updateButtonStates(state = {}) { const { canSave, canLoad, canRestart } = state; @@ -351,7 +458,7 @@ class UIController extends BaseModule { const saveButton = document.getElementById('save'); const loadButton = document.getElementById('reload'); const restartButton = document.getElementById('rewind'); - const speechToggle = document.getElementById('speech-toggle'); + const speechToggle = document.getElementById('speech'); // Update save button state if (saveButton) { @@ -382,21 +489,25 @@ class UIController extends BaseModule { // Update speech toggle button state if (speechToggle) { - // Update the button appearance based on TTS state - if (this.ttsEnabled) { - speechToggle.classList.add('active'); - speechToggle.title = 'Disable speech'; - } else { - speechToggle.classList.remove('active'); - speechToggle.title = 'Enable speech'; - } - - // Disable the button completely if TTS is not available - if (this.ttsAvailable === false) { + // Update the button appearance based on TTS state using existing styles + if (!this.ttsAvailable) { + // TTS is not available, disable the button speechToggle.setAttribute('disabled', 'disabled'); - speechToggle.title = 'Speech not available'; + speechToggle.title = 'Text-to-speech is not available'; } else { + // TTS is available, remove disabled attribute speechToggle.removeAttribute('disabled'); + + // Update based on whether TTS is enabled + if (this.ttsEnabled) { + speechToggle.style.fontWeight = 'bold'; + speechToggle.style.color = '#000'; + speechToggle.title = 'Disable speech'; + } else { + speechToggle.style.fontWeight = 'normal'; + speechToggle.style.color = '#999'; + speechToggle.title = 'Enable speech'; + } } } } diff --git a/public/js/ui-display-handler.js b/public/js/ui-display-handler.js index 4061cf4..33e19c2 100644 --- a/public/js/ui-display-handler.js +++ b/public/js/ui-display-handler.js @@ -243,6 +243,7 @@ class UIDisplayHandler extends BaseModule { restart save load + options `; this.pageLeft.appendChild(controls); diff --git a/public/kokoro-loader.html b/public/kokoro-loader.html new file mode 100644 index 0000000..efde23f --- /dev/null +++ b/public/kokoro-loader.html @@ -0,0 +1,275 @@ + + + + + + Kokoro Loader + + + +
Loading Kokoro...
+
+ + + + diff --git a/public/locales/de-de.json b/public/locales/de-de.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/public/locales/de-de.json @@ -0,0 +1 @@ +{} diff --git a/public/locales/en-gb.json b/public/locales/en-gb.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/public/locales/en-gb.json @@ -0,0 +1 @@ +{} diff --git a/public/locales/en-us.json b/public/locales/en-us.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/public/locales/en-us.json @@ -0,0 +1 @@ +{}