/** * UiController Module * Manages user interface interactions and updates UI elements. */ export class UiController { /** * Create a new UiController * @param {Object} config - Configuration options * @param {Object} config.animationQueue - The AnimationQueue instance * @param {Object} config.ttsPlayer - The TtsPlayer instance * @param {Object} config.inputHandler - The InputHandler instance * @param {Object} config.socketClient - The SocketClient instance (or rely on callbacks) * @param {HTMLElement} config.commandHistoryContainerElement - The command history container * @param {HTMLElement} config.storyContainerElement - The story container * @param {HTMLElement} config.speedSliderElement - The speed slider element * @param {HTMLElement} config.rewindButtonElement - The rewind button element * @param {HTMLElement} config.saveButtonElement - The save button element * @param {HTMLElement} config.loadButtonElement - The load button element * @param {HTMLElement} config.speechButtonElement - The speech button element * @param {HTMLElement} config.speedResetElement - The speed reset button element * @param {Object} config.translations - Translations object * @param {string} config.locale - Locale string */ constructor(config = {}) { // Store dependencies this.animationQueue = config.animationQueue; this.ttsPlayer = config.ttsPlayer; // Handles enabling/disabling TTS via its own logic this.inputHandler = config.inputHandler; // Needed for focus, suggestions? this.socketClient = config.socketClient; // Direct access or use callbacks // Callbacks for actions (to be set by AnimatedFiction) this.onRestartRequest = null; this.onSaveRequest = null; this.onLoadRequest = null; // Active TTS handler (set via setTtsHandler) this.ttsHandler = null; // UI elements this.speedSlider = config.speedSliderElement || document.getElementById('speed'); this.commandHistoryContainer = config.commandHistoryContainerElement; // Added this.storyContainer = config.storyContainerElement; // Added this.rewindButton = config.rewindButtonElement || document.getElementById('rewind'); this.saveButton = config.saveButtonElement || document.getElementById('save'); this.loadButton = config.loadButtonElement || document.getElementById('reload'); this.speechButton = config.speechButtonElement || document.getElementById('speech'); this.speedReset = config.speedResetElement || document.getElementById('speed_reset'); // Translations this.translations = config.translations || {}; this.locale = config.locale || 'en-us'; // Initial UI state this.updateButtonStates({ started: false, canLoad: false }); // Start with buttons disabled this.updateSpeechButtonAvailability(false); // Start with speech disabled } /** * Set up event listeners */ setupEventListeners() { // Speed slider if (this.speedSlider) { this.speedSlider.addEventListener('input', this.handleSpeedChange.bind(this)); } // Speed reset button if (this.speedReset) { this.speedReset.addEventListener('click', this.handleSpeedReset.bind(this)); } // Rewind button if (this.rewindButton) { this.rewindButton.addEventListener('click', this.handleRewindClick.bind(this)); } // Save button if (this.saveButton) { this.saveButton.addEventListener('click', this.handleSaveClick.bind(this)); } // Load button if (this.loadButton) { this.loadButton.addEventListener('click', this.handleLoadClick.bind(this)); } // Speech button if (this.speechButton) { this.speechButton.addEventListener('click', this.handleSpeechToggle.bind(this)); } // Fast forward (spacebar or click on right page) window.addEventListener('keydown', (event) => { if (event.code === 'Space') { this.handleFastForward(); } }); document.getElementById('page_right')?.addEventListener('click', this.handleFastForward.bind(this)); // Window resize window.addEventListener('resize', this.handleWindowResize.bind(this)); } /** * Handle speed slider change * @param {Event} event - The input event */ handleSpeedChange(event) { if (!this.animationQueue) return; const value = parseFloat(event.target.value); const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01; this.animationQueue.setSpeed(speed); } /** * Handle speed reset button click */ handleSpeedReset() { if (!this.speedSlider || !this.animationQueue) return; this.speedSlider.value = 50; const speed = Math.pow(100.0 - 50, 3) / 10000 * 10 + 0.01; this.animationQueue.setSpeed(speed); } /** * Handle rewind button click */ handleRewindClick() { if (this.rewindButton.getAttribute('disabled') === 'disabled') { return; } // Use localized confirm message if available const confirmMsg = this.translations[this.locale]?.confirm_restart || 'Are you sure you want to restart the game? All progress will be lost.'; if (confirm(confirmMsg)) { if (this.onRestartRequest) { this.onRestartRequest(); } else { console.warn("UiController: onRestartRequest callback not set."); } } } /** * Handle save button click */ handleSaveClick() { if (this.saveButton.getAttribute('disabled') === 'disabled') { return; } if (this.onSaveRequest) { this.onSaveRequest(); } else { console.warn("UiController: onSaveRequest callback not set."); } } /** * Handle load button click */ handleLoadClick() { if (this.loadButton.getAttribute('disabled') === 'disabled') { return; } if (this.onLoadRequest) { this.onLoadRequest(); } else { console.warn("UiController: onLoadRequest callback not set."); } } /** * Handle speech toggle button click */ handleSpeechToggle() { if (!this.ttsHandler) { console.warn("UiController: ttsHandler not set. Cannot toggle speech."); // Attempt to use ttsPlayer as fallback if needed, but prefer ttsHandler if (this.ttsPlayer && this.speechButton.getAttribute('disabled') !== 'disabled') { const enabled = this.ttsPlayer.toggle(); this.updateSpeechButtonStyling(enabled); } return; } if (this.speechButton.getAttribute('disabled') === 'disabled') { return; } // Ensure AudioContext is resumed on user interaction if using Kokoro if (window.ttsFactory && window.ttsFactory.usingKokoro && this.ttsHandler.audioContext && this.ttsHandler.audioContext.state === 'suspended') { this.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err)); } // Set user activation flag for the handler this.ttsHandler.hasUserActivation = true; const enabled = this.ttsHandler.toggle(); this.updateSpeechButtonStyling(enabled); // Update visual style if (enabled) { // Speak the last narrative if speech was just enabled and story container is available if (this.storyContainer) { const lastNarrative = this.storyContainer.lastElementChild; if (lastNarrative && lastNarrative.classList.contains('narrative')) { // Check if it's narrative text console.log("Speaking last narrative on toggle"); // Use a slight delay to ensure audio context is resumed setTimeout(() => this.ttsHandler.speak(lastNarrative.textContent), 50); } } } else { // If disabling, ensure speech stops this.ttsHandler.stop(); } } /** * Handle fast forward (spacebar or click) */ handleFastForward() { if (!this.animationQueue) return; this.animationQueue.fastForward(); } /** * Handle window resize */ handleWindowResize() { this.updateBookDimensions(); this.updateParagraphHeight(); } /** * Sets the active TTS handler. * @param {object} handler - The TTS handler instance (e.g., KokoroHandler, BrowserTtsHandler). */ setTtsHandler(handler) { this.ttsHandler = handler; console.log("UiController: TTS Handler set.", handler); // Update button state based on the new handler's status this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false); } /** * Update the book dimensions based on viewport size */ updateBookDimensions() { const vw = window.innerWidth; const vh = window.innerHeight; const viewportAspectRatio = vw / vh; const imageAspectRatio = 2727 / 1691; let bookWidth, bookHeight; if (viewportAspectRatio > imageAspectRatio) { bookWidth = vh * imageAspectRatio; bookHeight = vh; } else { bookWidth = vw; bookHeight = vw / imageAspectRatio; } document.documentElement.style.setProperty('--book-width', `${bookWidth}px`); document.documentElement.style.setProperty('--book-height', `${bookHeight}px`); // Setting a CSS variable that will be either vw or vh depending on the viewport aspect ratio document.documentElement.style.setProperty( "--viewport-dimension", viewportAspectRatio > imageAspectRatio ? 'vw' : 'vh' ); document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio); const story = document.getElementById("story"); if (story) { const paddingTop = window.getComputedStyle(story).paddingTop; const paddingBottom = window.getComputedStyle(story).paddingBottom; document.documentElement.style.setProperty('--story-line-height', (story.clientHeight - paddingTop - paddingBottom) / 28); } } /** * Update paragraph heights based on viewport */ updateParagraphHeight() { document.querySelectorAll("#story p").forEach((element) => { if (element.dataset.vpc) { const pHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height); const newHeight = pHeight * element.dataset.vpc / 100 + 'px'; element.style.height = newHeight; } }); } /** * Update the speech button styling based on enabled state. * @param {boolean} enabled - Whether speech is enabled. */ updateSpeechButtonStyling(enabled = false) { if (!this.speechButton) return; if (enabled) { this.speechButton.style.fontWeight = 'bold'; this.speechButton.style.color = '#000'; this.speechButton.style.backgroundColor = '#eee'; } else { this.speechButton.style.fontWeight = 'normal'; this.speechButton.style.color = '#333'; this.speechButton.style.backgroundColor = ''; } } /** * Updates the enabled/disabled state and title of the speech button. * @param {boolean} available - Whether any TTS system is available. * @param {string} [type] - The type of TTS system available ('kokoro', 'browser', etc.). */ updateSpeechButtonAvailability(available, type) { if (!this.speechButton) return; if (available) { this.speechButton.removeAttribute('disabled'); const ttsName = type === 'kokoro' ? 'Kokoro TTS' : (type === 'browser' ? 'Browser TTS' : 'TTS'); const title = this.translations[this.locale]?.title_speech || `Toggle Text-to-Speech (${ttsName})`; this.speechButton.setAttribute('title', title); // Update style based on current handler state if available this.updateSpeechButtonStyling(this.ttsHandler ? this.ttsHandler.isEnabled() : false); } else { this.speechButton.setAttribute('disabled', 'disabled'); const title = this.translations[this.locale]?.title_speech_unavailable || 'Text-to-Speech not available'; this.speechButton.setAttribute('title', title); this.updateSpeechButtonStyling(false); // Ensure style is off } } /** * Updates the enabled/disabled state of control buttons based on game state. * @param {object} gameState - The current game state from AnimatedFiction. * @param {boolean} gameState.started - Whether the game has started. * @param {boolean} [gameState.canLoad] - Whether a saved game exists to be loaded. */ updateButtonStates(gameState) { if (this.rewindButton) { if (gameState.started) { this.rewindButton.removeAttribute('disabled'); } else { this.rewindButton.setAttribute('disabled', 'disabled'); } } if (this.saveButton) { if (gameState.started) { this.saveButton.removeAttribute('disabled'); } else { this.saveButton.setAttribute('disabled', 'disabled'); } } if (this.loadButton) { // Enable load button if a save exists (indicated by canLoad flag or similar) // We might need a more robust way to check for saved state existence. // For now, enable if game started OR if canLoad is explicitly true. if (gameState.started || gameState.canLoad) { this.loadButton.removeAttribute('disabled'); } else { this.loadButton.setAttribute('disabled', 'disabled'); } } // Speech button availability is handled separately by updateSpeechButtonAvailability } /** * Updates the visual display of the speed slider. * @param {number} value - The speed value (0-100). */ updateSpeedDisplay(value) { if (this.speedSlider) { this.speedSlider.value = value; } } /** * Insert an element after a delay (Helper, potentially move elsewhere or keep if used) * @param {number} delay - The delay in milliseconds * @param {HTMLElement} target - The target element to append to * @param {HTMLElement} el - The element to insert * @param {boolean} fadeIn - Whether to fade in the element */ insertAfter(delay, target, el, fadeIn = true) { if (this.animationQueue) { if (fadeIn) { el.classList.add("fade-in"); this.animationQueue.schedule(function() { target.appendChild(el); }, delay); } else { this.animationQueue.schedule(function() { target.appendChild(el); }, delay); } } else { // Fallback if no animation queue if (fadeIn) { el.classList.add("fade-in"); setTimeout(() => { target.appendChild(el); }, delay); } else { setTimeout(() => { target.appendChild(el); }, delay); } } } /** * Set the locale for translations * @param {string} locale - The locale code */ setLocale(locale) { this.locale = locale; if (this.translations[locale]) { Object.keys(this.translations[locale]).forEach(key => { const prefix = key.substring(0, 5); const postfix = key.substring(6, key.length); const elements = document.querySelectorAll(`.l10n-${(prefix === 'title' ? postfix : key)}`); elements.forEach(element => { if (prefix === "title") { element.title = this.translations[locale][key]; } else { element.innerHTML = this.translations[locale][key]; } }); }); } else { console.error(`Locale ${locale} is not defined`); } } }