diff --git a/public/css/style.css b/public/css/style.css index 1e54c98..b2cbe02 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1352,6 +1352,37 @@ html[data-process-state="playing-ready"] body { cursor: var(--process-cursor, progress) !important; } +html body.modal-open { + cursor: var(--default-cursor, default) !important; +} + +html[data-process-state="command-waiting"] a:not([aria-disabled="true"]), +html[data-process-state="command-waiting"] button:not([disabled]), +html[data-process-state="command-waiting"] input:not([disabled]), +html[data-process-state="command-waiting"] textarea:not([disabled]), +html[data-process-state="command-waiting"] select:not([disabled]), +html[data-process-state="command-waiting"] [role="button"], +html[data-process-state="waiting-generating"] a:not([aria-disabled="true"]), +html[data-process-state="waiting-generating"] button:not([disabled]), +html[data-process-state="waiting-generating"] input:not([disabled]), +html[data-process-state="waiting-generating"] textarea:not([disabled]), +html[data-process-state="waiting-generating"] select:not([disabled]), +html[data-process-state="waiting-generating"] [role="button"], +html[data-process-state="playing-generating"] a:not([aria-disabled="true"]), +html[data-process-state="playing-generating"] button:not([disabled]), +html[data-process-state="playing-generating"] input:not([disabled]), +html[data-process-state="playing-generating"] textarea:not([disabled]), +html[data-process-state="playing-generating"] select:not([disabled]), +html[data-process-state="playing-generating"] [role="button"], +html[data-process-state="playing-ready"] a:not([aria-disabled="true"]), +html[data-process-state="playing-ready"] button:not([disabled]), +html[data-process-state="playing-ready"] input:not([disabled]), +html[data-process-state="playing-ready"] textarea:not([disabled]), +html[data-process-state="playing-ready"] select:not([disabled]), +html[data-process-state="playing-ready"] [role="button"] { + cursor: var(--pointer-cursor, pointer) !important; +} + /* Placeholder styling - lighter and italic, with padding to avoid cursor overlap */ #player_input::placeholder { color: var(--ink-disabled); diff --git a/public/index.html b/public/index.html index 332e64d..9961aa8 100644 --- a/public/index.html +++ b/public/index.html @@ -49,7 +49,7 @@ left: 0; width: 100%; height: 100%; - background-color: rgba(0, 0, 0, 0.85); + background-color: #000; display: flex; justify-content: center; align-items: center; diff --git a/public/js/audio-manager-module.js b/public/js/audio-manager-module.js index 56f6957..ac2a589 100644 --- a/public/js/audio-manager-module.js +++ b/public/js/audio-manager-module.js @@ -32,6 +32,7 @@ class AudioManagerModule extends BaseModule { this.ttsQueueEmpty = true; this.pendingMusicPlayback = null; this.currentMusicState = null; + this.mediaPreloadTimeoutMs = 60000; this.assetRoots = { images: '/images/', music: '/music/', @@ -493,6 +494,10 @@ class AudioManagerModule extends BaseModule { .then(audio => { this.setMediaVolume(audio, this.getSfxVolume()); return audio; + }) + .catch(error => { + this.sfxCache.delete(url); + throw error; }); this.sfxCache.set(url, promise); return promise; @@ -505,6 +510,10 @@ class AudioManagerModule extends BaseModule { .then(audio => { this.setMediaVolume(audio, this.getMusicVolume()); return audio; + }) + .catch(error => { + this.musicCache.delete(url); + throw error; }); this.musicCache.set(url, promise); return promise; @@ -517,14 +526,24 @@ class AudioManagerModule extends BaseModule { const finish = (result, error = null) => { if (settled) return; settled = true; + clearTimeout(timeoutId); audio.removeEventListener('canplaythrough', onReady); audio.removeEventListener('loadeddata', onReady); audio.removeEventListener('error', onError); - if (error) reject(error); - else resolve(result); + if (error) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + reject(error); + } else { + resolve(result); + } }; const onReady = () => finish(audio); const onError = () => finish(null, new Error(`Failed to preload ${label}: ${url}`)); + const timeoutId = setTimeout(() => { + finish(null, new Error(`Timed out preloading ${label}: ${url}`)); + }, this.mediaPreloadTimeoutMs); audio.preload = 'auto'; audio.addEventListener('canplaythrough', onReady, { once: true }); audio.addEventListener('loadeddata', onReady, { once: true }); @@ -538,16 +557,36 @@ class AudioManagerModule extends BaseModule { if (this.imageCache.has(url)) return this.imageCache.get(url); const promise = new Promise((resolve, reject) => { const image = new Image(); + let settled = false; + const finish = (result, error = null) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + image.onload = null; + image.onerror = null; + if (error) { + image.src = ''; + reject(error); + } else { + resolve(result); + } + }; image.decoding = 'async'; image.onload = () => { if (typeof image.decode === 'function') { - image.decode().catch(() => null).then(() => resolve(image)); + image.decode().catch(() => null).then(() => finish(image)); } else { - resolve(image); + finish(image); } }; - image.onerror = () => reject(new Error(`Failed to preload image: ${url}`)); + image.onerror = () => finish(null, new Error(`Failed to preload image: ${url}`)); + const timeoutId = setTimeout(() => { + finish(null, new Error(`Timed out preloading image: ${url}`)); + }, this.mediaPreloadTimeoutMs); image.src = url; + }).catch(error => { + this.imageCache.delete(url); + throw error; }); this.imageCache.set(url, promise); return promise; @@ -571,7 +610,7 @@ class AudioManagerModule extends BaseModule { throw error; })); - await Promise.all(tasks); + return Promise.all(tasks); } handleMediaCue(cue) { diff --git a/public/js/loader.js b/public/js/loader.js index 84458fc..1dbc459 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -44,6 +44,7 @@ const ModuleLoader = (function() { let gameLoopModule = null; // Add variable to hold game loop instance let moduleTimings = {}; // Track timing data for modules let finalizationTimer = null; + let moduleExitAnimations = new Map(); /** * Initialize the loader @@ -598,6 +599,7 @@ const ModuleLoader = (function() { // If no overlay exists in the HTML, create a minimal one loadingOverlay = document.createElement('div'); loadingOverlay.className = 'loading-overlay'; + loadingOverlay.style.backgroundColor = '#000'; loadingOverlay.style.transition = 'opacity 0.5s ease-out'; document.body.appendChild(loadingOverlay); @@ -639,6 +641,7 @@ const ModuleLoader = (function() { modulesList = loadingOverlay.querySelector('#modules-list'); // Ensure transition is set + loadingOverlay.style.backgroundColor = '#000'; loadingOverlay.style.transition = 'opacity 0.5s ease-out'; } } @@ -728,16 +731,7 @@ const ModuleLoader = (function() { // if (areAllModulesComplete()) { // hideLoadingOverlay(); // } - const moduleItem = document.getElementById(`module-${moduleId}`); - if (moduleItem) { - // Ensure module-finished class is added with a small delay to avoid race conditions - setTimeout(() => { - moduleItem.classList.add('module-finished'); - moduleItem.addEventListener('animationend', () => { - moduleItem.remove(); - }, { once: true }); - }, 120); - } + animateModuleItemExit(moduleId); } else if (state === ModuleState.ERROR) { moduleProgress[moduleId] = 100; } @@ -810,42 +804,40 @@ const ModuleLoader = (function() { /** * Finalize the loading process */ - function finalizeLoading() { + async function finalizeLoading() { console.log('Loading completed. Finalizing...'); try { // Display timing data displayModuleTimings(); - completeFinalization(); + await completeFinalization(); } catch (error) { console.error('Error during finalization:', error); - // Force hide the overlay even if there was an error - hideOverlay(); + await hideOverlay(); } } /** * Complete the finalization process */ - function completeFinalization() { + async function completeFinalization() { isLoadingComplete = true; // Call the start method on the game loop module directly // Ensure the game loop module was found during initialization if (gameLoopModule && typeof gameLoopModule.start === 'function') { // Hide the overlay first, then start the game loop - hideOverlay(() => { - console.log("Loader: Overlay hidden, starting Game Loop."); - try { - gameLoopModule.start(); - } catch (error) { - console.error("Error starting Game Loop:", error); - } - }); + await hideOverlay(); + console.log("Loader: Overlay hidden, starting Game Loop."); + try { + gameLoopModule.start(); + } catch (error) { + console.error("Error starting Game Loop:", error); + } } else { console.error("Loader: Game Loop module not found or start method missing."); // Hide overlay anyway, but log error - hideOverlay(); + await hideOverlay(); } } @@ -884,37 +876,178 @@ const ModuleLoader = (function() { * Then completely remove it from the DOM * @param {Function} [callback] - Optional callback to execute after fade completes */ - function hideOverlay(callback) { // Added callback parameter + async function hideOverlay(callback) { // Added callback parameter if (!loadingOverlay) { if (callback) callback(); // Call callback immediately if no overlay return; } + await waitForProgressIndicatorsToExit(); + // Set opacity to 0 to trigger the fade-out transition loadingOverlay.style.opacity = '0'; - // Use transition event listener to remove from DOM after fade completes - loadingOverlay.addEventListener('transitionend', function handler(e) { - // Only handle the opacity transition - if (e.propertyName === 'opacity') { - console.log('Module Loader: Removing overlay from DOM'); + await waitForTransition(loadingOverlay, 'opacity'); - // Remove from DOM completely - if (loadingOverlay.parentNode) { - loadingOverlay.parentNode.removeChild(loadingOverlay); + console.log('Module Loader: Removing overlay from DOM'); + + // Remove from DOM completely + if (loadingOverlay.parentNode) { + loadingOverlay.parentNode.removeChild(loadingOverlay); + } + + // Set to null to allow garbage collection + loadingOverlay = null; + + // Execute the callback if provided + if (callback) callback(); + + } + + /** + * Animate one module progress row out and resolve only after its own + * fade/collapse animation has finished. + * @param {string} moduleId - Module ID + * @returns {Promise} + */ + function animateModuleItemExit(moduleId) { + if (moduleExitAnimations.has(moduleId)) { + return moduleExitAnimations.get(moduleId); + } + + const moduleItem = document.getElementById(`module-${moduleId}`); + if (!moduleItem) { + return Promise.resolve(); + } + + const exitPromise = new Promise(resolve => { + let settled = false; + let timeoutId = null; + + const finish = () => { + if (settled) return; + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + moduleItem.removeEventListener('animationend', handleAnimationEnd); + if (moduleItem.parentNode) { + moduleItem.parentNode.removeChild(moduleItem); + } + moduleExitAnimations.delete(moduleId); + resolve(); + }; + + const handleAnimationEnd = event => { + if (event.target === moduleItem && event.animationName === 'fadeOutModule') { + finish(); + } + }; + + // Let the finished status paint briefly before the row collapses. + setTimeout(() => { + if (!moduleItem.isConnected) { + finish(); + return; } - // Remove the event listener to prevent memory leaks - loadingOverlay.removeEventListener('transitionend', handler); + moduleItem.addEventListener('animationend', handleAnimationEnd); + moduleItem.classList.add('module-finished'); - // Set to null to allow garbage collection - loadingOverlay = null; - - // Execute the callback if provided - if (callback) callback(); - } + const animationTime = getLongestCssTime(moduleItem, 'animation'); + timeoutId = setTimeout(finish, Math.max(animationTime + 80, 80)); + }, 120); }); + moduleExitAnimations.set(moduleId, exitPromise); + return exitPromise; + } + + /** + * Make every remaining progress row leave, then wait for all of them. + * This keeps the overlay fade from racing the final row animations. + */ + async function waitForProgressIndicatorsToExit() { + if (modulesList) { + modulesList.querySelectorAll('.module-item').forEach(moduleItem => { + const moduleId = moduleItem.id.replace(/^module-/, ''); + animateModuleItemExit(moduleId); + }); + } + + if (moduleExitAnimations.size > 0) { + await Promise.allSettled([...moduleExitAnimations.values()]); + } + } + + /** + * Wait for a CSS transition on an element. The timeout is derived from + * computed CSS duration/delay so non-animated cases resolve immediately. + * @param {Element} element - Element that is transitioning + * @param {string} propertyName - CSS property to wait for + * @returns {Promise} + */ + function waitForTransition(element, propertyName) { + const transitionTime = getLongestCssTime(element, 'transition'); + + if (transitionTime <= 0) { + return Promise.resolve(); + } + + return new Promise(resolve => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + element.removeEventListener('transitionend', handleTransitionEnd); + resolve(); + }; + const handleTransitionEnd = event => { + if (event.target === element && event.propertyName === propertyName) { + finish(); + } + }; + const timeoutId = setTimeout(finish, transitionTime + 80); + element.addEventListener('transitionend', handleTransitionEnd); + }); + } + + /** + * Read the longest duration+delay pair from computed transition/animation CSS. + * @param {Element} element - Element to inspect + * @param {'transition'|'animation'} kind - CSS timing group + * @returns {number} milliseconds + */ + function getLongestCssTime(element, kind) { + const style = window.getComputedStyle(element); + const durations = parseCssTimeList(style[`${kind}Duration`]); + const delays = parseCssTimeList(style[`${kind}Delay`]); + const count = Math.max(durations.length, delays.length); + let longest = 0; + + for (let i = 0; i < count; i++) { + const duration = durations[i % durations.length] || 0; + const delay = delays[i % delays.length] || 0; + longest = Math.max(longest, duration + delay); + } + + return longest; + } + + /** + * Parse a comma separated CSS time list into milliseconds. + * @param {string} value - CSS time list + * @returns {number[]} + */ + function parseCssTimeList(value) { + return String(value || '0s').split(',').map(part => { + const text = part.trim(); + const amount = Number.parseFloat(text); + if (!Number.isFinite(amount)) return 0; + return text.endsWith('ms') ? amount : amount * 1000; + }); } /** diff --git a/public/js/playback-coordinator-module.js b/public/js/playback-coordinator-module.js index 1378315..9645fd1 100644 --- a/public/js/playback-coordinator-module.js +++ b/public/js/playback-coordinator-module.js @@ -66,6 +66,13 @@ class PlaybackCoordinatorModule extends BaseModule { this.isPlaying = true; this.currentSentence = sentence; + document.dispatchEvent(new CustomEvent('story:process-state', { + detail: { + state: 'playing-ready', + reason: 'playback-start', + sentenceId: sentence?.id ?? null + } + })); try { // Start TTS first, then begin text animation when the audio element diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index d1b39ab..5c30a39 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -5,6 +5,8 @@ import { BaseModule } from './base-module.js'; const TTS_GENERATION_TIMEOUT_MS = 60000; +const ASSET_PRELOAD_TIMEOUT_MS = 60000; +const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000; class SentenceQueueModule extends BaseModule { constructor() { @@ -25,7 +27,9 @@ class SentenceQueueModule extends BaseModule { this.lastContinueAt = 0; this.pauseBeforeNextReason = null; this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS; + this.assetPreloadTimeoutMs = ASSET_PRELOAD_TIMEOUT_MS; this.generationRequests = new Map(); + this.assetPreloadRequests = new Map(); // Bind methods this.bindMethods([ @@ -44,6 +48,8 @@ class SentenceQueueModule extends BaseModule { 'runTtsPreloadWithTimeout', 'cancelBlockingGeneration', 'cancelGenerationRequests', + 'cancelBlockingAssetPreloads', + 'cancelAssetPreloads', 'isSpeechItem', 'getMediaPauseSeconds', 'readFirstFiniteNumber', @@ -96,7 +102,12 @@ class SentenceQueueModule extends BaseModule { this.addEventListener(document, 'ui:command', (event) => { if (event.detail?.type === 'continue') { this.lastContinueAt = performance.now(); - this.cancelBlockingGeneration('user-fast-forward'); + this.cancelBlockingGeneration('user-fast-forward', { + minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS + }); + this.cancelBlockingAssetPreloads('user-fast-forward', { + minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS + }); } }); return true; @@ -159,6 +170,16 @@ class SentenceQueueModule extends BaseModule { await this.waitForManualContinue(reason); } + document.dispatchEvent(new CustomEvent('story:process-state', { + detail: { + state: 'waiting-generating', + reason: 'preparing-next-block', + sentenceId: item?.id || null, + blockId: item?.blockId || null, + kind: item?.kind || item?.type || 'paragraph' + } + })); + const sentence = await this.getPreparedSentence(item); // Prefetch far enough ahead that media pauses do not block TTS @@ -189,6 +210,12 @@ class SentenceQueueModule extends BaseModule { } catch (error) { console.error("SentenceQueue: Error processing sentence:", error); + const failedItem = this.sentenceQueue.shift(); + console.warn('SentenceQueue: Dropped failed queue item so playback can continue', { + sentenceId: failedItem?.id || item?.id || null, + blockId: failedItem?.blockId || item?.blockId || null, + error + }); if (item.callback) item.callback({ success: false, error }); } finally { this.isProcessing = false; @@ -334,8 +361,12 @@ class SentenceQueueModule extends BaseModule { }); } - cancelBlockingGeneration(reason = 'cancelled') { - this.cancelGenerationRequests(reason, request => request.blocking === true); + cancelBlockingGeneration(reason = 'cancelled', options = {}) { + const minWaitMs = Math.max(0, Number(options.minWaitMs || 0)); + this.cancelGenerationRequests(reason, request => + request.blocking === true && + (performance.now() - request.startedAt) >= minWaitMs + ); } cancelGenerationRequests(reason = 'cancelled', predicate = () => true) { @@ -358,6 +389,30 @@ class SentenceQueueModule extends BaseModule { } } } + + cancelBlockingAssetPreloads(reason = 'cancelled', options = {}) { + const minWaitMs = Math.max(0, Number(options.minWaitMs || 0)); + this.cancelAssetPreloads(reason, request => + request.blocking === true && + (performance.now() - request.startedAt) >= minWaitMs + ); + } + + cancelAssetPreloads(reason = 'cancelled', predicate = () => true) { + for (const [requestId, request] of this.assetPreloadRequests.entries()) { + if (!predicate(request)) continue; + console.warn('SentenceQueue: Cancelling asset preload request', { + requestId, + sentenceId: request.sentenceId, + reason, + elapsedMs: Math.round(performance.now() - request.startedAt), + assetType: request.assetType + }); + if (typeof request.finish === 'function') { + request.finish({ success: false, reason: 'asset_preload_cancelled', cancelled: true }); + } + } + } /** * Estimate speech duration based on character count @@ -517,7 +572,7 @@ class SentenceQueueModule extends BaseModule { const layoutText = metadata.layoutText || text; const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : ''; const dropCapWidth = metadata.dropCap - ? this.measureDropCapReservation(storyElement, dropCapText, lineHeight) + ? await this.measureDropCapReservation(storyElement, dropCapText, lineHeight) : 0; const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText; const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0 @@ -598,7 +653,7 @@ class SentenceQueueModule extends BaseModule { async preloadAssetsForItem(item = {}, context = {}) { const audioManager = this.getModule('audio-manager'); - if (!audioManager) return; + if (!audioManager) return { success: true, reason: 'audio_manager_unavailable' }; const tasks = []; const type = String(item.type || item.kind || '').toLowerCase(); @@ -610,28 +665,82 @@ class SentenceQueueModule extends BaseModule { } const pending = tasks.filter(Boolean); - if (pending.length === 0) return; + if (pending.length === 0) return { success: true, reason: 'no_assets' }; const state = context.blocking ? 'waiting-generating' : 'playing-generating'; + const sentenceId = context.sentenceId || item.id || null; + const requestId = `${sentenceId || 'asset'}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`; + const startedAt = performance.now(); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state, reason: 'asset-preload-start', - sentenceId: context.sentenceId || item.id || null, + sentenceId, assetType: type || 'cue' } })); - await Promise.all(pending); + const result = await new Promise(resolve => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + this.assetPreloadRequests.delete(requestId); + resolve(value); + }; + const timeoutId = setTimeout(() => { + console.warn('SentenceQueue: Asset preload timed out; continuing without confirmed asset', { + sentenceId, + timeoutMs: this.assetPreloadTimeoutMs, + assetType: type || 'cue' + }); + finish({ success: false, reason: 'asset_preload_timeout', timedOut: true }); + }, this.assetPreloadTimeoutMs); + + this.assetPreloadRequests.set(requestId, { + blocking: context.blocking !== false, + sentenceId, + assetType: type || 'cue', + startedAt, + finish + }); + + Promise.allSettled(pending) + .then(results => { + const failures = results.filter(entry => entry.status === 'rejected'); + if (failures.length > 0) { + console.warn('SentenceQueue: Some assets failed to preload; continuing without them', { + sentenceId, + assetType: type || 'cue', + failures: failures.map(entry => entry.reason) + }); + finish({ success: false, reason: 'asset_preload_failed', failures }); + return; + } + finish({ success: true, reason: 'asset_preload_complete' }); + }) + .catch(error => { + console.warn('SentenceQueue: Asset preload failed unexpectedly; continuing', { + sentenceId, + assetType: type || 'cue', + error + }); + finish({ success: false, reason: 'asset_preload_error', error }); + }); + }); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', - reason: 'asset-preload-complete', - sentenceId: context.sentenceId || item.id || null, - assetType: type || 'cue' + reason: result.success ? 'asset-preload-complete' : result.reason, + sentenceId, + assetType: type || 'cue', + degraded: !result.success } })); + + return result; } shouldPauseAfterSentence(sentence) { @@ -688,7 +797,7 @@ class SentenceQueueModule extends BaseModule { async getPreparedSentence(item) { const pending = this.prefetchingSpeech.get(this.getCacheKey(item)); if (pending) { - await pending.catch(() => null); + pending.catch(() => null); } return this.prepareSentence(item); @@ -882,7 +991,7 @@ class SentenceQueueModule extends BaseModule { return String(text).replace(dropCap, '').trimStart(); } - measureDropCapReservation(container, dropCapText, lineHeight) { + async measureDropCapReservation(container, dropCapText, lineHeight) { if (!container || !dropCapText) { return lineHeight * 1.34; } @@ -905,8 +1014,25 @@ class SentenceQueueModule extends BaseModule { probeParagraph.appendChild(probe); container.appendChild(probeParagraph); - const rect = probe.getBoundingClientRect(); const computed = window.getComputedStyle(probe); + if (document.fonts && typeof document.fonts.load === 'function') { + const fontDescriptor = [ + computed.fontStyle, + computed.fontVariant, + computed.fontWeight, + computed.fontSize, + computed.fontFamily + ].filter(Boolean).join(' '); + try { + await document.fonts.load(fontDescriptor, dropCapText); + await document.fonts.ready; + await new Promise(resolve => requestAnimationFrame(resolve)); + } catch (error) { + console.warn('SentenceQueue: Drop-cap font load check failed; measuring current font state', error); + } + } + + const rect = probe.getBoundingClientRect(); let inkRight = 0; try { const canvas = document.createElement('canvas'); diff --git a/public/js/socket-client-module.js b/public/js/socket-client-module.js index 19c98be..d2c7839 100644 --- a/public/js/socket-client-module.js +++ b/public/js/socket-client-module.js @@ -4,6 +4,9 @@ */ import { BaseModule } from './base-module.js'; +const GAME_API_TIMEOUT_MS = 60000; +const PLAYER_COMMAND_TIMEOUT_MS = 60000; + class SocketClientModule extends BaseModule { constructor() { super('socket-client', 'Socket Client'); @@ -23,6 +26,10 @@ class SocketClientModule extends BaseModule { this.defaultHost = 'localhost:3000'; this.receivedBlockCounter = 0; this.receivedParagraphCounter = 0; + this.pendingCommandTimer = null; + this.pendingCommand = null; + this.gameApiTimeoutMs = GAME_API_TIMEOUT_MS; + this.playerCommandTimeoutMs = PLAYER_COMMAND_TIMEOUT_MS; // Bind methods using parent's bindMethods utility this.bindMethods([ @@ -54,6 +61,9 @@ class SocketClientModule extends BaseModule { 'cueMarkersFromTags', 'dispatchChoices', 'dispatchInputMode', + 'handleServerError', + 'clearPendingCommand', + 'translate', 'isStructuralTag', 'blocksFromTags', 'enqueueStructuredBlock', @@ -191,8 +201,13 @@ class SocketClientModule extends BaseModule { // Special handling for narrative text this.socket.on('narrativeResponse', (data) => { + this.clearPendingCommand('narrative-response'); this.processTurnResult(data); }); + + this.socket.on('error', (error) => { + this.handleServerError(error); + }); this.socket.on('gameConfig', (data) => { document.dispatchEvent(new CustomEvent('game:config', { @@ -300,6 +315,45 @@ class SocketClientModule extends BaseModule { })); } + handleServerError(error) { + const message = String(error?.message || error?.error || error || 'The game server reported an error.'); + console.error('Socket Client: Server error event:', error); + this.clearPendingCommand('server-error'); + document.dispatchEvent(new CustomEvent('story:tag', { + detail: { + key: 'error', + value: message, + source: 'server' + } + })); + document.dispatchEvent(new CustomEvent('story:process-state', { + detail: { state: 'ready', reason: 'server-error', message } + })); + } + + clearPendingCommand(reason = 'cleared') { + if (this.pendingCommandTimer) { + clearTimeout(this.pendingCommandTimer); + this.pendingCommandTimer = null; + } + if (this.pendingCommand) { + console.log('Socket Client: Command wait cleared', { + reason, + command: this.pendingCommand + }); + } + this.pendingCommand = null; + } + + translate(key, fallback, params = {}) { + const localization = this.getModule('localization'); + if (localization && typeof localization.translate === 'function') { + const translated = localization.translate(key, params); + if (translated && translated !== key) return translated; + } + return fallback; + } + processParagraphResult(paragraph, turnId, pendingParagraph = null) { const pending = pendingParagraph && typeof pendingParagraph === 'object' ? pendingParagraph @@ -652,7 +706,30 @@ class SocketClientModule extends BaseModule { } try { + this.clearPendingCommand('new-command'); this.socket.emit('playerCommand', { command }); + this.pendingCommand = command; + this.pendingCommandTimer = setTimeout(() => { + const timedOutCommand = this.pendingCommand; + this.clearPendingCommand('timeout'); + console.warn('Socket Client: Player command timed out', { + timeoutMs: this.playerCommandTimeoutMs, + command: timedOutCommand + }); + document.dispatchEvent(new CustomEvent('story:tag', { + detail: { + key: 'alert', + value: this.translate( + 'popup.commandTimeout', + 'The game server did not answer in time. You can try again.' + ), + source: 'client-timeout' + } + })); + document.dispatchEvent(new CustomEvent('story:process-state', { + detail: { state: 'ready', reason: 'command-timeout', command: timedOutCommand } + })); + }, this.playerCommandTimeoutMs); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'command-waiting', reason: 'command-sent', command } })); @@ -677,8 +754,26 @@ class SocketClientModule extends BaseModule { return; } - this.socket.emit('gameApi', { method, args }, (response) => { + let settled = false; + const finish = (response) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); resolve(response || { success: false, error: 'empty_response' }); + }; + const timeoutId = setTimeout(() => { + console.warn('Socket Client: gameApi call timed out', { + method, + timeoutMs: this.gameApiTimeoutMs + }); + document.dispatchEvent(new CustomEvent('story:process-state', { + detail: { state: 'ready', reason: 'game-api-timeout', method } + })); + finish({ success: false, error: 'timeout', method }); + }, this.gameApiTimeoutMs); + + this.socket.emit('gameApi', { method, args }, (response) => { + finish(response); }); }); } diff --git a/public/js/tts-factory-module.js b/public/js/tts-factory-module.js index e0c4ea2..647f02a 100644 --- a/public/js/tts-factory-module.js +++ b/public/js/tts-factory-module.js @@ -1224,9 +1224,11 @@ class TTSFactoryModule extends BaseModule { return null; } + let hash = null; + let generationStarted = false; try { // Generate a hash for this speech request - const hash = await this.generateSpeechHash(text); + hash = await this.generateSpeechHash(text); // Check if we have this audio in cache const cachedData = await this.getCachedSpeech(hash); @@ -1242,6 +1244,7 @@ class TTSFactoryModule extends BaseModule { // Cache miss - need to generate new speech data this.cacheMisses++; + generationStarted = true; this.emitProcessState('waiting-generating', { reason: 'tts-cache-miss', hash }); // If the handler has a preloadSpeech method, use it @@ -1253,15 +1256,21 @@ class TTSFactoryModule extends BaseModule { await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration); console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`); this.emitProcessState('playing-ready', { reason: 'tts-generated', hash }); + } else if (generationStarted) { + this.emitProcessState('playing-ready', { reason: 'tts-generation-unavailable', hash }); } return preloadData; } else { console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`); + this.emitProcessState('playing-ready', { reason: 'tts-preload-unsupported', hash }); return null; } } catch (error) { console.error("TTS Factory: Error preloading speech:", error); + if (generationStarted || hash) { + this.emitProcessState('playing-ready', { reason: 'tts-generation-error', hash, error }); + } return null; } } diff --git a/public/js/ui-controller-module.js b/public/js/ui-controller-module.js index 1862923..d4e5aa2 100644 --- a/public/js/ui-controller-module.js +++ b/public/js/ui-controller-module.js @@ -55,6 +55,7 @@ class UIControllerModule extends BaseModule { 'hideUI', 'clearDisplay', 'sendCommand', + 'isInteractiveClickTarget', 'updateButtonStates' ]); } @@ -263,7 +264,7 @@ class UIControllerModule extends BaseModule { }); this.addEventListener(document, 'click', (event) => { - if (event.target && event.target.closest && event.target.closest('#options-modal, #controls, #player_input, #command_input, #story_scrollbar')) { + if (this.isInteractiveClickTarget(event.target)) { return; } @@ -635,6 +636,38 @@ class UIControllerModule extends BaseModule { console.log('UIController: SentenceQueue pipeline configured'); } + + isInteractiveClickTarget(target) { + if (!target || typeof target.closest !== 'function') { + return false; + } + + return Boolean(target.closest([ + 'a', + 'button', + 'input', + 'textarea', + 'select', + 'label', + '[role="button"]', + '[role="link"]', + '[data-control]', + '#controls', + '#player_input', + '#command_input', + '#story_scrollbar', + '#story_choices', + '#options-modal', + '.modal', + '.modal-content', + '.credits-modal', + '.credits-dialog', + '.story-popup-modal', + '.story-popup-dialog', + '.choice-button', + '.volume-toggle' + ].join(','))); + } handleCommand(command) { // Route commands to appropriate handlers diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index e942fd3..a09f726 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -1485,7 +1485,7 @@ class UIDisplayHandlerModule extends BaseModule { const dropCapText = typeof sentenceQueue.getDropCapText === 'function' ? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '') : String(metadata.layoutText || item.text || '').trim().charAt(0); - metadata.dropCapWidth = sentenceQueue.measureDropCapReservation( + metadata.dropCapWidth = await sentenceQueue.measureDropCapReservation( this.container || this.paragraphContainer || document.getElementById('story'), dropCapText, this.measureStoryLineHeight() diff --git a/public/js/ui-input-handler-module.js b/public/js/ui-input-handler-module.js index 676d411..ffa700d 100644 --- a/public/js/ui-input-handler-module.js +++ b/public/js/ui-input-handler-module.js @@ -43,6 +43,7 @@ class UIInputHandlerModule extends BaseModule { 'installMouseCursors', 'startMouseCursorAnimation', 'stopMouseCursorAnimation', + 'normalizeProcessState', 'clearHistory' ]); @@ -264,12 +265,14 @@ class UIInputHandlerModule extends BaseModule { setProcessState(state, detail = {}) { const knownStates = [ 'ready', + 'paused', 'command-waiting', 'waiting-generating', 'playing-generating', 'playing-ready' ]; - const nextState = knownStates.includes(state) ? state : 'ready'; + const requestedState = knownStates.includes(state) ? state : 'ready'; + const nextState = this.normalizeProcessState(requestedState); this.applyMouseCursor(nextState); @@ -302,6 +305,21 @@ class UIInputHandlerModule extends BaseModule { } } + normalizeProcessState(state) { + const playbackCoordinator = this.getModule('playback-coordinator'); + const isPlaying = Boolean(playbackCoordinator?.isPlaying); + + if (isPlaying && state === 'ready') { + return 'playing-ready'; + } + + if (isPlaying && state === 'waiting-generating') { + return 'playing-generating'; + } + + return state; + } + applyTextInputAttributes(playerInput) { if (!playerInput) return; @@ -358,13 +376,15 @@ class UIInputHandlerModule extends BaseModule { } getMouseCursor(state) { - if (state === 'ready') { + if (state === 'ready' || state === 'paused') { return ''; } - const fallback = state === 'command-waiting' || state === 'waiting-generating' ? 'progress' : 'default'; - const usesArrowBase = state === 'command-waiting' || state === 'waiting-generating'; - return this.buildMouseCursor(state, fallback, usesArrowBase ? 4 : 5, usesArrowBase ? 3 : 24, this.cursorAnimationFrame); + if (state === 'command-waiting' || state === 'waiting-generating') { + return this.buildMouseCursor(state, 'progress', 16, 16, this.cursorAnimationFrame); + } + + return this.buildMouseCursor(state, 'default', 5, 24, this.cursorAnimationFrame); } buildMouseCursor(state, fallback = 'default', hotspotX = 12, hotspotY = 12, frame = 0) { @@ -401,27 +421,30 @@ class UIInputHandlerModule extends BaseModule { getMouseCursorSvg(state, frame = 0) { const stroke = '#2a1b10'; const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="${stroke}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"`; - const spinnerSpokes = Array.from({ length: 8 }, (_, index) => { + const makeSpinner = (centerX, centerY, innerRadius, outerRadius, strokeWidth = 1.8) => Array.from({ length: 8 }, (_, index) => { const opacity = 0.25 + (((index + frame) % 8) / 7) * 0.75; const angle = (index * 45) * Math.PI / 180; - const x1 = 24 + Math.cos(angle) * 3; - const y1 = 24 + Math.sin(angle) * 3; - const x2 = 24 + Math.cos(angle) * 5; - const y2 = 24 + Math.sin(angle) * 5; - return ``; + const x1 = centerX + Math.cos(angle) * innerRadius; + const y1 = centerY + Math.sin(angle) * innerRadius; + const x2 = centerX + Math.cos(angle) * outerRadius; + const y2 = centerY + Math.sin(angle) * outerRadius; + return ``; }).join(''); + const spinnerSpokes = makeSpinner(24, 24, 3, 5); + const largeSpinnerSpokes = makeSpinner(16, 16, 5.2, 10.2, 2.2); const arrow = ''; - const pointer = ''; + const pointer = ''; const feather = ''; const speaker = ''; const spinner = `${spinnerSpokes}`; + const largeSpinner = `${largeSpinnerSpokes}`; const hourglassSand = frame % 4 < 2 ? '' : ''; const hourglass = `${hourglassSand}`; const icons = { 'default': `${arrow}`, 'pointer': `${pointer}`, - 'command-waiting': `${arrow}${hourglass}`, - 'waiting-generating': `${arrow}${spinner}`, + 'command-waiting': `${largeSpinner}${hourglass}`, + 'waiting-generating': `${largeSpinner}`, 'playing-generating': `${feather}${speaker}${spinner}`, 'playing-ready': `${feather}${speaker}` }; diff --git a/public/locales/de_DE.json b/public/locales/de_DE.json index d5f7a9a..ea6fe7f 100644 --- a/public/locales/de_DE.json +++ b/public/locales/de_DE.json @@ -63,5 +63,6 @@ "popup.defaultEnding": "Du hast ein Ende erreicht.", "popup.defaultError": "Das Spiel wurde wegen eines nicht behebbaren Fehlers beendet.", "popup.defaultAchievement": "Errungenschaft freigeschaltet.", - "popup.defaultAlert": "Hinweis" + "popup.defaultAlert": "Hinweis", + "popup.commandTimeout": "Der Spielserver hat nicht rechtzeitig geantwortet. Du kannst es noch einmal versuchen." } diff --git a/public/locales/en_US.json b/public/locales/en_US.json index a460c21..efe8bd1 100644 --- a/public/locales/en_US.json +++ b/public/locales/en_US.json @@ -63,5 +63,6 @@ "popup.defaultEnding": "You reached an ending.", "popup.defaultError": "The game ended because of an unrecoverable error.", "popup.defaultAchievement": "Achievement unlocked.", - "popup.defaultAlert": "Hint" + "popup.defaultAlert": "Hint", + "popup.commandTimeout": "The game server did not answer in time. You can try again." }