/** * Options UI Module * Provides the options UI for the game */ import { BaseModule } from './base-module.js'; import { createUIElement, populateDropdown, registerHandler, createPreferenceBinding } from './ui-helper.js'; class OptionsUIModule extends BaseModule { /** * Create a new options UI module */ constructor() { super('options-ui', 'Options UI'); // Set up dependencies this.dependencies = [ 'persistence-manager', 'localization', 'tts-factory', 'audio-manager', 'game-config' ]; // Modal element this.modal = null; // UI elements this.elements = {}; // Settings that require reload this.reloadRequired = false; // Bind methods this.bindMethods([ 'show', 'hide', 'createModal', 'populateTtsSystems', 'populateVoices', 'ensureSelectedVoiceIsAvailable', 'updateVoiceControlVisibility', 'populateLanguages', 'loadPreferences', 'createVolumeControl', 'updateVolumeToggleButtons', 'updateVolumeToggleButton', 'showReloadNotice', 'toggle', 'setupEventListeners', 'setupApiUrlFields', 'setupInitialState', 'dispatchApiChangeEvent', 'getMetadataNumber', 'hasFixedBookPageCount', 'hasFixedPageReserve', 'getPreference', 'updatePreference', 'updateUIText', 'renderProviderStatuses', 'updateWebGLDisplays' ]); } t(key, params = {}) { const localization = this.getModule('localization'); return localization?.translate?.(key, params) || key; } updateUIText() { if (!this.modal) return; const wasOpen = this.modal.style.display === 'flex'; this.modal.remove(); this.modal = null; this.elements = {}; this.createModal(); this.setupPreferenceBindings(); this.populateTtsSystems(); this.populateLanguages(); this.populateVoices(); this.renderProviderStatuses(); if (wasOpen) this.show(); } /** * Dispatches an API change event * @param {string} eventType - Event type (e.g. 'api:key:change') * @param {string} provider - Provider name (e.g. 'elevenlabs') * @param {string} valueType - Value type (e.g. 'key', 'url') * @param {string} value - Value to dispatch */ dispatchApiChangeEvent(eventType, provider, valueType, value) { const eventName = `tts:${eventType}`; console.log(`Options UI: Dispatching event ${eventName} for provider ${provider}`); document.dispatchEvent(new CustomEvent(eventName, { detail: { provider, [valueType]: value } })); } getMetadataNumber(keys = []) { const gameConfig = this.getModule('game-config'); const metadata = gameConfig?.getMetadata?.() || {}; for (const key of keys) { if (!Object.prototype.hasOwnProperty.call(metadata, key)) continue; const value = Number(metadata[key]); if (Number.isFinite(value)) return value; } return null; } hasFixedBookPageCount() { return Number.isFinite(this.getMetadataNumber(['bookPageCount', 'defaultBookPageCount', 'webglBookPageCount'])); } hasFixedPageReserve() { return Number.isFinite(this.getMetadataNumber(['pageReserve', 'defaultPageReserve', 'webglPageReserve'])); } /** * Gets a preference from the persistence manager * @param {string} category - Preference category * @param {string} key - Preference key * @param {*} defaultValue - Default value if preference doesn't exist * @returns {*} - Preference value */ getPreference(category, key, defaultValue) { const persistenceManager = this.getModule('persistence-manager'); return persistenceManager.getPreference(category, key, defaultValue); } /** * Updates a preference in the persistence manager * @param {string} category - Preference category * @param {string} key - Preference key * @param {*} value - Value to set */ updatePreference(category, key, value) { const persistenceManager = this.getModule('persistence-manager'); persistenceManager.updatePreference(category, key, value); } /** * Initialize the Options UI module * @returns {Promise} - Promise resolves with initialization success */ async initialize() { console.log('Options UI: Initializing'); // Create DOM elements this.createModal(); // Set up event listeners this.setupEventListeners(); // Set up API URL fields this.setupApiUrlFields(); // Set up initial state await this.setupInitialState(); // Set up automatic bindings using the persistence manager this.setupPreferenceBindings(); this.reportProgress(100, 'Options UI initialized'); return true; } /** * Create the options modal */ createModal() { console.log('Options UI: Creating options modal'); // Create modal container this.modal = document.createElement('div'); this.modal.id = 'options-modal'; this.modal.className = 'modal'; this.modal.style.display = 'none'; // Create modal content const modalContent = document.createElement('div'); modalContent.className = 'modal-content'; // Create header const header = document.createElement('div'); header.className = 'modal-header'; const title = document.createElement('h2'); title.textContent = this.t('options.title'); header.appendChild(title); const closeButton = document.createElement('span'); closeButton.className = 'close'; closeButton.innerHTML = '×'; closeButton.onclick = () => this.hide(); header.appendChild(closeButton); modalContent.appendChild(header); // Create body const body = document.createElement('div'); body.className = 'modal-body'; const localization = this.getModule('localization'); // Create sections // App Settings Section (Language and Speed) const appSettingsSection = document.createElement('div'); appSettingsSection.className = 'options-section'; const appSettingsTitle = document.createElement('h3'); appSettingsTitle.textContent = this.t('options.applicationSettings'); appSettingsSection.appendChild(appSettingsTitle); // Language const languageContainer = document.createElement('div'); languageContainer.className = 'option-item'; const languageLabel = document.createElement('label'); languageLabel.textContent = this.t('options.language') + ':'; languageContainer.appendChild(languageLabel); this.elements.language = createUIElement('select', { 'data-pref-bind': 'app.locale' }, null, languageContainer); appSettingsSection.appendChild(languageContainer); const gameLanguageContainer = document.createElement('div'); gameLanguageContainer.className = 'option-item'; const gameLanguageLabel = document.createElement('label'); gameLanguageLabel.textContent = this.t('options.gameLanguage') + ':'; gameLanguageContainer.appendChild(gameLanguageLabel); const gameLanguageValue = document.createElement('span'); gameLanguageValue.className = 'game-language-value'; const gameConfig = this.getModule('game-config'); const gameLocale = gameConfig?.getLocale?.() || 'en_US'; gameLanguageValue.textContent = localization?.getLanguageName?.(gameLocale) || gameLocale; this.elements.gameLanguage = gameLanguageValue; gameLanguageContainer.appendChild(gameLanguageValue); appSettingsSection.appendChild(gameLanguageContainer); // Speed const speedContainer = document.createElement('div'); speedContainer.className = 'option-item'; const speedLabel = document.createElement('label'); speedLabel.textContent = this.t('options.speed') + ':'; speedContainer.appendChild(speedLabel); const speedValue = document.createElement('span'); speedValue.className = 'slider-value'; speedValue.textContent = '100%'; this.elements.ttsSpeedValue = speedValue; speedContainer.appendChild(speedValue); this.elements.ttsSpeed = createUIElement('input', { type: 'range', min: 50, max: 200, value: 100, 'data-pref-bind': 'tts.speed', 'data-pref-transform': 'multiplier-percent' }, null, speedContainer); // Update displayed value when slider changes this.elements.ttsSpeed.addEventListener('input', () => { this.updateSpeedDisplay(); }); appSettingsSection.appendChild(speedContainer); body.appendChild(appSettingsSection); const webglSection = document.createElement('div'); webglSection.className = 'options-section'; const webglTitle = document.createElement('h3'); webglTitle.textContent = this.t('options.bookDisplay'); webglSection.appendChild(webglTitle); const displayModeContainer = document.createElement('div'); displayModeContainer.className = 'option-item'; const displayModeLabel = document.createElement('label'); displayModeLabel.textContent = this.t('options.displayMode') + ':'; displayModeContainer.appendChild(displayModeLabel); this.elements.webglMode = createUIElement('select', { 'data-pref-bind': 'webgl.mode' }, null, displayModeContainer); [ { value: '3d', label: this.t('options.displayMode3d') }, { value: '2d', label: this.t('options.displayMode2d') } ].forEach((optionConfig) => { const option = document.createElement('option'); option.value = optionConfig.value; option.textContent = optionConfig.label; this.elements.webglMode.appendChild(option); }); webglSection.appendChild(displayModeContainer); const bookSizeContainer = document.createElement('div'); bookSizeContainer.className = 'option-item'; const bookSizeLabel = document.createElement('label'); bookSizeLabel.textContent = this.t('options.bookSize') + ':'; bookSizeContainer.appendChild(bookSizeLabel); const bookSizeValue = document.createElement('span'); bookSizeValue.className = 'slider-value'; bookSizeValue.textContent = '300'; this.elements.webglBookSizeValue = bookSizeValue; bookSizeContainer.appendChild(bookSizeValue); this.elements.webglBookSize = createUIElement('input', { type: 'range', min: 40, max: 500, step: 10, value: 300, 'data-pref-bind': 'webgl.bookPageCount', 'data-pref-transform': 'integer:40,500' }, null, bookSizeContainer); this.elements.webglBookSize.addEventListener('input', () => this.updateWebGLDisplays()); if (!this.hasFixedBookPageCount()) { webglSection.appendChild(bookSizeContainer); } const pageReserveContainer = document.createElement('div'); pageReserveContainer.className = 'option-item'; const pageReserveLabel = document.createElement('label'); pageReserveLabel.textContent = this.t('options.pageReserve') + ':'; pageReserveContainer.appendChild(pageReserveLabel); const pageReserveValue = document.createElement('span'); pageReserveValue.className = 'slider-value'; pageReserveValue.textContent = '50'; this.elements.webglPageReserveValue = pageReserveValue; pageReserveContainer.appendChild(pageReserveValue); this.elements.webglPageReserve = createUIElement('input', { type: 'range', min: 0, max: 500, step: 1, value: 50, 'data-pref-bind': 'webgl.pageReserve', 'data-pref-transform': 'integer:0,500' }, null, pageReserveContainer); this.elements.webglPageReserve.addEventListener('input', () => this.updateWebGLDisplays()); if (!this.hasFixedPageReserve()) { webglSection.appendChild(pageReserveContainer); } body.appendChild(webglSection); // TTS Section const ttsSection = document.createElement('div'); ttsSection.className = 'options-section'; const ttsTitle = document.createElement('h3'); ttsTitle.textContent = this.t('options.speech'); ttsSection.appendChild(ttsTitle); // TTS Enable const ttsEnableContainer = document.createElement('div'); ttsEnableContainer.className = 'option-item'; const ttsEnableLabel = document.createElement('label'); ttsEnableLabel.textContent = this.t('options.enableSpeech') + ':'; ttsEnableContainer.appendChild(ttsEnableLabel); this.elements.ttsEnabled = createUIElement('input', { type: 'checkbox', 'data-pref-bind': 'tts.enabled' }, null, ttsEnableContainer); ttsSection.appendChild(ttsEnableContainer); // TTS System const ttsSystemContainer = document.createElement('div'); ttsSystemContainer.className = 'option-item'; const ttsSystemLabel = document.createElement('label'); ttsSystemLabel.textContent = this.t('options.provider') + ':'; ttsSystemContainer.appendChild(ttsSystemLabel); this.elements.ttsSystem = createUIElement('select', { 'data-pref-bind': 'tts.preferred_handler' }, null, ttsSystemContainer); ttsSection.appendChild(ttsSystemContainer); const providerStatusContainer = document.createElement('div'); providerStatusContainer.className = 'provider-status-list'; this.elements.providerStatus = providerStatusContainer; ttsSection.appendChild(providerStatusContainer); // TTS Voice const ttsVoiceContainer = document.createElement('div'); ttsVoiceContainer.className = 'option-item'; const ttsVoiceLabel = document.createElement('label'); ttsVoiceLabel.textContent = this.t('options.voice') + ':'; ttsVoiceContainer.appendChild(ttsVoiceLabel); this.elements.ttsVoice = createUIElement('select', { 'data-pref-bind': 'tts.voice' }, null, ttsVoiceContainer); this.elements.localOpenAiVoice = createUIElement('input', { id: 'local-openai-voice', type: 'text', placeholder: 'alloy', 'data-pref-bind': 'tts.local-openai-tts_voice' }, null, ttsVoiceContainer); this.elements.localOpenAiVoice.style.display = 'none'; ttsSection.appendChild(ttsVoiceContainer); // Add API Settings const apiSettings = this.createApiSettings(); ttsSection.appendChild(apiSettings); body.appendChild(ttsSection); // Audio Section const audioSection = document.createElement('div'); audioSection.className = 'options-section'; const audioTitle = document.createElement('h3'); audioTitle.textContent = this.t('options.audio'); audioSection.appendChild(audioTitle); audioSection.appendChild(this.createVolumeControl('masterVolume', 'masterVolumeEnabled', 'options.masterVolume', 'options.muteMasterVolume', 'options.unmuteMasterVolume', 100)); audioSection.appendChild(this.createVolumeControl('ttsVolume', 'ttsVolumeEnabled', 'options.speechVolume', 'options.muteSpeechVolume', 'options.unmuteSpeechVolume', 100)); audioSection.appendChild(this.createVolumeControl('musicVolume', 'musicVolumeEnabled', 'options.musicVolume', 'options.muteMusicVolume', 'options.unmuteMusicVolume', 70)); audioSection.appendChild(this.createVolumeControl('sfxVolume', 'sfxVolumeEnabled', 'options.sfxVolume', 'options.muteSfxVolume', 'options.unmuteSfxVolume', 100)); audioSection.appendChild(this.createVolumeControl('musicDuckingAmount', 'musicDuckingEnabled', 'options.musicDucking', 'options.disableMusicDucking', 'options.enableMusicDucking', 30)); body.appendChild(audioSection); modalContent.appendChild(body); // Create footer const footer = document.createElement('div'); footer.className = 'modal-footer'; const closeModalButton = document.createElement('button'); closeModalButton.textContent = this.t('options.close'); closeModalButton.onclick = () => this.hide(); footer.appendChild(closeModalButton); modalContent.appendChild(footer); // Add modal content to modal this.modal.appendChild(modalContent); // Add modal to document document.body.appendChild(this.modal); } createVolumeControl(valueKey, enabledKey, labelKey, muteTitleKey, unmuteTitleKey, defaultPercent) { const container = document.createElement('div'); container.className = 'option-item volume-option'; const label = document.createElement('label'); label.textContent = this.t(labelKey) + ':'; container.appendChild(label); const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'volume-toggle'; toggle.dataset.prefCategory = 'audio'; toggle.dataset.prefKey = enabledKey; toggle.dataset.muteTitleKey = muteTitleKey; toggle.dataset.unmuteTitleKey = unmuteTitleKey; toggle.addEventListener('click', () => { const current = this.getPreference('audio', enabledKey, true) !== false; this.updatePreference('audio', enabledKey, !current); this.updateVolumeToggleButton(toggle); }); container.appendChild(toggle); const value = document.createElement('span'); value.className = 'slider-value'; value.textContent = `${defaultPercent}%`; this.elements[`${valueKey}Value`] = value; container.appendChild(value); const slider = createUIElement('input', { type: 'range', min: 0, max: 100, value: defaultPercent, 'data-pref-bind': `audio.${valueKey}`, 'data-pref-transform': 'range:0,1' }, null, container); this.elements[valueKey] = slider; slider.addEventListener('input', () => { value.textContent = `${slider.value}%`; }); this.updateVolumeToggleButton(toggle); return container; } updateVolumeToggleButtons() { if (!this.modal) return; this.modal.querySelectorAll('.volume-toggle').forEach(button => { this.updateVolumeToggleButton(button); }); } updateVolumeToggleButton(button) { if (!button) return; const enabled = this.getPreference(button.dataset.prefCategory, button.dataset.prefKey, true) !== false; button.classList.toggle('is-muted', !enabled); button.innerHTML = this.getVolumeToggleIcon(enabled); const titleKey = enabled ? button.dataset.muteTitleKey : button.dataset.unmuteTitleKey; const title = this.t(titleKey); button.title = title; button.setAttribute('aria-label', title); } getVolumeToggleIcon(enabled) { const common = `xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"`; if (enabled) { return ``; } return ``; } /** * Create API settings controls * @returns {HTMLElement} - API settings element */ createApiSettings() { console.log('Options UI: Creating API settings'); const apiSettings = document.createElement('div'); apiSettings.className = 'options-section'; // ElevenLabs API settings const elevenLabsSettings = document.createElement('div'); elevenLabsSettings.className = 'api-settings elevenlabs-tts-settings'; elevenLabsSettings.style.display = 'none'; const elevenLabsTitle = document.createElement('h3'); elevenLabsTitle.textContent = this.t('options.elevenLabsSettings'); elevenLabsSettings.appendChild(elevenLabsTitle); // ElevenLabs API Key const elevenLabsApiKeyContainer = document.createElement('div'); elevenLabsApiKeyContainer.className = 'option-item'; const elevenLabsApiKeyLabel = document.createElement('label'); elevenLabsApiKeyLabel.textContent = this.t('options.apiKey') + ':'; elevenLabsApiKeyContainer.appendChild(elevenLabsApiKeyLabel); this.elements.elevenLabsApiKey = createUIElement('input', { type: 'password', 'data-pref-bind': 'tts.elevenlabs-tts_api_key' }, null, elevenLabsApiKeyContainer); elevenLabsSettings.appendChild(elevenLabsApiKeyContainer); // ElevenLabs API URL const elevenLabsApiUrlContainer = document.createElement('div'); elevenLabsApiUrlContainer.className = 'option-item'; const elevenLabsApiUrlLabel = document.createElement('label'); elevenLabsApiUrlLabel.textContent = this.t('options.apiUrl') + ':'; elevenLabsApiUrlContainer.appendChild(elevenLabsApiUrlLabel); this.elements.elevenLabsApiUrl = createUIElement('input', { type: 'text', 'data-pref-bind': 'tts.elevenlabs-tts_api_url' }, null, elevenLabsApiUrlContainer); elevenLabsSettings.appendChild(elevenLabsApiUrlContainer); // OpenAI API settings const openaiSettings = document.createElement('div'); openaiSettings.className = 'api-settings openai-tts-settings'; openaiSettings.style.display = 'none'; const openaiTitle = document.createElement('h3'); openaiTitle.textContent = this.t('options.openAiSettings'); openaiSettings.appendChild(openaiTitle); // OpenAI API Key const openaiApiKeyContainer = document.createElement('div'); openaiApiKeyContainer.className = 'option-item'; const openaiApiKeyLabel = document.createElement('label'); openaiApiKeyLabel.textContent = this.t('options.apiKey') + ':'; openaiApiKeyContainer.appendChild(openaiApiKeyLabel); this.elements.openaiApiKey = createUIElement('input', { type: 'password', 'data-pref-bind': 'tts.openai-tts_api_key' }, null, openaiApiKeyContainer); openaiSettings.appendChild(openaiApiKeyContainer); // OpenAI API URL const openaiApiUrlContainer = document.createElement('div'); openaiApiUrlContainer.className = 'option-item'; const openaiApiUrlLabel = document.createElement('label'); openaiApiUrlLabel.textContent = this.t('options.apiUrl') + ':'; openaiApiUrlContainer.appendChild(openaiApiUrlLabel); this.elements.openaiApiUrl = createUIElement('input', { type: 'text', 'data-pref-bind': 'tts.openai-tts_api_url' }, null, openaiApiUrlContainer); openaiSettings.appendChild(openaiApiUrlContainer); const openaiModelContainer = document.createElement('div'); openaiModelContainer.className = 'option-item'; const openaiModelLabel = document.createElement('label'); openaiModelLabel.textContent = this.t('options.model') + ':'; openaiModelContainer.appendChild(openaiModelLabel); this.elements.openaiModel = createUIElement('select', { id: 'openai-model', 'data-pref-bind': 'tts.openai-tts_model' }, null, openaiModelContainer); [ { id: 'tts-1', name: 'TTS-1' }, { id: 'tts-1-hd', name: 'TTS-1 HD' }, { id: 'gpt-4o-mini-tts', name: 'GPT-4o mini TTS' } ].forEach(model => { const option = document.createElement('option'); option.value = model.id; option.textContent = model.name; this.elements.openaiModel.appendChild(option); }); openaiSettings.appendChild(openaiModelContainer); // Local OpenAI-compatible API settings const localOpenAiSettings = document.createElement('div'); localOpenAiSettings.className = 'api-settings local-openai-tts-settings'; localOpenAiSettings.style.display = 'none'; const localOpenAiTitle = document.createElement('h3'); localOpenAiTitle.textContent = this.t('options.localOpenAiSettings'); localOpenAiSettings.appendChild(localOpenAiTitle); const localOpenAiApiKeyContainer = document.createElement('div'); localOpenAiApiKeyContainer.className = 'option-item'; const localOpenAiApiKeyLabel = document.createElement('label'); localOpenAiApiKeyLabel.textContent = this.t('options.optionalApiKey') + ':'; localOpenAiApiKeyContainer.appendChild(localOpenAiApiKeyLabel); this.elements.localOpenAiApiKey = createUIElement('input', { type: 'password', 'data-pref-bind': 'tts.local-openai-tts_api_key' }, null, localOpenAiApiKeyContainer); localOpenAiSettings.appendChild(localOpenAiApiKeyContainer); const localOpenAiApiUrlContainer = document.createElement('div'); localOpenAiApiUrlContainer.className = 'option-item'; const localOpenAiApiUrlLabel = document.createElement('label'); localOpenAiApiUrlLabel.textContent = this.t('options.apiUrl') + ':'; localOpenAiApiUrlContainer.appendChild(localOpenAiApiUrlLabel); this.elements.localOpenAiApiUrl = createUIElement('input', { type: 'text', 'data-pref-bind': 'tts.local-openai-tts_api_url' }, null, localOpenAiApiUrlContainer); localOpenAiSettings.appendChild(localOpenAiApiUrlContainer); const localOpenAiModelContainer = document.createElement('div'); localOpenAiModelContainer.className = 'option-item'; const localOpenAiModelLabel = document.createElement('label'); localOpenAiModelLabel.textContent = this.t('options.model') + ':'; localOpenAiModelContainer.appendChild(localOpenAiModelLabel); this.elements.localOpenAiModel = createUIElement('input', { id: 'local-openai-model', type: 'text', placeholder: 'tts-1', 'data-pref-bind': 'tts.local-openai-tts_model' }, null, localOpenAiModelContainer); localOpenAiSettings.appendChild(localOpenAiModelContainer); const localOpenAiTimeoutContainer = document.createElement('div'); localOpenAiTimeoutContainer.className = 'option-item'; const localOpenAiTimeoutLabel = document.createElement('label'); localOpenAiTimeoutLabel.textContent = this.t('options.requestTimeoutMs') + ':'; localOpenAiTimeoutContainer.appendChild(localOpenAiTimeoutLabel); this.elements.localOpenAiTimeout = createUIElement('input', { id: 'local-openai-timeout-ms', type: 'number', min: 1000, max: 600000, step: 1000, 'data-pref-bind': 'tts.local-openai-tts_timeout_ms', 'data-pref-transform': 'integer:1000,600000' }, null, localOpenAiTimeoutContainer); localOpenAiSettings.appendChild(localOpenAiTimeoutContainer); // Add all API settings to container apiSettings.appendChild(elevenLabsSettings); apiSettings.appendChild(openaiSettings); apiSettings.appendChild(localOpenAiSettings); return apiSettings; } /** * Set up event listeners for options controls */ setupEventListeners() { if (!this.modal) return; // TTS System change if (this.elements.ttsSystem) { this.elements.ttsSystem.addEventListener('change', async (event) => { this.updateApiSettingsVisibility(event.target.value); const ttsFactory = this.getModule('tts-factory'); if (ttsFactory) { await ttsFactory.refreshHandlerStatus(event.target.value); } await this.populateVoices(); this.renderProviderStatuses(); }); } // Close when clicking outside the modal content this.modal.addEventListener('click', (event) => { if (event.target === this.modal) { this.hide(); } }); } /** * Update API settings visibility based on selected TTS system * @param {string} handlerId - Selected TTS system */ updateApiSettingsVisibility(handlerId) { const apiContainers = this.modal.querySelectorAll('.api-settings'); apiContainers.forEach(container => { const shouldShow = container.classList.contains(`${handlerId}-settings`); container.style.display = shouldShow ? 'block' : 'none'; }); } /** * Show the options UI */ show() { if (this.modal) { this.modal.style.display = 'flex'; document.body.classList.add('modal-open'); } } /** * Hide the options UI */ hide() { if (this.modal) { this.modal.style.display = 'none'; document.body.classList.remove('modal-open'); } } /** * Toggle the options UI visibility */ toggle() { if (this.modal) { if (this.modal.style.display === 'flex') { this.hide(); } else { this.show(); } } } /** * Populate the TTS systems dropdown */ async populateTtsSystems() { const ttsFactory = this.getModule('tts-factory'); if (!ttsFactory || !this.elements.ttsSystem) return; // Get available TTS systems const handlers = ttsFactory.getAvailableHandlers(); console.log('Options UI: Available TTS handlers:', handlers); // Format for display const systems = handlers.map(handler => ({ id: handler.id, name: handler.displayName || handler.id })); // Populate dropdown populateDropdown( this.elements.ttsSystem, systems, 'id', 'name', this.getPreference('tts', 'preferred_handler', 'none') ); // Update API settings visibility this.updateApiSettingsVisibility(this.elements.ttsSystem.value); this.renderProviderStatuses(); } /** * Populate the voices dropdown */ async populateVoices() { const ttsFactory = this.getModule('tts-factory'); if (!ttsFactory || !this.elements.ttsVoice) return; const selectedHandler = this.elements.ttsSystem?.value || this.getPreference('tts', 'preferred_handler', 'none'); this.updateVoiceControlVisibility(selectedHandler); if (selectedHandler === 'local-openai-tts') { if (this.elements.localOpenAiVoice) { this.elements.localOpenAiVoice.value = this.getPreference('tts', 'local-openai-tts_voice', 'alloy'); } return; } const voices = typeof ttsFactory.getVoicesForHandler === 'function' ? await ttsFactory.getVoicesForHandler(selectedHandler) || [] : await ttsFactory.getVoices() || []; console.log('Options UI: TTS voices:', voices); // Populate dropdown populateDropdown( this.elements.ttsVoice, voices, 'id', 'name', this.getPreference('tts', `${selectedHandler}_voice`, this.getPreference('tts', 'voice', '')) ); this.ensureSelectedVoiceIsAvailable(selectedHandler, voices); } ensureSelectedVoiceIsAvailable(selectedHandler, voices = []) { if (!this.elements.ttsVoice || selectedHandler === 'local-openai-tts') return; if (!Array.isArray(voices) || voices.length === 0) return; const available = new Set(voices.map(voice => String(voice.id || '').toLowerCase())); const current = String(this.elements.ttsVoice.value || '').toLowerCase(); if (current && available.has(current)) return; const fallback = voices.some(voice => voice.id === 'alloy') ? 'alloy' : voices[0].id; this.elements.ttsVoice.value = fallback; this.updatePreference('tts', 'voice', fallback); if (selectedHandler && selectedHandler !== 'none') { this.updatePreference('tts', `${selectedHandler}_voice`, fallback); } } updateVoiceControlVisibility(selectedHandler) { const useTextVoice = selectedHandler === 'local-openai-tts'; if (this.elements.ttsVoice) { this.elements.ttsVoice.style.display = useTextVoice ? 'none' : ''; } if (this.elements.localOpenAiVoice) { this.elements.localOpenAiVoice.style.display = useTextVoice ? '' : 'none'; } } renderProviderStatuses() { const container = this.elements.providerStatus; const ttsFactory = this.getModule('tts-factory'); if (!container || !ttsFactory || typeof ttsFactory.getHandlerStatuses !== 'function') { return; } container.innerHTML = ''; const statuses = ttsFactory.getHandlerStatuses(); statuses.forEach(status => { const row = document.createElement('div'); row.className = 'provider-status-row'; const name = document.createElement('span'); name.textContent = status.name; row.appendChild(name); const value = document.createElement('span'); value.className = 'provider-status-value'; value.textContent = `${status.ready ? 'ready' : 'not ready'} - ${status.message}`; row.appendChild(value); container.appendChild(row); }); } /** * Populate the languages dropdown */ async populateLanguages() { const localization = this.getModule('localization'); if (!localization || !this.elements.language) return; // Get available languages const languages = localization.getAvailableLocales() || []; console.log('Options UI: Available languages:', languages); // Format languages with their names const languageOptions = languages.map(code => ({ code, name: localization.getLanguageName(code) })); // Populate dropdown populateDropdown( this.elements.language, languageOptions, 'code', 'name', this.getPreference('app', 'locale', localization.getLocale?.() || 'en_US') ); } /** * Load user preferences from the persistence manager * This is now handled by the persistence manager's setupBindings method */ loadPreferences() { // Update API settings visibility based on current TTS system if (this.elements.ttsSystem) { this.updateApiSettingsVisibility(this.elements.ttsSystem.value); this.updateVoiceControlVisibility(this.elements.ttsSystem.value); } } /** * Show a reload notice * @param {string} message - Message to show */ showReloadNotice(message) { console.log('Options UI: Reload required -', message); this.reloadRequired = true; } /** * Set up listeners for settings that should save immediately */ setupImmediateSaveListeners() { // Settings are saved immediately with two-way binding via data-pref-bind attributes } /** * Set up API URL fields with default values */ setupApiUrlFields() { // Set up ElevenLabs API URL if (this.elements.elevenLabsApiUrl) { const savedUrl = this.getPreference('tts', 'elevenlabs-tts_api_url'); const defaultUrl = 'https://api.elevenlabs.io/v1'; // If no saved URL, set the default if (!savedUrl) { console.log('Options UI: Setting default ElevenLabs API URL:', defaultUrl); this.updatePreference('tts', 'elevenlabs-tts_api_url', defaultUrl); } } // Set up OpenAI API URL if (this.elements.openaiApiUrl) { const savedUrl = this.getPreference('tts', 'openai-tts_api_url'); const defaultUrl = 'https://api.openai.com/v1'; // If no saved URL, set the default if (!savedUrl) { console.log('Options UI: Setting default OpenAI API URL:', defaultUrl); this.updatePreference('tts', 'openai-tts_api_url', defaultUrl); } } // Make sure API keys are initialized if not already set if (!this.getPreference('tts', 'elevenlabs-tts_api_key')) { this.updatePreference('tts', 'elevenlabs-tts_api_key', ''); } if (!this.getPreference('tts', 'openai-tts_api_key')) { this.updatePreference('tts', 'openai-tts_api_key', ''); } if (!this.getPreference('tts', 'openai-tts_model')) { this.updatePreference('tts', 'openai-tts_model', 'tts-1-hd'); } if (this.elements.localOpenAiApiUrl) { const savedUrl = this.getPreference('tts', 'local-openai-tts_api_url'); const defaultUrl = 'http://localhost:8000/v1'; if (!savedUrl) { console.log('Options UI: Setting default local OpenAI-compatible API URL:', defaultUrl); this.updatePreference('tts', 'local-openai-tts_api_url', defaultUrl); } } if (!this.getPreference('tts', 'local-openai-tts_api_key')) { this.updatePreference('tts', 'local-openai-tts_api_key', ''); } if (!this.getPreference('tts', 'local-openai-tts_voice')) { this.updatePreference('tts', 'local-openai-tts_voice', 'alloy'); } if (!this.getPreference('tts', 'local-openai-tts_model')) { this.updatePreference('tts', 'local-openai-tts_model', 'tts-1'); } if (!this.getPreference('tts', 'local-openai-tts_timeout_ms')) { this.updatePreference('tts', 'local-openai-tts_timeout_ms', 60000); } } /** * Set up the initial state of the Options UI */ async setupInitialState() { // Add event listener for toggling options UI document.addEventListener('ui:options:toggle', () => this.toggle()); // Set up key bindings document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.modal && this.modal.style.display === 'flex') { this.hide(); } }); // Populate TTS systems selector await this.populateTtsSystems(); // Populate languages await this.populateLanguages(); // Populate voices based on current TTS system await this.populateVoices(); // Register for TTS events to update voices when they change document.addEventListener('tts:voices:updated', () => { console.log('Options UI: Received tts:voices:updated event, updating voice dropdown'); this.populateVoices(); this.renderProviderStatuses(); }); // Set up language change listener document.addEventListener('locale:changed', async () => { this.updateUIText(); await this.populateLanguages(); }); // Register event listeners for TTS availability and voiceId changes document.addEventListener('tts:engine:change', async (event) => { console.log('Options UI: Received TTS engine change event:', event.detail); await this.populateVoices(); this.updateApiSettingsVisibility(this.elements.ttsSystem.value); this.renderProviderStatuses(); }); document.addEventListener('tts:status:updated', () => { this.renderProviderStatuses(); }); document.addEventListener('tts:enabled:change', async (event) => { if (!event.detail || typeof event.detail.enabled !== 'boolean') { return; } if (this.elements.ttsEnabled) { this.elements.ttsEnabled.checked = event.detail.enabled; } const ttsFactory = this.getModule('tts-factory'); if (!ttsFactory) { return; } if (event.detail.enabled) { const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none'); if (preferredHandler !== 'none') { await ttsFactory.setActiveHandler(preferredHandler); } } else { await ttsFactory.disableAfterCurrentPlayback(); } this.renderProviderStatuses(); }); } /** * Set up two-way bindings for preferences using data attributes */ setupPreferenceBindings() { const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager || !persistenceManager.setupBindings) { console.error('Options UI: Cannot set up preference bindings, persistence manager not available or missing setupBindings method'); return; } // Setup all bindings in the modal this.bindings = persistenceManager.setupBindings('#options-modal'); console.log('Options UI: Preference bindings set up', this.bindings.length); this.updateSpeedDisplay(); this.updateVolumeDisplays(); this.updateWebGLDisplays(); // Add event listeners for side effects when preferences change document.addEventListener('preference-updated', (event) => { const { category, key, value } = event.detail; // Handle audio settings side effects if (category === 'audio') { const audioManager = this.getModule('audio-manager'); if (!audioManager) return; if (key === 'masterVolume') { audioManager.setMasterVolume(value); } else if (key === 'musicVolume') { audioManager.setMusicVolume(value); } else if (key === 'sfxVolume') { audioManager.setSfxVolume(value); } else if (key === 'ttsVolume') { audioManager.setTtsVolume(value); } else if (key === 'masterVolumeEnabled') { audioManager.setVolumeEnabled('master', value); } else if (key === 'musicVolumeEnabled') { audioManager.setVolumeEnabled('music', value); } else if (key === 'sfxVolumeEnabled') { audioManager.setVolumeEnabled('sfx', value); } else if (key === 'ttsVolumeEnabled') { audioManager.setVolumeEnabled('tts', value); } else if (key === 'musicDuckingAmount') { audioManager.setMusicDuckingAmount(value); } else if (key === 'musicDuckingEnabled') { audioManager.setMusicDuckingEnabled(value); } this.updateVolumeDisplays(); this.updateVolumeToggleButtons(); } // Handle TTS settings side effects if (category === 'tts') { const ttsFactory = this.getModule('tts-factory'); if (!ttsFactory) return; if (key === 'preferred_handler') { const enabled = this.getPreference('tts', 'enabled', false); const activation = enabled && value !== 'none' ? ttsFactory.setActiveHandler(value) : Promise.resolve(ttsFactory.disableAfterCurrentPlayback()); activation.then(() => { this.populateVoices(); this.renderProviderStatuses(); }); this.updateApiSettingsVisibility(value); this.updateVoiceControlVisibility(value); } else if (key === 'voice') { ttsFactory.configure({ voice: value }); } else if (key === 'speed') { ttsFactory.configure({ speed: value }); } else if (key === 'enabled') { if (!value) { ttsFactory.disableAfterCurrentPlayback(); } else { const preferredHandler = this.getPreference('tts', 'preferred_handler', 'none'); if (preferredHandler !== 'none') { ttsFactory.setActiveHandler(preferredHandler); } } document.dispatchEvent(new CustomEvent('tts:enabled:change', { detail: { enabled: value } })); } else if (key.endsWith('_api_key')) { const provider = key.replace('_api_key', ''); this.dispatchApiChangeEvent('api:keyChanged', provider, 'key', value); ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses()); } else if (key.endsWith('_api_url')) { const provider = key.replace('_api_url', ''); this.dispatchApiChangeEvent('api:urlChanged', provider, 'url', value); ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses()); } else if (key.endsWith('_voice')) { const provider = key.replace('_voice', ''); const handler = typeof ttsFactory.getHandler === 'function' ? ttsFactory.getHandler(provider) : null; if (handler && typeof handler.setVoiceOptions === 'function') { handler.setVoiceOptions({ voice: value }); } if (ttsFactory.activeHandler === provider) { ttsFactory.voice = value; } } else if (key.endsWith('_model')) { const provider = key.replace('_model', ''); const handler = typeof ttsFactory.getHandler === 'function' ? ttsFactory.getHandler(provider) : null; if (handler && typeof handler.setVoiceOptions === 'function') { handler.setVoiceOptions({ model: value }); } if (provider === 'openai-tts') { this.populateVoices(); } } if (category === 'webgl') { this.updateWebGLDisplays(); } if (key === 'speed' && this.elements.ttsSpeed) { this.updateSpeedDisplay(); } } // Handle locale changes if (category === 'app' && key === 'locale') { const localization = this.getModule('localization'); if (localization) { localization.setLocale(value); } } }); } updateSpeedDisplay() { if (!this.elements.ttsSpeed || !this.elements.ttsSpeedValue) { return; } this.elements.ttsSpeedValue.textContent = `${this.elements.ttsSpeed.value}%`; } updateVolumeDisplays() { if (this.elements.masterVolume && this.elements.masterVolumeValue) { this.elements.masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`; } if (this.elements.ttsVolume && this.elements.ttsVolumeValue) { this.elements.ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`; } if (this.elements.musicVolume && this.elements.musicVolumeValue) { this.elements.musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`; } if (this.elements.sfxVolume && this.elements.sfxVolumeValue) { this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`; } if (this.elements.musicDuckingAmount && this.elements.musicDuckingAmountValue) { this.elements.musicDuckingAmountValue.textContent = `${this.elements.musicDuckingAmount.value}%`; } } updateWebGLDisplays() { if (this.elements.webglBookSize && this.elements.webglBookSizeValue) { this.elements.webglBookSizeValue.textContent = String(this.elements.webglBookSize.value); } if (this.elements.webglPageReserve && this.elements.webglPageReserveValue) { const bookSize = Number(this.elements.webglBookSize?.value || this.getPreference('webgl', 'bookPageCount', 300)); const maxReserve = Number.isFinite(bookSize) ? Math.max(0, Math.floor(bookSize)) : 500; this.elements.webglPageReserve.max = String(maxReserve); if (Number(this.elements.webglPageReserve.value) > maxReserve) { this.elements.webglPageReserve.value = String(maxReserve); } this.elements.webglPageReserveValue.textContent = String(this.elements.webglPageReserve.value); } } } // Create the singleton instance const OptionsUI = new OptionsUIModule(); // Export the module export { OptionsUI };