Files
ai.interactive.fiction/public/js/options-ui-module.js
T
2025-04-07 06:51:45 +00:00

826 lines
29 KiB
JavaScript

/**
* 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'
];
// 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',
'populateLanguages',
'loadPreferences',
'showReloadNotice',
'toggle',
'setupEventListeners',
'setupApiUrlFields',
'setupInitialState',
'dispatchApiChangeEvent',
'getPreference',
'updatePreference'
]);
}
/**
* 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 }
}));
}
/**
* 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<boolean>} - 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 = 'Options';
header.appendChild(title);
const closeButton = document.createElement('span');
closeButton.className = 'close';
closeButton.innerHTML = '&times;';
closeButton.onclick = () => this.hide();
header.appendChild(closeButton);
modalContent.appendChild(header);
// Create body
const body = document.createElement('div');
body.className = 'modal-body';
// Create sections
// App Settings Section (Language and Speed)
const appSettingsSection = document.createElement('div');
appSettingsSection.className = 'options-section';
const appSettingsTitle = document.createElement('h3');
appSettingsTitle.textContent = 'Application Settings';
appSettingsSection.appendChild(appSettingsTitle);
// Language
const languageContainer = document.createElement('div');
languageContainer.className = 'option-item';
const languageLabel = document.createElement('label');
languageLabel.textContent = 'Language:';
languageContainer.appendChild(languageLabel);
this.elements.language = createUIElement('select', {
'data-pref-bind': 'app.locale'
}, null, languageContainer);
appSettingsSection.appendChild(languageContainer);
// Speed
const speedContainer = document.createElement('div');
speedContainer.className = 'option-item';
const speedLabel = document.createElement('label');
speedLabel.textContent = 'Speed:';
speedContainer.appendChild(speedLabel);
const speedValue = document.createElement('span');
speedValue.className = 'slider-value';
speedValue.textContent = '100%';
speedContainer.appendChild(speedValue);
this.elements.ttsSpeed = createUIElement('input', {
type: 'range',
min: 50,
max: 200,
value: 100,
'data-pref-bind': 'app.speed',
'data-pref-transform': 'range:0.5,2.0'
}, null, speedContainer);
// Update displayed value when slider changes
this.elements.ttsSpeed.addEventListener('input', () => {
speedValue.textContent = `${this.elements.ttsSpeed.value}%`;
});
appSettingsSection.appendChild(speedContainer);
body.appendChild(appSettingsSection);
// TTS Section
const ttsSection = document.createElement('div');
ttsSection.className = 'options-section';
const ttsTitle = document.createElement('h3');
ttsTitle.textContent = 'Text-to-Speech';
ttsSection.appendChild(ttsTitle);
// TTS Enable
const ttsEnableContainer = document.createElement('div');
ttsEnableContainer.className = 'option-item';
const ttsEnableLabel = document.createElement('label');
ttsEnableLabel.textContent = 'Enable TTS:';
ttsEnableContainer.appendChild(ttsEnableLabel);
this.elements.ttsEnabled = createUIElement('input', {
type: 'checkbox',
'data-pref-bind': 'tts.enabled'
}, null, ttsEnableContainer);
ttsSection.appendChild(ttsEnableContainer);
// TTS System
const ttsSystemContainer = document.createElement('div');
ttsSystemContainer.className = 'option-item';
const ttsSystemLabel = document.createElement('label');
ttsSystemLabel.textContent = 'TTS System:';
ttsSystemContainer.appendChild(ttsSystemLabel);
this.elements.ttsSystem = createUIElement('select', {
'data-pref-bind': 'tts.preferred_handler'
}, null, ttsSystemContainer);
ttsSection.appendChild(ttsSystemContainer);
// TTS Voice
const ttsVoiceContainer = document.createElement('div');
ttsVoiceContainer.className = 'option-item';
const ttsVoiceLabel = document.createElement('label');
ttsVoiceLabel.textContent = 'Voice:';
ttsVoiceContainer.appendChild(ttsVoiceLabel);
this.elements.ttsVoice = createUIElement('select', {
'data-pref-bind': 'tts.voice'
}, null, ttsVoiceContainer);
ttsSection.appendChild(ttsVoiceContainer);
// Add API Settings
const apiSettings = this.createApiSettings();
ttsSection.appendChild(apiSettings);
body.appendChild(ttsSection);
// Audio Section
const audioSection = document.createElement('div');
audioSection.className = 'options-section';
const audioTitle = document.createElement('h3');
audioTitle.textContent = 'Audio';
audioSection.appendChild(audioTitle);
// Master Volume
const masterVolumeContainer = document.createElement('div');
masterVolumeContainer.className = 'option-item';
const masterVolumeLabel = document.createElement('label');
masterVolumeLabel.textContent = 'Master Volume:';
masterVolumeContainer.appendChild(masterVolumeLabel);
const masterVolumeValue = document.createElement('span');
masterVolumeValue.className = 'slider-value';
masterVolumeValue.textContent = '100%';
masterVolumeContainer.appendChild(masterVolumeValue);
this.elements.masterVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.masterVolume',
'data-pref-transform': 'range:0,1'
}, null, masterVolumeContainer);
// Update displayed value when slider changes
this.elements.masterVolume.addEventListener('input', () => {
masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`;
});
audioSection.appendChild(masterVolumeContainer);
// Speech Volume
const ttsVolumeContainer = document.createElement('div');
ttsVolumeContainer.className = 'option-item';
const ttsVolumeLabel = document.createElement('label');
ttsVolumeLabel.textContent = 'Speech Volume:';
ttsVolumeContainer.appendChild(ttsVolumeLabel);
const ttsVolumeValue = document.createElement('span');
ttsVolumeValue.className = 'slider-value';
ttsVolumeValue.textContent = '100%';
ttsVolumeContainer.appendChild(ttsVolumeValue);
this.elements.ttsVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.ttsVolume',
'data-pref-transform': 'range:0,1'
}, null, ttsVolumeContainer);
// Update displayed value when slider changes
this.elements.ttsVolume.addEventListener('input', () => {
ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`;
});
audioSection.appendChild(ttsVolumeContainer);
// Music Volume
const musicVolumeContainer = document.createElement('div');
musicVolumeContainer.className = 'option-item';
const musicVolumeLabel = document.createElement('label');
musicVolumeLabel.textContent = 'Music Volume:';
musicVolumeContainer.appendChild(musicVolumeLabel);
const musicVolumeValue = document.createElement('span');
musicVolumeValue.className = 'slider-value';
musicVolumeValue.textContent = '100%';
musicVolumeContainer.appendChild(musicVolumeValue);
this.elements.musicVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 70,
'data-pref-bind': 'audio.musicVolume',
'data-pref-transform': 'range:0,1'
}, null, musicVolumeContainer);
// Update displayed value when slider changes
this.elements.musicVolume.addEventListener('input', () => {
musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`;
});
audioSection.appendChild(musicVolumeContainer);
// SFX Volume
const sfxVolumeContainer = document.createElement('div');
sfxVolumeContainer.className = 'option-item';
const sfxVolumeLabel = document.createElement('label');
sfxVolumeLabel.textContent = 'Sound Effects Volume:';
sfxVolumeContainer.appendChild(sfxVolumeLabel);
const sfxVolumeValue = document.createElement('span');
sfxVolumeValue.className = 'slider-value';
sfxVolumeValue.textContent = '100%';
sfxVolumeContainer.appendChild(sfxVolumeValue);
this.elements.sfxVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.sfxVolume',
'data-pref-transform': 'range:0,1'
}, null, sfxVolumeContainer);
// Update displayed value when slider changes
this.elements.sfxVolume.addEventListener('input', () => {
sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
});
audioSection.appendChild(sfxVolumeContainer);
body.appendChild(audioSection);
modalContent.appendChild(body);
// Create footer
const footer = document.createElement('div');
footer.className = 'modal-footer';
const closeModalButton = document.createElement('button');
closeModalButton.textContent = 'Close';
closeModalButton.onclick = () => this.hide();
footer.appendChild(closeModalButton);
modalContent.appendChild(footer);
// Add modal content to modal
this.modal.appendChild(modalContent);
// Add modal to document
document.body.appendChild(this.modal);
}
/**
* Create API settings controls
* @returns {HTMLElement} - API settings element
*/
createApiSettings() {
console.log('Options UI: Creating API settings');
const apiSettings = document.createElement('div');
apiSettings.className = 'options-section';
// ElevenLabs API settings
const elevenLabsSettings = document.createElement('div');
elevenLabsSettings.className = 'api-settings elevenlabs-tts-settings';
elevenLabsSettings.style.display = 'none';
const elevenLabsTitle = document.createElement('h3');
elevenLabsTitle.textContent = 'ElevenLabs API Settings';
elevenLabsSettings.appendChild(elevenLabsTitle);
// ElevenLabs API Key
const elevenLabsApiKeyContainer = document.createElement('div');
elevenLabsApiKeyContainer.className = 'option-item';
const elevenLabsApiKeyLabel = document.createElement('label');
elevenLabsApiKeyLabel.textContent = 'API Key:';
elevenLabsApiKeyContainer.appendChild(elevenLabsApiKeyLabel);
this.elements.elevenLabsApiKey = createUIElement('input', {
type: 'password',
'data-pref-bind': 'tts.elevenlabs-tts_api_key'
}, null, elevenLabsApiKeyContainer);
elevenLabsSettings.appendChild(elevenLabsApiKeyContainer);
// ElevenLabs API URL
const elevenLabsApiUrlContainer = document.createElement('div');
elevenLabsApiUrlContainer.className = 'option-item';
const elevenLabsApiUrlLabel = document.createElement('label');
elevenLabsApiUrlLabel.textContent = 'API URL:';
elevenLabsApiUrlContainer.appendChild(elevenLabsApiUrlLabel);
this.elements.elevenLabsApiUrl = createUIElement('input', {
type: 'text',
'data-pref-bind': 'tts.elevenlabs-tts_api_url'
}, null, elevenLabsApiUrlContainer);
elevenLabsSettings.appendChild(elevenLabsApiUrlContainer);
// OpenAI API settings
const openaiSettings = document.createElement('div');
openaiSettings.className = 'api-settings openai-tts-settings';
openaiSettings.style.display = 'none';
const openaiTitle = document.createElement('h3');
openaiTitle.textContent = 'OpenAI API Settings';
openaiSettings.appendChild(openaiTitle);
// OpenAI API Key
const openaiApiKeyContainer = document.createElement('div');
openaiApiKeyContainer.className = 'option-item';
const openaiApiKeyLabel = document.createElement('label');
openaiApiKeyLabel.textContent = 'API Key:';
openaiApiKeyContainer.appendChild(openaiApiKeyLabel);
this.elements.openaiApiKey = createUIElement('input', {
type: 'password',
'data-pref-bind': 'tts.openai-tts_api_key'
}, null, openaiApiKeyContainer);
openaiSettings.appendChild(openaiApiKeyContainer);
// OpenAI API URL
const openaiApiUrlContainer = document.createElement('div');
openaiApiUrlContainer.className = 'option-item';
const openaiApiUrlLabel = document.createElement('label');
openaiApiUrlLabel.textContent = 'API URL:';
openaiApiUrlContainer.appendChild(openaiApiUrlLabel);
this.elements.openaiApiUrl = createUIElement('input', {
type: 'text',
'data-pref-bind': 'tts.openai-tts_api_url'
}, null, openaiApiUrlContainer);
openaiSettings.appendChild(openaiApiUrlContainer);
// Add all API settings to container
apiSettings.appendChild(elevenLabsSettings);
apiSettings.appendChild(openaiSettings);
return apiSettings;
}
/**
* Set up event listeners for options controls
*/
setupEventListeners() {
if (!this.modal) return;
// TTS System change
if (this.elements.ttsSystem) {
this.elements.ttsSystem.addEventListener('change', async (event) => {
this.updateApiSettingsVisibility(event.target.value);
await this.populateVoices();
});
}
// Close when clicking outside the modal content
this.modal.addEventListener('click', (event) => {
if (event.target === this.modal) {
this.hide();
}
});
}
/**
* Update API settings visibility based on selected TTS system
* @param {string} handlerId - Selected TTS system
*/
updateApiSettingsVisibility(handlerId) {
const apiContainers = this.modal.querySelectorAll('.api-settings');
apiContainers.forEach(container => {
const shouldShow = container.classList.contains(`${handlerId}-settings`);
container.style.display = shouldShow ? 'block' : 'none';
});
}
/**
* 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);
}
/**
* Populate the voices dropdown
*/
async populateVoices() {
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory || !this.elements.ttsVoice) return;
// Get voices for current TTS system
const voices = await ttsFactory.getVoices() || [];
console.log('Options UI: TTS voices:', voices);
// Populate dropdown
populateDropdown(
this.elements.ttsVoice,
voices,
'id',
'name',
this.getPreference('tts', 'voice', '')
);
}
/**
* 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', '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);
}
}
/**
* 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', '');
}
}
/**
* 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();
});
// 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);
});
}
/**
* Set up two-way bindings for preferences using data attributes
*/
setupPreferenceBindings() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager || !persistenceManager.setupBindings) {
console.error('Options UI: Cannot set up preference bindings, persistence manager not available or missing setupBindings method');
return;
}
// Setup all bindings in the modal
this.bindings = persistenceManager.setupBindings('#options-modal');
console.log('Options UI: Preference bindings set up', this.bindings.length);
// Add event listeners for side effects when preferences change
document.addEventListener('preference-updated', (event) => {
const { category, key, value } = event.detail;
// Handle audio settings side effects
if (category === 'audio') {
const audioManager = this.getModule('audio-manager');
if (!audioManager) return;
if (key === 'masterVolume') {
audioManager.setMasterVolume(value);
} else if (key === 'musicVolume') {
audioManager.setMusicVolume(value);
} else if (key === 'sfxVolume') {
audioManager.setSfxVolume(value);
} else if (key === 'ttsVolume') {
audioManager.setTtsVolume(value);
}
}
// Handle TTS settings side effects
if (category === 'tts') {
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) return;
if (key === 'preferred_handler') {
this.populateVoices();
this.updateApiSettingsVisibility(value);
} else if (key === 'voice') {
ttsFactory.configure({ voice: value });
} else if (key === 'speed') {
ttsFactory.configure({ speed: value });
} else if (key === 'enabled') {
ttsFactory.configure({ enabled: value });
}
}
// Handle locale changes
if (category === 'app' && key === 'locale') {
const localization = this.getModule('localization');
if (localization) {
localization.setLocale(value);
}
}
});
}
}
// Create the singleton instance
const OptionsUI = new OptionsUIModule();
// Register with the module registry
moduleRegistry.register(OptionsUI);
// Export the module
export { OptionsUI };