Fix TTS module initialization and dependency issues. Update module IDs for consistency, improve circular dependency detection, and fix UI Controller event handling.
This commit is contained in:
@@ -393,6 +393,10 @@ ol.choice {
|
||||
|
||||
#story {
|
||||
overflow-x: visible;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
margin-bottom: 1.2em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* #story p span {
|
||||
@@ -442,6 +446,12 @@ ol.choice {
|
||||
-ms-animation: fadeIn ease 1s;
|
||||
}
|
||||
|
||||
/* Style for individual words that need to fade in */
|
||||
.word.fade-in {
|
||||
animation-fill-mode: forwards;
|
||||
opacity: 0; /* Start invisible */
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {opacity:0;}
|
||||
100% {opacity:1;}
|
||||
@@ -655,3 +665,59 @@ ol.choice {
|
||||
.fade-in-input {
|
||||
animation: fadeInInput 0.5s ease forwards;
|
||||
}
|
||||
|
||||
/* Text animation and typography styles */
|
||||
|
||||
/* Fade-in animation for text elements */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.5s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Hyphenation settings */
|
||||
.story-paragraph {
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
-ms-hyphens: auto;
|
||||
}
|
||||
|
||||
/* Justified text styles */
|
||||
#story p {
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
margin-bottom: 1.2em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Typography for word elements in rendered paragraphs */
|
||||
.word {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Typography for hyphen at line breaks */
|
||||
.hyphen-marker {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Highlight the latest paragraph being rendered */
|
||||
.latest-paragraph {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Completed paragraphs style */
|
||||
.completed-paragraph {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
/* Animation speed controls */
|
||||
#speed {
|
||||
width: 80px;
|
||||
vertical-align: middle;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
+272
-91
@@ -5,53 +5,54 @@
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js'; // Add this import
|
||||
|
||||
class AnimationQueueModule extends BaseModule {
|
||||
constructor() {
|
||||
super('animation-queue', 'Animation Queue');
|
||||
|
||||
// Queue of scheduled animations/functions
|
||||
this.queue = [];
|
||||
|
||||
// Animation timing properties
|
||||
this.speed = 0.05; // Base animation speed (seconds per character)
|
||||
this.delay = 0; // Current accumulated delay
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['tts'];
|
||||
this.tts = null; // TTS module reference
|
||||
this.dependencies = [];
|
||||
|
||||
// Fast-forwarding state
|
||||
this.isFastForwarding = false;
|
||||
// Queue of scheduled animations/functions
|
||||
this.timeoutQueue = [];
|
||||
|
||||
// Bind methods
|
||||
this.schedule = this.schedule.bind(this);
|
||||
this.fastForward = this.fastForward.bind(this);
|
||||
this.clearAll = this.clearAll.bind(this);
|
||||
this.setSpeed = this.setSpeed.bind(this);
|
||||
}
|
||||
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for TTS module to be available
|
||||
this.tts = moduleRegistry.getModule('tts');
|
||||
|
||||
if (!this.tts) {
|
||||
console.warn("TTS module not ready, Animation Queue will have limited functionality");
|
||||
return true; // Continue anyway
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error waiting for Animation Queue dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
// Animation timing properties - use parent's config system
|
||||
this.updateConfig({
|
||||
speed: 0.05, // Base animation speed (seconds per character)
|
||||
fastForwardEnabled: false
|
||||
});
|
||||
|
||||
this.delay = 0; // Current accumulated delay
|
||||
this.tts = null; // TTS module reference
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'schedule',
|
||||
'fastForward',
|
||||
'clearAll',
|
||||
'setSpeed',
|
||||
'beginFastForward',
|
||||
'endFastForward',
|
||||
'emitAnimationComplete',
|
||||
'cleanupStaleTasks',
|
||||
'isAnyTtsSpeaking'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Nothing special to initialize here
|
||||
this.reportProgress(20, "Initializing Animation Queue");
|
||||
|
||||
// We'll try to get the TTS module, but it's not a hard dependency
|
||||
// We'll check for it again at runtime when needed
|
||||
setTimeout(() => {
|
||||
// Try to get TTS module after a delay to allow it to initialize
|
||||
this.tts = this.getModule('tts-player');
|
||||
if (!this.tts) {
|
||||
console.log("Animation Queue: TTS Player module not found yet, will try again when needed");
|
||||
}
|
||||
}, 500);
|
||||
|
||||
this.reportProgress(100, "Animation Queue ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -61,107 +62,273 @@ class AnimationQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a function to execute after a delay
|
||||
* Schedule a function to execute after a delay, with optional TTS synchronization
|
||||
* @param {Function} func - Function to execute
|
||||
* @param {number} delay - Delay in milliseconds
|
||||
* @param {...any} args - Arguments to pass to the function
|
||||
* @returns {Object} - Timeout object that can be used to cancel
|
||||
* @param {Object} options - Optional parameters including TTS text
|
||||
* @returns {number} - Timeout ID for cancellation
|
||||
*/
|
||||
schedule(func, delay, ...args) {
|
||||
schedule(func, delay, options = {}) {
|
||||
if (typeof func !== 'function') {
|
||||
console.error('Animation Queue: Not a function passed to schedule');
|
||||
return null;
|
||||
console.error('AnimationQueue: Invalid function provided to schedule');
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create timeout object with execute method
|
||||
// Adjust delay based on fast-forward or speed settings
|
||||
const actualDelay = this.config.fastForwardEnabled ? 0 : Math.max(0, delay * this.config.speed);
|
||||
|
||||
// Record the delay for tracking
|
||||
this.delay = Math.max(this.delay, delay);
|
||||
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Handle TTS if text is provided and TTS is available and enabled
|
||||
let ttsSpeaking = false;
|
||||
if (options.text && this.tts && typeof this.tts.isEnabled === 'function' && this.tts.isEnabled()) {
|
||||
// If we're fast forwarding, don't speak
|
||||
if (!this.config.fastForwardEnabled) {
|
||||
ttsSpeaking = true;
|
||||
// Request TTS to speak the text
|
||||
this.tts.speak(options.text, (result) => {
|
||||
ttsSpeaking = false;
|
||||
|
||||
// Check if this was keeping the queue busy
|
||||
if (this.timeoutQueue.length === 0) {
|
||||
this.emitAnimationComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create a timeout object
|
||||
const timeoutObject = {
|
||||
execute: () => {
|
||||
try {
|
||||
func(...args);
|
||||
} catch (error) {
|
||||
console.error('Error executing scheduled function:', error);
|
||||
}
|
||||
},
|
||||
func: func,
|
||||
delay: actualDelay,
|
||||
timeoutId: null,
|
||||
createdAt: Date.now(),
|
||||
delay: delay
|
||||
executed: false,
|
||||
startTime: Date.now(),
|
||||
ttsSpeaking: ttsSpeaking,
|
||||
|
||||
// Add an execute method that marks the timeout as executed
|
||||
execute: function() {
|
||||
if (!this.executed) {
|
||||
this.func();
|
||||
this.executed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Apply speed factor to the delay
|
||||
const adjustedDelay = delay * this.speed;
|
||||
// Add to queue
|
||||
this.timeoutQueue.push(timeoutObject);
|
||||
|
||||
// Schedule execution
|
||||
// If we're fast forwarding, execute immediately
|
||||
if (this.config.fastForwardEnabled) {
|
||||
timeoutObject.execute();
|
||||
return -1; // No timeout ID since it executed immediately
|
||||
}
|
||||
|
||||
// Schedule the timeout
|
||||
timeoutObject.timeoutId = setTimeout(() => {
|
||||
// Execute the function
|
||||
timeoutObject.execute();
|
||||
|
||||
// Remove from queue
|
||||
const index = this.queue.indexOf(timeoutObject);
|
||||
const index = this.timeoutQueue.indexOf(timeoutObject);
|
||||
if (index !== -1) {
|
||||
this.queue.splice(index, 1);
|
||||
this.timeoutQueue.splice(index, 1);
|
||||
}
|
||||
}, adjustedDelay);
|
||||
|
||||
// If queue is empty and no TTS is speaking, emit animation complete
|
||||
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
|
||||
this.emitAnimationComplete();
|
||||
}
|
||||
|
||||
}, actualDelay);
|
||||
|
||||
// Add to queue
|
||||
this.queue.push(timeoutObject);
|
||||
|
||||
// Update current total delay
|
||||
this.delay = adjustedDelay + delay;
|
||||
|
||||
return timeoutObject;
|
||||
return timeoutObject.timeoutId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast-forward all pending animations
|
||||
* Emit an animation complete event
|
||||
*/
|
||||
emitAnimationComplete() {
|
||||
// Only emit if queue is empty and no TTS is speaking
|
||||
if (this.timeoutQueue.length === 0 && !this.isAnyTtsSpeaking()) {
|
||||
// Use parent's dispatchEvent method
|
||||
this.dispatchEvent('ui:animation:complete', {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any animation tasks that might have been missed
|
||||
* (e.g. due to browser tab being inactive)
|
||||
*/
|
||||
cleanupStaleTasks() {
|
||||
const now = Date.now();
|
||||
const staleTasks = [];
|
||||
|
||||
// Find stale tasks
|
||||
this.timeoutQueue.forEach(task => {
|
||||
// If task has been running for more than 10 seconds, consider it stale
|
||||
if (now - task.startTime > 10000 && !task.executed) {
|
||||
staleTasks.push(task);
|
||||
}
|
||||
});
|
||||
|
||||
// Execute and remove stale tasks
|
||||
staleTasks.forEach(task => {
|
||||
console.log('AnimationQueue: Cleaning up stale task');
|
||||
|
||||
// Clear the timeout
|
||||
if (task.timeoutId !== null) {
|
||||
clearTimeout(task.timeoutId);
|
||||
}
|
||||
|
||||
// Execute the task
|
||||
task.execute();
|
||||
|
||||
// Remove from queue
|
||||
const index = this.timeoutQueue.indexOf(task);
|
||||
if (index !== -1) {
|
||||
this.timeoutQueue.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any TTS is currently speaking
|
||||
* @returns {boolean} - True if TTS is speaking
|
||||
*/
|
||||
isAnyTtsSpeaking() {
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Check if TTS is speaking
|
||||
if (this.tts && typeof this.tts.isSpeaking === 'function') {
|
||||
return this.tts.isSpeaking();
|
||||
}
|
||||
|
||||
// Default to false if TTS module is not available
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast forward all pending animations and stop TTS
|
||||
*/
|
||||
fastForward() {
|
||||
console.log(`Animation Queue: Fast-forwarding ${this.queue.length} pending items`);
|
||||
if (this.timeoutQueue.length === 0) {
|
||||
console.log('AnimationQueue: No animations to fast forward');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop TTS if playing
|
||||
if (this.tts) {
|
||||
console.log(`AnimationQueue: Fast forwarding ${this.timeoutQueue.length} pending items`);
|
||||
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Stop any active TTS
|
||||
if (this.tts && typeof this.tts.stop === 'function') {
|
||||
this.tts.stop();
|
||||
}
|
||||
|
||||
// Execute and clear all timeouts
|
||||
const queueCopy = [...this.queue]; // Make a copy to avoid modification during iteration
|
||||
|
||||
queueCopy.forEach(timeoutObject => {
|
||||
// Clear timeout
|
||||
if (timeoutObject.timeoutId !== null) {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
// Execute all pending animations immediately
|
||||
this.timeoutQueue.forEach(timeout => {
|
||||
// Clear the timeout
|
||||
if (timeout.timeoutId !== null) {
|
||||
clearTimeout(timeout.timeoutId);
|
||||
timeout.timeoutId = null;
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
timeoutObject.execute();
|
||||
// Clear TTS flag
|
||||
timeout.ttsSpeaking = false;
|
||||
|
||||
// Execute the function immediately
|
||||
timeout.execute();
|
||||
});
|
||||
|
||||
// Clear queue
|
||||
this.queue = [];
|
||||
// Clear the queue
|
||||
this.timeoutQueue = [];
|
||||
|
||||
// Reset delay
|
||||
this.delay = 0;
|
||||
|
||||
// Use direct DOM event dispatch instead of this.dispatchEvent
|
||||
document.dispatchEvent(new CustomEvent('animations:fastForwarded', {
|
||||
detail: { moduleId: this.id }
|
||||
}));
|
||||
// Update config using parent's updateConfig method
|
||||
this.updateConfig({ fastForwardEnabled: false });
|
||||
|
||||
// Emit animation complete event
|
||||
this.emitAnimationComplete();
|
||||
|
||||
// Log the fastforward completion
|
||||
console.log('AnimationQueue: Fast forward complete');
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
this.dispatchEvent('ui:animation:fastforward', { state: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin fast forwarding mode
|
||||
*/
|
||||
beginFastForward() {
|
||||
if (this.config.fastForwardEnabled) return;
|
||||
|
||||
// Update config using parent's updateConfig method
|
||||
this.updateConfig({ fastForwardEnabled: true });
|
||||
|
||||
// If we don't have a reference to the TTS module yet, try to get it
|
||||
if (!this.tts) {
|
||||
this.tts = this.getModule('tts-player');
|
||||
}
|
||||
|
||||
// Stop any active TTS
|
||||
if (this.tts && typeof this.tts.stop === 'function') {
|
||||
this.tts.stop();
|
||||
}
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
this.dispatchEvent('ui:animation:fastforward', { state: true });
|
||||
|
||||
console.log('AnimationQueue: Fast forward mode activated');
|
||||
}
|
||||
|
||||
/**
|
||||
* End fast forwarding mode
|
||||
*/
|
||||
endFastForward() {
|
||||
if (!this.config.fastForwardEnabled) return;
|
||||
|
||||
// Update config using parent's updateConfig method
|
||||
this.updateConfig({ fastForwardEnabled: false });
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
this.dispatchEvent('ui:animation:fastforward', { state: false });
|
||||
|
||||
console.log('AnimationQueue: Fast forward mode deactivated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all scheduled animations without executing them
|
||||
*/
|
||||
clearAll() {
|
||||
console.log(`Animation Queue: Clearing ${this.queue.length} pending items`);
|
||||
console.log(`Animation Queue: Clearing ${this.timeoutQueue.length} pending items`);
|
||||
|
||||
// Clear all timeouts
|
||||
this.queue.forEach(timeoutObject => {
|
||||
this.timeoutQueue.forEach(timeoutObject => {
|
||||
if (timeoutObject.timeoutId !== null) {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear queue
|
||||
this.queue = [];
|
||||
this.timeoutQueue = [];
|
||||
|
||||
// Reset delay
|
||||
this.delay = 0;
|
||||
@@ -177,16 +344,33 @@ class AnimationQueueModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
this.speed = speed;
|
||||
// Update config using parent's updateConfig method
|
||||
this.updateConfig({ speed });
|
||||
console.log(`Animation Queue: Speed set to ${speed}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current animation speed
|
||||
* @returns {number} - Current animation speed factor
|
||||
*/
|
||||
getSpeed() {
|
||||
return this.config.speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fast forwarding is active
|
||||
* @returns {boolean} - Whether fast forwarding is active
|
||||
*/
|
||||
isFastForwarding() {
|
||||
return this.config.fastForwardEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue length
|
||||
* @returns {number} - Number of items in the queue
|
||||
*/
|
||||
getQueueLength() {
|
||||
return this.queue.length;
|
||||
return this.timeoutQueue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,6 +390,3 @@ moduleRegistry.register(AnimationQueue);
|
||||
|
||||
// Export the module
|
||||
export { AnimationQueue };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.AnimationQueue = AnimationQueue;
|
||||
|
||||
+689
-262
@@ -1,280 +1,707 @@
|
||||
/**
|
||||
* ApiTTSHandler for AI Interactive Fiction
|
||||
* Implementation using external TTS APIs like ElevenLabs
|
||||
* API TTS Handler
|
||||
* Provides TTS via external APIs (e.g., ElevenLabs)
|
||||
*/
|
||||
import { TTSHandler } from './tts-handler.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
export class ApiTTSHandler extends TTSHandler {
|
||||
constructor() {
|
||||
super(); // Initialize the base TTSHandler
|
||||
this.isReady = false;
|
||||
this.enabled = false; // Disabled by default until options panel is implemented
|
||||
this.audioElement = null;
|
||||
// Set voice options through base class
|
||||
this.voiceOptions = {
|
||||
voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID
|
||||
model: 'eleven_multilingual_v1',
|
||||
stability: 0,
|
||||
similarityBoost: 0,
|
||||
style: 0.5,
|
||||
useSpeakerBoost: true
|
||||
};
|
||||
this.apiKey = 'd191e27c2e5b07573b39fe70f0783f48'; // From speech.js
|
||||
this.apiUrl = 'https://api.elevenlabs.io/v1/text-to-speech';
|
||||
this.voicesApiUrl = 'https://api.elevenlabs.io/v1/voices'; // Separate URL for voices endpoint
|
||||
this.cache = new Map();
|
||||
this.currentCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of this provider
|
||||
* @returns {string} - Provider ID
|
||||
*/
|
||||
getId() {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API TTS system
|
||||
* @param {Function} progressCallback - Optional callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
if (progressCallback) progressCallback(20, 'Setting up API TTS');
|
||||
|
||||
// Create audio element for playback
|
||||
this.audioElement = new Audio();
|
||||
|
||||
// Set up audio event listeners
|
||||
this.audioElement.onended = () => {
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
this.audioElement.onerror = (error) => {
|
||||
console.error('Audio playback error:', error);
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
if (progressCallback) progressCallback(80, 'API TTS ready');
|
||||
|
||||
// Only check API if enabled
|
||||
if (this.enabled) {
|
||||
// Check if the API is reachable with a simple request
|
||||
constructor() {
|
||||
super();
|
||||
this.id = 'api';
|
||||
this.name = 'API TTS Handler';
|
||||
|
||||
// Voice options
|
||||
this.voiceOptions = {
|
||||
voice: 'pNInz6obpgDQGcFmaJgB', // Default German voice ID for ElevenLabs
|
||||
model: 'eleven_multilingual_v2', // Use the multilingual model for better German
|
||||
speed: 1.0
|
||||
};
|
||||
|
||||
// State
|
||||
this.available = false;
|
||||
this.isReady = false;
|
||||
this.currentAudio = null;
|
||||
this.preloadCache = new Map();
|
||||
|
||||
// API endpoint
|
||||
this.apiEndpoint = '/api/tts';
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['localization', 'persistence-manager'];
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'speak',
|
||||
'speakPreloaded',
|
||||
'preloadSpeech',
|
||||
'stop',
|
||||
'isAvailable',
|
||||
'getId',
|
||||
'getVoices',
|
||||
'setVoiceOptions',
|
||||
'getModule',
|
||||
'setupVoiceFromPreferences',
|
||||
'selectVoiceForLocale',
|
||||
'selectDefaultVoice'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module from the registry
|
||||
* @param {string} moduleId - ID of the module to get
|
||||
* @returns {Object|null} - The module or null if not found
|
||||
*/
|
||||
getModule(moduleId) {
|
||||
return moduleRegistry.getModule(moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API TTS handler
|
||||
* @param {Function} progressCallback - Callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
const testResponse = await fetch(this.voicesApiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': this.apiKey
|
||||
if (progressCallback) {
|
||||
progressCallback(10, "Initializing API TTS Handler");
|
||||
}
|
||||
});
|
||||
|
||||
if (testResponse.ok) {
|
||||
|
||||
// Check for required dependencies
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
|
||||
if (!localization) {
|
||||
console.error("API TTS: Localization module not found, required dependency missing");
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "API TTS initialization failed - missing localization");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!persistenceManager) {
|
||||
console.error("API TTS: Persistence Manager module not found, required dependency missing");
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "API TTS initialization failed - missing persistence manager");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create audio element
|
||||
this.audioElement = new Audio();
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(30, "Loading voices");
|
||||
}
|
||||
|
||||
// Load available voices
|
||||
try {
|
||||
await this.loadVoices();
|
||||
} catch (error) {
|
||||
console.warn("API TTS: Failed to load voices, continuing with initialization", error);
|
||||
// Continue initialization even if voice loading fails
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(50, "Setting up voice preferences");
|
||||
}
|
||||
|
||||
// Set up voice based on preferences and locale
|
||||
try {
|
||||
const voiceSetupSuccess = await this.setupVoiceFromPreferences();
|
||||
if (!voiceSetupSuccess) {
|
||||
console.warn("API TTS: Could not set up voice from preferences, using default");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("API TTS: Error setting up voice preferences", error);
|
||||
// Continue initialization even if voice setup fails
|
||||
}
|
||||
|
||||
// Check if API is available by making a test request
|
||||
try {
|
||||
if (progressCallback) {
|
||||
progressCallback(70, "Checking API availability");
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiEndpoint}/voices`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`API TTS: API endpoint not available (${response.status} ${response.statusText}). Will use fallback.`);
|
||||
this.available = false;
|
||||
this.isReady = true; // Still mark as ready, just not available
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "API TTS unavailable, using fallback");
|
||||
}
|
||||
|
||||
// Return true to indicate the module initialized successfully
|
||||
// even though the API is not available
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(90, "API TTS available");
|
||||
}
|
||||
|
||||
// Check for German voices and set default if available
|
||||
if (data && data.voices && Array.isArray(data.voices)) {
|
||||
const germanVoices = data.voices.filter(voice =>
|
||||
voice.name.toLowerCase().includes('german') ||
|
||||
voice.language === 'de' ||
|
||||
voice.language === 'de-DE'
|
||||
);
|
||||
|
||||
if (germanVoices.length > 0) {
|
||||
// Use the first German voice as default
|
||||
this.voiceOptions.voice = germanVoices[0].id;
|
||||
console.log(`API TTS: Found German voice: ${germanVoices[0].name} (${germanVoices[0].id})`);
|
||||
}
|
||||
}
|
||||
|
||||
this.available = true;
|
||||
this.isReady = true;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "API TTS Handler ready");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("API TTS: Error checking API availability:", error);
|
||||
|
||||
// Mark as ready but not available
|
||||
this.available = false;
|
||||
this.isReady = true;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "API TTS unavailable due to error");
|
||||
}
|
||||
|
||||
// Return true to indicate the module initialized successfully
|
||||
// even though the API is not available
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing API TTS Handler:", error);
|
||||
|
||||
// Mark as ready but not available
|
||||
this.available = false;
|
||||
this.isReady = true;
|
||||
console.log('API TTS initialized successfully');
|
||||
} else {
|
||||
console.warn('API TTS initialized but API may not be accessible');
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.warn('Could not verify API access, but continuing:', apiError);
|
||||
// We'll still mark as ready and try when speak is called
|
||||
this.isReady = true;
|
||||
}
|
||||
} else {
|
||||
console.log('API TTS is disabled by default. Enable via options panel when implemented.');
|
||||
}
|
||||
|
||||
if (progressCallback) progressCallback(100, 'API TTS initialization complete');
|
||||
|
||||
return this.isReady;
|
||||
} catch (error) {
|
||||
console.error('Error initializing API TTS:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API TTS is available
|
||||
* @returns {boolean} - True if API TTS is ready to use
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.isReady && this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an MD5 hash for text caching
|
||||
* @param {string} text - Text to hash
|
||||
* @returns {string} - MD5 hash
|
||||
*/
|
||||
generateHash(text) {
|
||||
// Simple hash function for client-side use
|
||||
// For production, consider using a proper hashing library
|
||||
let hash = 0;
|
||||
if (text.length === 0) return hash.toString();
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert text to speech via API and play it
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
*/
|
||||
async speak(text, callback = null) {
|
||||
if (!this.isAvailable() || !text) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
// Set new callback
|
||||
this.currentCallback = callback;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cacheKey = this.generateHash(text + JSON.stringify(this.voiceOptions));
|
||||
let audioUrl = this.cache.get(cacheKey);
|
||||
|
||||
if (!audioUrl) {
|
||||
// Make API request to get audio
|
||||
const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': this.apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
model_id: this.voiceOptions.model,
|
||||
voice_settings: {
|
||||
stability: this.voiceOptions.stability,
|
||||
similarity_boost: this.voiceOptions.similarityBoost,
|
||||
style: this.voiceOptions.style,
|
||||
use_speaker_boost: this.voiceOptions.useSpeakerBoost
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "API TTS initialization failed");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
||||
|
||||
// Return true to indicate the module initialized successfully
|
||||
// even though there was an error
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the audio data as blob
|
||||
const audioBlob = await response.blob();
|
||||
audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Store in cache
|
||||
this.cache.set(cacheKey, audioUrl);
|
||||
}
|
||||
|
||||
// Play the audio
|
||||
this.audioElement.src = audioUrl;
|
||||
await this.audioElement.play();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error speaking with API TTS:', error);
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any ongoing speech
|
||||
*/
|
||||
stop() {
|
||||
if (this.audioElement) {
|
||||
this.audioElement.pause();
|
||||
this.audioElement.currentTime = 0;
|
||||
}
|
||||
|
||||
if (this.currentCallback) {
|
||||
const callback = this.currentCallback;
|
||||
this.currentCallback = null;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
|
||||
if (options.model !== undefined) this.voiceOptions.model = options.model;
|
||||
if (options.stability !== undefined) this.voiceOptions.stability = options.stability;
|
||||
if (options.similarityBoost !== undefined) this.voiceOptions.similarityBoost = options.similarityBoost;
|
||||
if (options.style !== undefined) this.voiceOptions.style = options.style;
|
||||
if (options.useSpeakerBoost !== undefined) this.voiceOptions.useSpeakerBoost = options.useSpeakerBoost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices from the API
|
||||
* @returns {Promise<Array>} - Array of available voices
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.enabled) {
|
||||
return [];
|
||||
/**
|
||||
* Set up voice based on preferences and locale
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async setupVoiceFromPreferences() {
|
||||
try {
|
||||
// Get localization and persistence manager modules
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
|
||||
// Both modules should be available as we checked in initialize
|
||||
if (!localization || !persistenceManager) {
|
||||
console.error("API TTS: Required modules not available for voice setup");
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
// Get current locale and preferred voice
|
||||
const currentLocale = localization.getLocale();
|
||||
const preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
|
||||
|
||||
// If we have a preferred voice, use it
|
||||
if (preferredVoice) {
|
||||
this.voiceOptions.voice = preferredVoice;
|
||||
console.log(`API TTS: Using preferred voice: ${preferredVoice}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise select based on locale
|
||||
console.log(`API TTS: No preferred voice, selecting for locale: ${currentLocale}`);
|
||||
return this.selectVoiceForLocale(currentLocale);
|
||||
} catch (error) {
|
||||
console.error("API TTS: Error setting up voice from preferences:", error);
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.voicesApiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': this.apiKey
|
||||
/**
|
||||
* Load available voices from API
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadVoices() {
|
||||
try {
|
||||
// Fetch available voices from API
|
||||
const response = await fetch(`${this.apiEndpoint}/voices`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`API TTS: Failed to load voices - ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.voices || !Array.isArray(data.voices)) {
|
||||
console.warn("API TTS: Invalid voice data received");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.voices = data.voices;
|
||||
console.log(`API TTS: Loaded ${this.voices.length} voices`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading API TTS voices:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.voices || [];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting voices from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the API TTS
|
||||
* @param {boolean} enabled - Whether the API TTS should be enabled
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled;
|
||||
if (enabled && !this.isReady) {
|
||||
// Re-initialize if enabled
|
||||
this.initialize();
|
||||
|
||||
/**
|
||||
* Select a voice for the given locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectVoiceForLocale(locale) {
|
||||
if (!locale || this.voices.length === 0) {
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
// Normalize locale
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
|
||||
// Try to find a voice for the exact locale
|
||||
let matchingVoice = this.voices.find(voice =>
|
||||
voice.lang && voice.lang.toLowerCase() === normalizedLocale
|
||||
);
|
||||
|
||||
// If no exact match, try to find a voice for the language part
|
||||
if (!matchingVoice) {
|
||||
const langPart = normalizedLocale.split('-')[0];
|
||||
matchingVoice = this.voices.find(voice =>
|
||||
voice.lang && voice.lang.toLowerCase().startsWith(langPart)
|
||||
);
|
||||
}
|
||||
|
||||
// If still no match, use default
|
||||
if (!matchingVoice) {
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
// Set the matching voice
|
||||
this.voiceOptions.voice = matchingVoice.id;
|
||||
console.log(`API TTS: Selected voice ${matchingVoice.name} for locale ${locale}`);
|
||||
|
||||
// Update preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'voice', matchingVoice.id || matchingVoice.name);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a default voice
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
if (this.voices.length === 0) {
|
||||
console.warn("API TTS: No voices available for default selection");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefer English voices if available
|
||||
const englishVoice = this.voices.find(voice =>
|
||||
voice.lang && voice.lang.toLowerCase().startsWith('en')
|
||||
);
|
||||
|
||||
if (englishVoice) {
|
||||
this.voiceOptions.voice = englishVoice.id;
|
||||
console.log(`API TTS: Selected default English voice ${englishVoice.name}`);
|
||||
} else {
|
||||
// Otherwise use the first available voice
|
||||
this.voiceOptions.voice = this.voices[0].id;
|
||||
console.log(`API TTS: Selected first available voice ${this.voices[0].name}`);
|
||||
}
|
||||
|
||||
// Update preference
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'voice', this.voiceOptions.voice);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for a text
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Preloaded audio data
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
if (!this.available || !text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
console.log(`API TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Make API request to generate speech
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: processedText,
|
||||
voice_id: this.voiceOptions.voice,
|
||||
model_id: this.voiceOptions.model,
|
||||
speed: this.voiceOptions.speed
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get audio blob
|
||||
const audioBlob = await response.blob();
|
||||
|
||||
// Create audio element but don't play it
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
// Store preloaded data
|
||||
const preloadData = {
|
||||
audio,
|
||||
url: audioUrl,
|
||||
text: processedText
|
||||
};
|
||||
|
||||
this.preloadCache.set(text, preloadData);
|
||||
return preloadData;
|
||||
} catch (error) {
|
||||
console.warn("API TTS: Error preloading speech:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using preloaded audio
|
||||
* @param {Object} preloadData - Preloaded audio data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (!this.available || !preloadData || !preloadData.audio) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
const { audio, url, text } = preloadData;
|
||||
|
||||
// Dispatch start event
|
||||
this.dispatchEvent('tts:speak:start', { text });
|
||||
|
||||
// Set up event listeners
|
||||
audio.addEventListener('ended', () => {
|
||||
this.currentAudio = null;
|
||||
|
||||
// Clean up URL object
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Dispatch end event
|
||||
this.dispatchEvent('tts:speak:end', { text });
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
audio.addEventListener('error', (error) => {
|
||||
this.currentAudio = null;
|
||||
|
||||
// Clean up URL object
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
// Store reference to current audio
|
||||
this.currentAudio = audio;
|
||||
|
||||
// Play the audio
|
||||
audio.play();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("API TTS: Error playing preloaded speech:", error);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text: preloadData.text,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
async speak(text, callback = null) {
|
||||
if (!this.available) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
// Check if we have this in the preload cache
|
||||
if (this.preloadCache.has(text)) {
|
||||
const preloadData = this.preloadCache.get(text);
|
||||
this.preloadCache.delete(text); // Remove from cache
|
||||
return this.speakPreloaded(preloadData, callback);
|
||||
}
|
||||
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Dispatch start event
|
||||
this.dispatchEvent('tts:speak:start', { text: processedText });
|
||||
|
||||
// Make API request to generate speech
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: processedText,
|
||||
voice_id: this.voiceOptions.voice,
|
||||
model_id: this.voiceOptions.model,
|
||||
speed: this.voiceOptions.speed
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get audio blob
|
||||
const audioBlob = await response.blob();
|
||||
|
||||
// Create audio element
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
// Set up event listeners
|
||||
audio.addEventListener('ended', () => {
|
||||
this.currentAudio = null;
|
||||
|
||||
// Clean up URL object
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
// Dispatch end event
|
||||
this.dispatchEvent('tts:speak:end', { text: processedText });
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
audio.addEventListener('error', (error) => {
|
||||
this.currentAudio = null;
|
||||
|
||||
// Clean up URL object
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text: processedText,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
// Store reference to current audio
|
||||
this.currentAudio = audio;
|
||||
|
||||
// Play the audio
|
||||
audio.play();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("API TTS: Error generating speech:", error);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'generation_error', error }), 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
preprocessText(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Trim whitespace
|
||||
let processed = text.trim();
|
||||
|
||||
// Replace multiple spaces with a single space
|
||||
processed = processed.replace(/\s+/g, ' ');
|
||||
|
||||
// Add a period at the end if there's no punctuation
|
||||
if (!/[.!?]$/.test(processed)) {
|
||||
processed += '.';
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop speaking
|
||||
*/
|
||||
stop() {
|
||||
if (this.currentAudio) {
|
||||
try {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio = null;
|
||||
} catch (error) {
|
||||
console.error("API TTS: Error stopping speech:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is available
|
||||
* @returns {boolean} - True if TTS is available
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler ID
|
||||
* @returns {string} - Handler ID
|
||||
*/
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Resolves with array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.available) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiEndpoint}/voices`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.voices && Array.isArray(data.voices)) {
|
||||
return data.voices.map(voice => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
language: voice.language || 'unknown'
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("API TTS: Error getting voices:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice) {
|
||||
this.voiceOptions.voice = options.voice;
|
||||
}
|
||||
|
||||
if (options.model) {
|
||||
this.voiceOptions.model = options.model;
|
||||
}
|
||||
|
||||
if (typeof options.speed === 'number') {
|
||||
// Clamp speed between 0.5 and 2.0
|
||||
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if speech is currently playing
|
||||
* @returns {boolean} - True if speaking
|
||||
*/
|
||||
isSpeaking() {
|
||||
return this.audioElement !== null &&
|
||||
!this.audioElement.paused &&
|
||||
!this.audioElement.ended;
|
||||
}
|
||||
}
|
||||
|
||||
+456
-13
@@ -9,6 +9,24 @@ export class BaseModule {
|
||||
this.state = 'PENDING';
|
||||
this.progress = 0;
|
||||
this.progressCallback = null;
|
||||
|
||||
// Add standard event target for custom events
|
||||
this.eventTarget = document.createElement('div');
|
||||
|
||||
// Add standard configuration object
|
||||
this.config = {};
|
||||
|
||||
// Track event listeners for cleanup
|
||||
this._eventListeners = [];
|
||||
|
||||
// Resource loading tracking
|
||||
this._loadingResources = new Map();
|
||||
this._totalResources = 0;
|
||||
this._loadedResources = 0;
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = [];
|
||||
this._loadedDependencies = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,15 +41,10 @@ export class BaseModule {
|
||||
this.changeState('LOADING');
|
||||
this.reportProgress(10, "Starting initialization");
|
||||
|
||||
// Load dependencies
|
||||
const depsLoaded = await this.loadDependencies();
|
||||
if (!depsLoaded) {
|
||||
this.changeState('ERROR');
|
||||
this.reportProgress(100, "Failed to load dependencies");
|
||||
return false;
|
||||
}
|
||||
// Skip loadDependencies() call - now handled automatically
|
||||
|
||||
const depStatus = await this.waitForDependencies();
|
||||
// Wait for dependencies
|
||||
const depStatus = await this._waitForModuleDependencies();
|
||||
if (!depStatus) {
|
||||
// If dependencies aren't available, report waiting
|
||||
this.changeState('WAITING');
|
||||
@@ -59,24 +72,100 @@ export class BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies - Override this in child classes
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
* Wait for module dependencies
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||
*/
|
||||
async _waitForModuleDependencies() {
|
||||
if (!this.dependencies || this.dependencies.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.reportProgress(15, "Waiting for dependencies");
|
||||
|
||||
// Get moduleRegistry - first try import then fallback to window
|
||||
const registry = window.moduleRegistry;
|
||||
if (!registry) {
|
||||
console.error(`${this.id}: Module registry not found, will retry`);
|
||||
|
||||
// Retry after a short delay to allow registry to be initialized
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Try again
|
||||
const retryRegistry = window.moduleRegistry;
|
||||
if (!retryRegistry) {
|
||||
console.error(`${this.id}: Module registry still not found after retry`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`${this.id}: Found module registry after retry`);
|
||||
return this._continueWaitForDependencies(retryRegistry);
|
||||
}
|
||||
|
||||
return this._continueWaitForDependencies(registry);
|
||||
} catch (error) {
|
||||
console.error(`${this.id}: Error waiting for dependencies:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue waiting for dependencies using the provided registry
|
||||
* @param {ModuleRegistry} registry - The module registry
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||
* @private
|
||||
*/
|
||||
async _continueWaitForDependencies(registry) {
|
||||
try {
|
||||
// Wait for all dependencies to be ready
|
||||
const results = await registry.waitForModules(this.dependencies);
|
||||
|
||||
// Store references to dependencies
|
||||
for (let i = 0; i < this.dependencies.length; i++) {
|
||||
const depId = this.dependencies[i];
|
||||
const depModule = registry.getModule(depId);
|
||||
if (depModule) {
|
||||
this._loadedDependencies.set(depId, depModule);
|
||||
}
|
||||
}
|
||||
|
||||
const allDepsReady = results.every(ready => ready === true);
|
||||
if (allDepsReady) {
|
||||
this.reportProgress(20, "Dependencies ready");
|
||||
return true;
|
||||
} else {
|
||||
this.reportProgress(15, "Some dependencies not ready");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${this.id}: Error in _continueWaitForDependencies:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backwards compatibility
|
||||
* @deprecated Use dependencies array property instead
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
// This is now handled by _waitForModuleDependencies
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready - Override this in child classes
|
||||
* @returns {Promise} - Resolves when dependencies are ready
|
||||
* Legacy method for backwards compatibility
|
||||
* @deprecated No longer needed as waitForDependencies is handled automatically
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
// This is now handled by _waitForModuleDependencies
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module - Override this in child classes
|
||||
* @returns {Promise} - Resolves when initialization is complete
|
||||
* @returns {Promise<boolean>} - Resolves when initialization is complete
|
||||
*/
|
||||
async initialize() {
|
||||
return Promise.resolve(true);
|
||||
@@ -116,6 +205,360 @@ export class BaseModule {
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a module event
|
||||
* @param {string} name - Event name
|
||||
* @param {Object} detail - Event details
|
||||
*/
|
||||
dispatchEvent(name, detail = {}) {
|
||||
const event = new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event listener with automatic tracking for cleanup
|
||||
* @param {EventTarget} target - Event target (document, window, etc)
|
||||
* @param {string} type - Event type
|
||||
* @param {Function} listener - Event listener
|
||||
* @param {Object} options - Event listener options
|
||||
*/
|
||||
addEventListener(target, type, listener, options = {}) {
|
||||
target.addEventListener(type, listener, options);
|
||||
this._eventListeners.push({ target, type, listener, options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific event listener
|
||||
* @param {EventTarget} target - Event target
|
||||
* @param {string} type - Event type
|
||||
* @param {Function} listener - Event listener
|
||||
* @param {Object} options - Event listener options
|
||||
*/
|
||||
removeEventListener(target, type, listener, options = {}) {
|
||||
target.removeEventListener(type, listener, options);
|
||||
this._eventListeners = this._eventListeners.filter(
|
||||
item => !(item.target === target &&
|
||||
item.type === type &&
|
||||
item.listener === listener)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all event listeners registered through addEventListener
|
||||
*/
|
||||
removeAllEventListeners() {
|
||||
this._eventListeners.forEach(({ target, type, listener, options }) => {
|
||||
target.removeEventListener(type, listener, options);
|
||||
});
|
||||
this._eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference to another module
|
||||
* @param {string} moduleId - ID of the module to get
|
||||
* @returns {BaseModule|null} - The module or null if not found
|
||||
*/
|
||||
getModule(moduleId) {
|
||||
// First check our dependency cache
|
||||
if (this._loadedDependencies.has(moduleId)) {
|
||||
return this._loadedDependencies.get(moduleId);
|
||||
}
|
||||
|
||||
// Then check in the registry
|
||||
return window.moduleRegistry ?
|
||||
window.moduleRegistry.getModule(moduleId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-bind methods to preserve 'this' context
|
||||
* @param {Array<string>} methodNames - Array of method names to bind
|
||||
*/
|
||||
bindMethods(methodNames) {
|
||||
methodNames.forEach(methodName => {
|
||||
if (typeof this[methodName] === 'function') {
|
||||
this[methodName] = this[methodName].bind(this);
|
||||
} else {
|
||||
console.warn(`Method ${methodName} not found on ${this.id} module`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
* @param {Object} newConfig - New configuration to merge
|
||||
*/
|
||||
updateConfig(newConfig = {}) {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
* @returns {Object} - Current configuration
|
||||
*/
|
||||
getConfig() {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a JavaScript file
|
||||
* @param {string} url - URL of the script to load
|
||||
* @param {boolean} [isModule=false] - Whether to load as a module
|
||||
* @returns {Promise<HTMLScriptElement>} - Promise resolving to the loaded script element
|
||||
*/
|
||||
loadScript(url, isModule = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Track this resource
|
||||
this._trackResource(url);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
if (isModule) {
|
||||
script.type = 'module';
|
||||
}
|
||||
|
||||
script.onload = () => {
|
||||
this._resourceLoaded(url);
|
||||
resolve(script);
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
this._resourceFailed(url, error);
|
||||
reject(new Error(`Failed to load script: ${url}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a CSS stylesheet
|
||||
* @param {string} url - URL of the stylesheet to load
|
||||
* @returns {Promise<HTMLLinkElement>} - Promise resolving to the loaded link element
|
||||
*/
|
||||
loadCSS(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Track this resource
|
||||
this._trackResource(url);
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.href = url;
|
||||
link.rel = 'stylesheet';
|
||||
|
||||
link.onload = () => {
|
||||
this._resourceLoaded(url);
|
||||
resolve(link);
|
||||
};
|
||||
|
||||
link.onerror = (error) => {
|
||||
this._resourceFailed(url, error);
|
||||
reject(new Error(`Failed to load stylesheet: ${url}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload an image
|
||||
* @param {string} url - URL of the image to load
|
||||
* @returns {Promise<HTMLImageElement>} - Promise resolving to the loaded image element
|
||||
*/
|
||||
loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Track this resource
|
||||
this._trackResource(url);
|
||||
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
this._resourceLoaded(url);
|
||||
resolve(img);
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
this._resourceFailed(url, error);
|
||||
reject(new Error(`Failed to load image: ${url}`));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load JSON data
|
||||
* @param {string} url - URL of the JSON file to load
|
||||
* @returns {Promise<Object>} - Promise resolving to the parsed JSON data
|
||||
*/
|
||||
loadJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Track this resource
|
||||
this._trackResource(url);
|
||||
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
this._resourceLoaded(url);
|
||||
resolve(data);
|
||||
})
|
||||
.catch(error => {
|
||||
this._resourceFailed(url, error);
|
||||
reject(new Error(`Failed to load JSON: ${url} - ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a generic resource with fetch
|
||||
* @param {string} url - URL of the resource to load
|
||||
* @param {string} [responseType='text'] - Response type ('text', 'blob', 'arrayBuffer', etc.)
|
||||
* @returns {Promise<any>} - Promise resolving to the loaded resource
|
||||
*/
|
||||
loadResource(url, responseType = 'text') {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Track this resource
|
||||
this._trackResource(url);
|
||||
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
switch(responseType) {
|
||||
case 'json': return response.json();
|
||||
case 'blob': return response.blob();
|
||||
case 'arrayBuffer': return response.arrayBuffer();
|
||||
case 'formData': return response.formData();
|
||||
case 'text':
|
||||
default: return response.text();
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
this._resourceLoaded(url);
|
||||
resolve(data);
|
||||
})
|
||||
.catch(error => {
|
||||
this._resourceFailed(url, error);
|
||||
reject(new Error(`Failed to load resource: ${url} - ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple resources at once
|
||||
* @param {Array<Object>} resources - Array of resource descriptors
|
||||
* @returns {Promise<Array>} - Promise resolving to an array of loaded resources
|
||||
* @example
|
||||
* loadResources([
|
||||
* { type: 'script', url: '/js/lib.js' },
|
||||
* { type: 'css', url: '/css/style.css' },
|
||||
* { type: 'image', url: '/img/logo.png' },
|
||||
* { type: 'json', url: '/data/config.json' }
|
||||
* ])
|
||||
*/
|
||||
loadResources(resources) {
|
||||
const promises = resources.map(resource => {
|
||||
switch(resource.type) {
|
||||
case 'script':
|
||||
return this.loadScript(resource.url, resource.isModule);
|
||||
case 'css':
|
||||
return this.loadCSS(resource.url);
|
||||
case 'image':
|
||||
return this.loadImage(resource.url);
|
||||
case 'json':
|
||||
return this.loadJSON(resource.url);
|
||||
default:
|
||||
return this.loadResource(resource.url, resource.responseType);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a resource being loaded
|
||||
* @param {string} url - URL of the resource
|
||||
* @private
|
||||
*/
|
||||
_trackResource(url) {
|
||||
this._loadingResources.set(url, {
|
||||
started: Date.now(),
|
||||
completed: false,
|
||||
failed: false
|
||||
});
|
||||
this._totalResources++;
|
||||
|
||||
// Report progress
|
||||
this._updateResourceProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a resource as successfully loaded
|
||||
* @param {string} url - URL of the resource
|
||||
* @private
|
||||
*/
|
||||
_resourceLoaded(url) {
|
||||
if (this._loadingResources.has(url)) {
|
||||
const resource = this._loadingResources.get(url);
|
||||
resource.completed = true;
|
||||
resource.completedAt = Date.now();
|
||||
this._loadedResources++;
|
||||
|
||||
// Report progress
|
||||
this._updateResourceProgress();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a resource as failed to load
|
||||
* @param {string} url - URL of the resource
|
||||
* @param {Error} error - Error that occurred
|
||||
* @private
|
||||
*/
|
||||
_resourceFailed(url, error) {
|
||||
if (this._loadingResources.has(url)) {
|
||||
const resource = this._loadingResources.get(url);
|
||||
resource.failed = true;
|
||||
resource.error = error;
|
||||
resource.completedAt = Date.now();
|
||||
this._loadedResources++;
|
||||
|
||||
// Log the error
|
||||
console.error(`${this.id}: Failed to load resource:`, url, error);
|
||||
|
||||
// Report progress
|
||||
this._updateResourceProgress();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update loading progress based on resources
|
||||
* @private
|
||||
*/
|
||||
_updateResourceProgress() {
|
||||
if (this._totalResources === 0) return;
|
||||
|
||||
const percent = Math.round((this._loadedResources / this._totalResources) * 100);
|
||||
this.reportProgress(percent, `Loading resources: ${this._loadedResources}/${this._totalResources}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose resources when module is destroyed
|
||||
* Override in child classes to add custom cleanup
|
||||
*/
|
||||
dispose() {
|
||||
this.removeAllEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+571
-180
@@ -3,196 +3,587 @@
|
||||
* Implementation using the browser's Web Speech API
|
||||
*/
|
||||
import { TTSHandler } from './tts-handler.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
export class BrowserTTSHandler extends TTSHandler {
|
||||
constructor() {
|
||||
super(); // Initialize the base TTSHandler
|
||||
this.synth = window.speechSynthesis;
|
||||
this.utterance = null;
|
||||
this.voices = [];
|
||||
this.isReady = false;
|
||||
// Initialize voice options through base class
|
||||
this.voiceOptions = {
|
||||
voice: '',
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
volume: 1.0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if speech is currently playing
|
||||
* @returns {boolean} - True if speaking
|
||||
*/
|
||||
isSpeaking() {
|
||||
return this.synth && this.synth.speaking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of this provider
|
||||
* @returns {string} - Provider ID
|
||||
*/
|
||||
getId() {
|
||||
return 'browser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the browser's speech synthesis
|
||||
* @param {Function} progressCallback - Optional callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
if (!this.synth) {
|
||||
console.warn('Web Speech API not supported in this browser');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (progressCallback) progressCallback(20, 'Loading speech synthesis');
|
||||
|
||||
// Get available voices
|
||||
this.voices = await this.getVoices();
|
||||
|
||||
if (progressCallback) progressCallback(80, 'Speech synthesis loaded');
|
||||
|
||||
// If we have voices, we're ready
|
||||
this.isReady = this.voices && this.voices.length > 0;
|
||||
|
||||
if (this.isReady) {
|
||||
console.log('Browser TTS initialized with', this.voices.length, 'voices');
|
||||
} else {
|
||||
console.warn('Browser TTS initialized but no voices available');
|
||||
}
|
||||
|
||||
if (progressCallback) progressCallback(100, 'Browser TTS ready');
|
||||
|
||||
return this.isReady;
|
||||
} catch (error) {
|
||||
console.error('Error initializing browser TTS:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Array of available voices
|
||||
*/
|
||||
async getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
// Some browsers get voices immediately, others need an event
|
||||
const voices = this.synth.getVoices();
|
||||
|
||||
if (voices && voices.length > 0) {
|
||||
resolve(voices);
|
||||
} else {
|
||||
// Wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
this.synth.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(this.synth.getVoices());
|
||||
constructor() {
|
||||
super();
|
||||
this.id = 'browser';
|
||||
this.name = 'Browser TTS Handler';
|
||||
|
||||
// Voice options
|
||||
this.voiceOptions = {
|
||||
voice: null, // Will be set during initialization
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
volume: 1.0
|
||||
};
|
||||
|
||||
this.synth.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
// State
|
||||
this.available = false;
|
||||
this.voices = [];
|
||||
this.currentUtterance = null;
|
||||
this.preloadCache = new Map();
|
||||
|
||||
// Safety mechanism: if after 3 seconds we still have no voices and no event,
|
||||
// resolve with whatever we have (or empty array)
|
||||
// This is not a setTimeout for synchronization, but a safety fallback
|
||||
const safetyCheckVoices = () => {
|
||||
const currentVoices = this.synth.getVoices() || [];
|
||||
console.log(`Safety check: Found ${currentVoices.length} voices`);
|
||||
resolve(currentVoices);
|
||||
};
|
||||
// Add dependencies
|
||||
this.dependencies = ['localization', 'persistence-manager'];
|
||||
|
||||
// Use requestIdleCallback if available, otherwise requestAnimationFrame
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(safetyCheckVoices, { timeout: 3000 });
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'speak',
|
||||
'speakPreloaded',
|
||||
'preloadSpeech',
|
||||
'stop',
|
||||
'isAvailable',
|
||||
'getId',
|
||||
'getVoices',
|
||||
'setVoiceOptions',
|
||||
'onVoicesChanged',
|
||||
'getModule'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module from the registry
|
||||
* @param {string} moduleId - ID of the module to get
|
||||
* @returns {Object|null} - The module or null if not found
|
||||
*/
|
||||
getModule(moduleId) {
|
||||
return moduleRegistry.getModule(moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the browser TTS handler
|
||||
* @param {Function} progressCallback - Callback for progress updates
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize(progressCallback = null) {
|
||||
try {
|
||||
if (progressCallback) {
|
||||
progressCallback(10, "Initializing Browser TTS Handler");
|
||||
}
|
||||
|
||||
// Check if the browser supports speech synthesis
|
||||
if (!window.speechSynthesis) {
|
||||
console.error("Browser TTS: Speech synthesis not supported by browser");
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "Browser TTS unavailable");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(30, "Loading voices");
|
||||
}
|
||||
|
||||
try {
|
||||
// Load available voices
|
||||
await this.loadVoices();
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(70, "Setting up voice");
|
||||
}
|
||||
|
||||
// Get localization module
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
|
||||
// Get current locale and preferred voice
|
||||
let currentLocale = 'en-us';
|
||||
let preferredVoice = '';
|
||||
|
||||
if (localization) {
|
||||
currentLocale = localization.getLocale();
|
||||
} else {
|
||||
console.error("Browser TTS: Localization module not found");
|
||||
}
|
||||
|
||||
if (persistenceManager) {
|
||||
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
|
||||
} else {
|
||||
console.error("Browser TTS: Persistence Manager module not found");
|
||||
}
|
||||
|
||||
// Set voice based on locale and preferences
|
||||
await this.selectVoiceForLocale(currentLocale, preferredVoice);
|
||||
|
||||
// Check if we have a voice set
|
||||
if (this.voiceOptions.voice) {
|
||||
this.available = true;
|
||||
this.isReady = true;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "Browser TTS Handler ready");
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
// Try one more time with a delay
|
||||
console.log("Browser TTS: No voice set, trying again after delay");
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(80, "Retrying voice loading");
|
||||
}
|
||||
|
||||
// Wait a bit and try again
|
||||
return new Promise(resolve => {
|
||||
setTimeout(async () => {
|
||||
await this.loadVoices();
|
||||
await this.selectVoiceForLocale(currentLocale, preferredVoice);
|
||||
|
||||
if (this.voiceOptions.voice) {
|
||||
this.available = true;
|
||||
this.isReady = true;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "Browser TTS Handler ready");
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error("Browser TTS: Failed to set voice after retry");
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "Browser TTS initialization failed");
|
||||
}
|
||||
|
||||
resolve(false);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Browser TTS: Error loading voices:", error);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "Browser TTS initialization failed");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Browser TTS: Initialization error:", error);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(100, "Browser TTS initialization failed");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voices changed event
|
||||
*/
|
||||
async onVoicesChanged() {
|
||||
await this.loadVoices();
|
||||
const localization = this.getModule('localization');
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
let currentLocale = 'en-us';
|
||||
let preferredVoice = '';
|
||||
if (localization) {
|
||||
currentLocale = localization.getLocale();
|
||||
}
|
||||
if (persistenceManager) {
|
||||
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
|
||||
}
|
||||
await this.selectVoiceForLocale(currentLocale, preferredVoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available voices
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadVoices() {
|
||||
return new Promise(resolve => {
|
||||
// Get available voices
|
||||
const getVoices = () => {
|
||||
this.voices = speechSynthesis.getVoices() || [];
|
||||
console.log(`Browser TTS: Loaded ${this.voices.length} voices`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Some browsers need a timeout to get voices
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.voices.length === 0) {
|
||||
this.voices = speechSynthesis.getVoices() || [];
|
||||
console.log(`Browser TTS: Loaded ${this.voices.length} voices after timeout`);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Try to get voices immediately
|
||||
this.voices = speechSynthesis.getVoices() || [];
|
||||
if (this.voices.length > 0) {
|
||||
clearTimeout(timeoutId);
|
||||
console.log(`Browser TTS: Loaded ${this.voices.length} voices immediately`);
|
||||
resolve();
|
||||
} else {
|
||||
// If no voices are available yet, set up the onvoiceschanged event
|
||||
speechSynthesis.onvoiceschanged = () => {
|
||||
clearTimeout(timeoutId);
|
||||
this.voices = speechSynthesis.getVoices() || [];
|
||||
console.log(`Browser TTS: Loaded ${this.voices.length} voices from event`);
|
||||
speechSynthesis.onvoiceschanged = null;
|
||||
resolve();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice based on locale
|
||||
* @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr')
|
||||
* @param {string} preferredVoice - Optional preferred voice name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') {
|
||||
// Normalize locale for comparison
|
||||
const normalizedLocale = locale.toLowerCase().split('-')[0];
|
||||
|
||||
// If we have a preferred voice, try to use it first
|
||||
if (preferredVoice) {
|
||||
const matchingVoice = this.voices.find(voice =>
|
||||
voice.name === preferredVoice ||
|
||||
voice.voiceURI === preferredVoice
|
||||
);
|
||||
|
||||
if (matchingVoice) {
|
||||
this.voiceOptions.voice = matchingVoice;
|
||||
console.log(`Browser TTS: Using preferred voice: ${matchingVoice.name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find voices matching the locale
|
||||
const localeVoices = this.voices.filter(voice => {
|
||||
const voiceLocale = voice.lang.toLowerCase();
|
||||
return voiceLocale.startsWith(normalizedLocale) ||
|
||||
voice.name.toLowerCase().includes(normalizedLocale);
|
||||
});
|
||||
|
||||
if (localeVoices.length > 0) {
|
||||
// Use the first matching voice
|
||||
this.voiceOptions.voice = localeVoices[0];
|
||||
console.log(`Browser TTS: Using ${normalizedLocale} voice: ${this.voiceOptions.voice.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no matching voice found, try to find any voice
|
||||
if (this.voices.length > 0) {
|
||||
// Look for a preferred language voice (English)
|
||||
const englishVoices = this.voices.filter(voice =>
|
||||
voice.lang.toLowerCase().startsWith('en')
|
||||
);
|
||||
|
||||
if (englishVoices.length > 0) {
|
||||
this.voiceOptions.voice = englishVoices[0];
|
||||
console.log(`Browser TTS: No ${normalizedLocale} voice found, using English voice: ${this.voiceOptions.voice.name}`);
|
||||
} else {
|
||||
// Use the first available voice
|
||||
this.voiceOptions.voice = this.voices[0];
|
||||
console.log(`Browser TTS: No ${normalizedLocale} or English voice found, using: ${this.voiceOptions.voice.name}`);
|
||||
}
|
||||
} else {
|
||||
// Schedule for next frame, but with longer delay
|
||||
setTimeout(safetyCheckVoices, 3000);
|
||||
console.log("Browser TTS: No voices available");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser TTS is available
|
||||
* @returns {boolean} - True if browser TTS is ready to use
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.isReady && this.synth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using browser TTS
|
||||
* @param {string} text - The text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
if (!this.isAvailable() || !text) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
try {
|
||||
// Create a new utterance
|
||||
this.utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
// Apply voice options
|
||||
if (this.voiceOptions.voice) {
|
||||
// Find the voice by name or URI
|
||||
const selectedVoice = this.voices.find(v =>
|
||||
v.name === this.voiceOptions.voice ||
|
||||
v.voiceURI === this.voiceOptions.voice
|
||||
);
|
||||
if (selectedVoice) {
|
||||
this.utterance.voice = selectedVoice;
|
||||
|
||||
/**
|
||||
* Preload speech for a text
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Preloaded speech data
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
if (!this.available || !text || !this.voiceOptions.voice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
console.log(`Browser TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Create utterance but don't speak it yet
|
||||
const utterance = new SpeechSynthesisUtterance(processedText);
|
||||
|
||||
// Set voice and options
|
||||
utterance.voice = this.voiceOptions.voice;
|
||||
utterance.rate = this.voiceOptions.rate;
|
||||
utterance.pitch = this.voiceOptions.pitch;
|
||||
utterance.volume = this.voiceOptions.volume;
|
||||
utterance.lang = this.voiceOptions.voice.lang;
|
||||
|
||||
// Store preloaded data
|
||||
const preloadData = {
|
||||
utterance,
|
||||
text: processedText
|
||||
};
|
||||
|
||||
this.preloadCache.set(text, preloadData);
|
||||
return preloadData;
|
||||
} catch (error) {
|
||||
console.warn("Browser TTS: Error preloading speech:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply other options
|
||||
this.utterance.rate = this.voiceOptions.rate;
|
||||
this.utterance.pitch = this.voiceOptions.pitch;
|
||||
this.utterance.volume = this.voiceOptions.volume;
|
||||
|
||||
// Handle end of speech
|
||||
this.utterance.onend = () => {
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
// Handle errors
|
||||
this.utterance.onerror = (e) => {
|
||||
console.error('Speech synthesis error:', e);
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
// Start speaking
|
||||
this.synth.speak(this.utterance);
|
||||
} catch (error) {
|
||||
console.error('Error speaking with browser TTS:', error);
|
||||
if (callback) callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any ongoing speech
|
||||
*/
|
||||
stop() {
|
||||
if (this.synth) {
|
||||
this.synth.cancel();
|
||||
this.utterance = null;
|
||||
|
||||
/**
|
||||
* Speak text using preloaded utterance
|
||||
* @param {Object} preloadData - Preloaded speech data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (!this.available || !preloadData || !preloadData.utterance) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
const { utterance, text } = preloadData;
|
||||
|
||||
// Dispatch start event
|
||||
this.dispatchEvent('tts:speak:start', { text });
|
||||
|
||||
// Set up event listeners
|
||||
utterance.onend = () => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch end event
|
||||
this.dispatchEvent('tts:speak:end', { text });
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
};
|
||||
|
||||
utterance.onerror = (error) => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text,
|
||||
error: error.error || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'synthesis_error', error });
|
||||
}
|
||||
};
|
||||
|
||||
// Store reference to current utterance
|
||||
this.currentUtterance = utterance;
|
||||
|
||||
// Speak the utterance
|
||||
speechSynthesis.speak(utterance);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Browser TTS: Error playing preloaded speech:", error);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text: preloadData.text,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
if (!this.available || !this.voiceOptions.voice) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current speech
|
||||
this.stop();
|
||||
|
||||
// Check if we have this in the preload cache
|
||||
if (this.preloadCache.has(text)) {
|
||||
const preloadData = this.preloadCache.get(text);
|
||||
this.preloadCache.delete(text); // Remove from cache
|
||||
return this.speakPreloaded(preloadData, callback);
|
||||
}
|
||||
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Create utterance
|
||||
const utterance = new SpeechSynthesisUtterance(processedText);
|
||||
|
||||
// Set voice and options
|
||||
utterance.voice = this.voiceOptions.voice;
|
||||
utterance.rate = this.voiceOptions.rate;
|
||||
utterance.pitch = this.voiceOptions.pitch;
|
||||
utterance.volume = this.voiceOptions.volume;
|
||||
utterance.lang = this.voiceOptions.voice.lang;
|
||||
|
||||
// Dispatch start event
|
||||
this.dispatchEvent('tts:speak:start', { text: processedText });
|
||||
|
||||
// Set up event listeners
|
||||
utterance.onend = () => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch end event
|
||||
this.dispatchEvent('tts:speak:end', { text: processedText });
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
};
|
||||
|
||||
utterance.onerror = (error) => {
|
||||
this.currentUtterance = null;
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text: processedText,
|
||||
error: error.error || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'synthesis_error', error });
|
||||
}
|
||||
};
|
||||
|
||||
// Store reference to current utterance
|
||||
this.currentUtterance = utterance;
|
||||
|
||||
// Speak the utterance
|
||||
speechSynthesis.speak(utterance);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Browser TTS: Error generating speech:", error);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
text,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
preprocessText(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Trim whitespace
|
||||
let processed = text.trim();
|
||||
|
||||
// Replace multiple spaces with a single space
|
||||
processed = processed.replace(/\s+/g, ' ');
|
||||
|
||||
// Add a period at the end if there's no punctuation
|
||||
if (!/[.!?]$/.test(processed)) {
|
||||
processed += '.';
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop speaking
|
||||
*/
|
||||
stop() {
|
||||
if (speechSynthesis) {
|
||||
speechSynthesis.cancel();
|
||||
this.currentUtterance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is available
|
||||
* @returns {boolean} - True if TTS is available
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.available && this.voiceOptions.voice !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler ID
|
||||
* @returns {string} - Handler ID
|
||||
*/
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Array} - Array of voice objects
|
||||
*/
|
||||
getVoices() {
|
||||
return this.voices.map(voice => ({
|
||||
id: voice.voiceURI,
|
||||
name: voice.name,
|
||||
language: voice.lang
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice) {
|
||||
// Find the voice by ID or name
|
||||
const voice = this.voices.find(v =>
|
||||
v.voiceURI === options.voice ||
|
||||
v.name === options.voice
|
||||
);
|
||||
|
||||
if (voice) {
|
||||
this.voiceOptions.voice = voice;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.rate === 'number') {
|
||||
// Clamp rate between 0.1 and 10
|
||||
this.voiceOptions.rate = Math.max(0.1, Math.min(10, options.rate));
|
||||
}
|
||||
|
||||
if (typeof options.pitch === 'number') {
|
||||
// Clamp pitch between 0 and 2
|
||||
this.voiceOptions.pitch = Math.max(0, Math.min(2, options.pitch));
|
||||
}
|
||||
|
||||
if (typeof options.volume === 'number') {
|
||||
// Clamp volume between 0 and 1
|
||||
this.voiceOptions.volume = Math.max(0, Math.min(1, options.volume));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
|
||||
if (options.rate !== undefined) this.voiceOptions.rate = options.rate;
|
||||
if (options.pitch !== undefined) this.voiceOptions.pitch = options.pitch;
|
||||
if (options.volume !== undefined) this.voiceOptions.volume = options.volume;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-79
@@ -8,11 +8,11 @@ import { moduleRegistry } from './module-registry.js';
|
||||
class GameLoopModule extends BaseModule {
|
||||
constructor() {
|
||||
super('game-loop', 'Game Loop');
|
||||
this.uiController = null;
|
||||
this.socketClient = null;
|
||||
this.ttsPlayer = null;
|
||||
this.textBuffer = null;
|
||||
this.isRunning = false;
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['ui-controller', 'socket-client', 'tts-player', 'text-buffer'];
|
||||
|
||||
// Game state
|
||||
this.gameState = {
|
||||
started: false,
|
||||
canLoad: false,
|
||||
@@ -20,78 +20,33 @@ class GameLoopModule extends BaseModule {
|
||||
inventory: [],
|
||||
commandHistory: []
|
||||
};
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'start',
|
||||
'setupSocketEventListeners',
|
||||
'updateGameState',
|
||||
'updateUIState',
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
'addText'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
// Basic dependency declaration - details handled in waitForDependencies
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for TTS module with a timeout
|
||||
const ttsReady = await moduleRegistry.waitForModule('tts', 15000);
|
||||
|
||||
if (ttsReady) {
|
||||
this.ttsPlayer = moduleRegistry.getModule('tts');
|
||||
this.reportProgress(30, "TTS module ready");
|
||||
} else {
|
||||
console.warn("TTS module not ready, game will have limited functionality");
|
||||
}
|
||||
|
||||
// Wait for UI Controller with a timeout
|
||||
const uiReady = await moduleRegistry.waitForModule('ui-controller', 15000);
|
||||
|
||||
if (uiReady) {
|
||||
this.uiController = moduleRegistry.getModule('ui-controller');
|
||||
this.reportProgress(50, "UI Controller ready");
|
||||
} else {
|
||||
console.warn("UI Controller not ready, game will have limited functionality");
|
||||
}
|
||||
|
||||
// Get text buffer reference
|
||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
||||
if (this.textBuffer) {
|
||||
this.reportProgress(60, "Text buffer ready");
|
||||
}
|
||||
|
||||
// Continue even with limited functionality
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error waiting for dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
|
||||
async initialize() {
|
||||
this.reportProgress(100, "Game loop initialized");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game loop
|
||||
*/
|
||||
start() {
|
||||
console.log("GameLoop: Starting game sequence...");
|
||||
|
||||
try {
|
||||
// Update UI with initial game state
|
||||
if (this.uiController && this.ttsPlayer) {
|
||||
this.updateUIState();
|
||||
} else {
|
||||
console.warn("GameLoop: UI Controller or TTS Player not ready for status update.");
|
||||
}
|
||||
// The dependencies are now automatically available via getModule
|
||||
this.updateUIState();
|
||||
|
||||
console.log("GameLoop: Setting up socket listeners and connecting...");
|
||||
|
||||
@@ -105,12 +60,9 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up socket event listeners and connect to server
|
||||
*/
|
||||
setupSocketEventListeners() {
|
||||
// Get the socket client module
|
||||
this.socketClient = moduleRegistry.getModule('socket-client');
|
||||
// Get the socket client module using parent's getModule method
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
|
||||
if (!this.socketClient) {
|
||||
console.error("Socket client module not found");
|
||||
@@ -118,6 +70,8 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
|
||||
// Connect UI controller to socket client for command handling
|
||||
this.uiController = this.getModule('ui-controller');
|
||||
|
||||
if (this.uiController) {
|
||||
this.uiController.socketClient = this.socketClient;
|
||||
} else {
|
||||
@@ -128,9 +82,14 @@ class GameLoopModule extends BaseModule {
|
||||
this.socketClient.on('connect', () => {
|
||||
console.log("GameLoop: Socket connected event received.");
|
||||
|
||||
// Request a new game start when we (re)connect
|
||||
console.log("GameLoop: Requesting start game on (re)connect.");
|
||||
this.requestStartGame();
|
||||
// Request a new game start when we connect
|
||||
// Only request start game if one isn't already in progress
|
||||
if (!this.gameState.started) {
|
||||
console.log("GameLoop: Requesting start game on connect.");
|
||||
this.requestStartGame();
|
||||
} else {
|
||||
console.log("GameLoop: Game already started, skipping duplicate start request.");
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for game state updates
|
||||
@@ -157,8 +116,6 @@ class GameLoopModule extends BaseModule {
|
||||
this.socketClient.connect().then(success => {
|
||||
if (success) {
|
||||
console.log("GameLoop: Socket connection established successfully.");
|
||||
console.log("GameLoop: Requesting to start a new game");
|
||||
this.requestStartGame();
|
||||
} else {
|
||||
console.error("GameLoop: Failed to connect to socket server");
|
||||
}
|
||||
@@ -229,12 +186,15 @@ class GameLoopModule extends BaseModule {
|
||||
* @param {string} text - Text to add
|
||||
*/
|
||||
addText(text) {
|
||||
if (!this.textBuffer) {
|
||||
// Use parent's getModule method
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
|
||||
if (!textBuffer) {
|
||||
console.warn("Text buffer not available");
|
||||
return;
|
||||
}
|
||||
|
||||
this.textBuffer.addText(text);
|
||||
textBuffer.addText(text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+792
-707
File diff suppressed because it is too large
Load Diff
+38
-35
@@ -23,7 +23,7 @@ self.onmessage = function(e) {
|
||||
break;
|
||||
|
||||
case 'generate':
|
||||
if (!message.data || !message.data.text) {
|
||||
if (!message.text) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: 'No text provided for generation'
|
||||
@@ -32,11 +32,17 @@ self.onmessage = function(e) {
|
||||
}
|
||||
|
||||
// Store voice options
|
||||
if (message.data.voice) voiceOptions.voice = message.data.voice;
|
||||
if (message.data.speed) voiceOptions.speed = message.data.speed;
|
||||
if (message.voice) voiceOptions.voice = message.voice;
|
||||
if (message.speed) voiceOptions.speed = message.speed;
|
||||
|
||||
// Generate speech
|
||||
generateSpeech(message.data.text)
|
||||
generateSpeech(message.text)
|
||||
.then(result => {
|
||||
self.postMessage({
|
||||
type: 'generated',
|
||||
result: result
|
||||
}, [result.audio.buffer]);
|
||||
})
|
||||
.catch(error => {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
@@ -73,46 +79,43 @@ async function generateSpeech(text) {
|
||||
try {
|
||||
// Load Kokoro if not already loaded
|
||||
if (!kokoroLoaded) {
|
||||
// Load the Kokoro script
|
||||
self.importScripts('/js/kokoro-js.js');
|
||||
|
||||
if (!self.kokoro || !self.kokoro.KokoroTTS) {
|
||||
throw new Error('Kokoro failed to load correctly');
|
||||
try {
|
||||
// Load the Kokoro script
|
||||
self.importScripts('/js/kokoro.js');
|
||||
|
||||
if (!self.Kokoro) {
|
||||
throw new Error('Kokoro failed to load correctly');
|
||||
}
|
||||
|
||||
kokoroLoaded = true;
|
||||
console.log('Kokoro loaded in worker');
|
||||
} catch (loadError) {
|
||||
console.error('Error loading Kokoro in worker:', loadError);
|
||||
throw new Error(`Failed to load Kokoro: ${loadError.message}`);
|
||||
}
|
||||
|
||||
kokoroLoaded = true;
|
||||
}
|
||||
|
||||
// Create a new Kokoro instance for this generation
|
||||
// We can't easily transfer the instance from the main thread, so we create it here
|
||||
const kokoroTTS = self.kokoro.KokoroTTS;
|
||||
|
||||
// Create instance using from_pretrained
|
||||
const tts = await kokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
|
||||
dtype: "fp32",
|
||||
device: "wasm",
|
||||
cache: true // Use cache to speed up subsequent loads
|
||||
});
|
||||
|
||||
// Generate speech
|
||||
const result = await tts.generate(text, {
|
||||
// Generate speech using Kokoro
|
||||
const result = await self.Kokoro(text, {
|
||||
voice: voiceOptions.voice,
|
||||
speed: voiceOptions.speed
|
||||
speed: voiceOptions.speed,
|
||||
autoPlay: false
|
||||
});
|
||||
|
||||
// Send the result back to the main thread
|
||||
// We can't transfer the Float32Array directly, so let's transfer the buffer
|
||||
const audioBuffer = result.audio.buffer;
|
||||
// Extract audio data
|
||||
const audioContext = new (self.AudioContext || self.webkitAudioContext)();
|
||||
const audioBuffer = await audioContext.decodeAudioData(result.buffer);
|
||||
|
||||
self.postMessage({
|
||||
type: 'generated',
|
||||
result: {
|
||||
audio: audioBuffer,
|
||||
sampling_rate: result.sampling_rate
|
||||
}
|
||||
}, [audioBuffer]); // Transfer the buffer for better performance
|
||||
// Get audio data as Float32Array
|
||||
const audioData = audioBuffer.getChannelData(0);
|
||||
|
||||
// Return the result
|
||||
return {
|
||||
audio: audioData,
|
||||
sampling_rate: audioBuffer.sampleRate
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating speech in worker:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
|
||||
+290
-230
@@ -1,251 +1,311 @@
|
||||
/**
|
||||
* LayoutRenderer Module
|
||||
* Translates the abstract layout data into concrete visual elements (DOM nodes).
|
||||
* Layout Renderer Module
|
||||
* Renders calculated paragraph layouts into the DOM with proper animations
|
||||
*/
|
||||
export class LayoutRenderer {
|
||||
/**
|
||||
* Create a new LayoutRenderer
|
||||
* @param {Object} animationQueue - The AnimationQueue instance
|
||||
*/
|
||||
constructor(animationQueue) {
|
||||
this.animationQueue = animationQueue;
|
||||
this.fastForwardingAll = false;
|
||||
}
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class LayoutRendererModule extends BaseModule {
|
||||
constructor() {
|
||||
super('layout-renderer', 'Layout Renderer');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['animation-queue'];
|
||||
|
||||
// Module references
|
||||
this.animationQueue = null;
|
||||
this.ttsPlayer = null;
|
||||
|
||||
// Configuration
|
||||
this.updateConfig({
|
||||
animation: {
|
||||
defaultSpeed: 1.0,
|
||||
wordAnimationClass: 'animate-word'
|
||||
}
|
||||
});
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'renderParagraph',
|
||||
'renderWord',
|
||||
'scheduleWordAnimation'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a paragraph based on layout data
|
||||
* @param {Object} paragraphData - The layout data from ParagraphLayout
|
||||
* @param {number} delay - Initial delay for animations
|
||||
* @param {Array<number>} measure - Array of line width measurements
|
||||
* @returns {Array} Array containing the paragraph element and the final delay
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
renderParagraph(paragraphData, delay = 0, measure = []) {
|
||||
const stack = [];
|
||||
let left = 0;
|
||||
const p = document.createElement("p");
|
||||
p.style.position = 'relative';
|
||||
p.classList.add("latest-paragraph");
|
||||
p.dataset.numberOfLines = paragraphData.breaks.length - 1;
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#ruler')).lineHeight);
|
||||
const lineWidth = parseFloat(window.getComputedStyle(document.getElementById('story')).width);
|
||||
const pageHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height);
|
||||
|
||||
p.style.height = lineHeight * (paragraphData.breaks.length - 1) + 'px';
|
||||
const paragraphHeight = parseFloat(p.style.height);
|
||||
p.dataset.vpc = paragraphHeight * 100 / pageHeight;
|
||||
p.style.marginBlockEnd = 0;
|
||||
stack.push(p);
|
||||
|
||||
for (let i = 1; i < paragraphData.breaks.length; i++) {
|
||||
left = measure[measure.length - 1] - measure[Math.min(i - 1, measure.length - 1)];
|
||||
let lastChild = null;
|
||||
let syllable = "";
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(10, "Initializing Layout Renderer");
|
||||
|
||||
for (let j = paragraphData.breaks[i-1].position; j <= paragraphData.breaks[i].position; j++) {
|
||||
if (paragraphData.nodes[j].type === 'box' && paragraphData.nodes[j].value !== '' && j < paragraphData.breaks[i].position) {
|
||||
if (j > paragraphData.breaks[i-1].position + 1 && paragraphData.nodes[j-1].type === 'penalty' && lastChild) {
|
||||
syllable += '\u200c' + paragraphData.nodes[j].value;
|
||||
lastChild.innerHTML = syllable;
|
||||
left += paragraphData.nodes[j].width;
|
||||
} else {
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.animationDuration = this.animationQueue.getSpeed() * 10 + 'ms';
|
||||
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
syllable = paragraphData.nodes[j].value;
|
||||
word.innerHTML = syllable;
|
||||
lastChild = word;
|
||||
// Get animation queue from module registry
|
||||
this.animationQueue = this.getModule('animation-queue');
|
||||
if (!this.animationQueue) {
|
||||
console.warn("Layout Renderer: Animation Queue module not found in registry");
|
||||
}
|
||||
|
||||
// We'll try to get the TTS module, but it's not a hard dependency
|
||||
// We'll check for it again at runtime when needed
|
||||
setTimeout(() => {
|
||||
// Try to get TTS module after a delay to allow it to initialize
|
||||
this.ttsPlayer = this.getModule('tts-player');
|
||||
if (!this.ttsPlayer) {
|
||||
console.log("Layout Renderer: TTS Player module not found yet, will try again when needed");
|
||||
}
|
||||
}, 500);
|
||||
|
||||
this.reportProgress(100, "Layout Renderer ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Layout Renderer:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default containers
|
||||
*/
|
||||
initializeContainers() {
|
||||
// Check if story container exists
|
||||
const storyContainer = document.getElementById('story');
|
||||
if (!storyContainer) {
|
||||
console.log('Story container not found, creating it');
|
||||
const container = document.createElement('div');
|
||||
container.id = 'story';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a paragraph from layout data
|
||||
* @param {Object} layout - Layout data from paragraph-layout
|
||||
* @param {Object} options - Rendering options
|
||||
* @returns {HTMLElement} - The created paragraph element
|
||||
*/
|
||||
renderParagraph(layout, options = {}) {
|
||||
const {
|
||||
container = document.getElementById('paragraphs'),
|
||||
id = `p-${Date.now()}`,
|
||||
className = '',
|
||||
style = {},
|
||||
animateWords = true,
|
||||
animationSpeed = this.config.animation.defaultSpeed,
|
||||
tts = false,
|
||||
onComplete = null
|
||||
} = options;
|
||||
|
||||
if (!layout || !layout.breaks || !layout.nodes || !container) {
|
||||
console.error('Invalid layout data or container');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create paragraph element
|
||||
const paragraphElement = document.createElement('p');
|
||||
paragraphElement.id = id;
|
||||
paragraphElement.className = `paragraph ${className}`.trim();
|
||||
paragraphElement.style.position = 'relative';
|
||||
|
||||
// Get line height and container width for positioning
|
||||
const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#story')).lineHeight) || 1.5;
|
||||
const containerWidth = parseFloat(window.getComputedStyle(container).width);
|
||||
|
||||
// Calculate paragraph height based on number of lines
|
||||
const numLines = layout.breaks.length - 1;
|
||||
paragraphElement.style.height = `${lineHeight * numLines}px`;
|
||||
|
||||
// Apply custom styles
|
||||
Object.assign(paragraphElement.style, style);
|
||||
|
||||
// Create a fragment to build the paragraph
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Track total delay for animations
|
||||
let totalDelay = 0;
|
||||
let wordElements = [];
|
||||
|
||||
// Process each line in the layout
|
||||
for (let i = 1; i < layout.breaks.length; i++) {
|
||||
// Track the current x position within the line
|
||||
let xPosition = 0;
|
||||
|
||||
// Process nodes in this line
|
||||
for (let j = layout.breaks[i-1].position; j < layout.breaks[i].position; j++) {
|
||||
const node = layout.nodes[j];
|
||||
|
||||
// Handle different node types
|
||||
switch (node.type) {
|
||||
case 'box':
|
||||
// This is a word
|
||||
if (node.value && node.value.trim() !== '') {
|
||||
const wordElement = this.renderWord(node.value, animateWords);
|
||||
|
||||
// Position the word within the line
|
||||
wordElement.style.position = 'absolute';
|
||||
wordElement.style.left = `${xPosition * 100 / containerWidth}%`;
|
||||
wordElement.style.top = `${(i - 1) * lineHeight}px`;
|
||||
|
||||
// Update x position for next word
|
||||
xPosition += node.width;
|
||||
|
||||
paragraphElement.appendChild(wordElement);
|
||||
wordElements.push(wordElement);
|
||||
}
|
||||
break;
|
||||
case 'glue':
|
||||
// This is a space - calculate its width based on the ratio
|
||||
const ratio = layout.breaks[i].ratio;
|
||||
let spaceWidth = node.width;
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], word);
|
||||
if (ratio > 0) {
|
||||
// Stretch space
|
||||
spaceWidth += ratio * node.stretch;
|
||||
} else if (ratio < 0) {
|
||||
// Shrink space
|
||||
spaceWidth += ratio * node.shrink;
|
||||
}
|
||||
|
||||
delay += this.animationQueue.getSpeed();
|
||||
left += paragraphData.nodes[j].width;
|
||||
}
|
||||
} else if (paragraphData.nodes[j].type === 'tag') {
|
||||
if (paragraphData.nodes[j].value.substr(0, 2) == '</') {
|
||||
stack.pop();
|
||||
} else {
|
||||
let tmp = document.createElement('div');
|
||||
tmp.innerHTML = paragraphData.nodes[j].value;
|
||||
const word = tmp.firstChild;
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
stack[stack.length-1].appendChild(word);
|
||||
stack.push(word);
|
||||
}
|
||||
} else if (j > paragraphData.breaks[i-1].position && paragraphData.nodes[j].type === 'glue' && paragraphData.nodes[j].width !== 0 && j <= paragraphData.breaks[i].position) {
|
||||
// Insert space character
|
||||
if (paragraphData.breaks[i].ratio > 0) {
|
||||
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].stretch;
|
||||
} else {
|
||||
left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].shrink;
|
||||
}
|
||||
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
word.style.left = left * 100 / lineWidth + '%';
|
||||
word.innerHTML = " ";
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], word);
|
||||
}
|
||||
} else if (paragraphData.nodes[j].type === 'penalty' && paragraphData.nodes[j].penalty === 100 && j === paragraphData.breaks[i].position) {
|
||||
// Create a hyphen at the end of the line if breaking at a hyphenation point
|
||||
let hyphen = document.createElement("span");
|
||||
hyphen.style.position = 'absolute';
|
||||
hyphen.classList.add("fade-in");
|
||||
hyphen.classList.add("hyphen-marker"); // Add a class for easier styling if needed
|
||||
hyphen.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%';
|
||||
hyphen.style.left = left * 100 / lineWidth + '%';
|
||||
hyphen.innerHTML = "-";
|
||||
|
||||
// Ensure hyphen is visible with stronger styling
|
||||
hyphen.style.fontWeight = "normal";
|
||||
hyphen.style.opacity = "1";
|
||||
|
||||
if (!this.fastForwardingAll) {
|
||||
this.insertAfter(delay, stack[stack.length-1], hyphen);
|
||||
// Log for debugging
|
||||
console.log("Inserted hyphen at line break:", i, "position:", left);
|
||||
}
|
||||
|
||||
delay += this.animationQueue.getSpeed();
|
||||
xPosition += spaceWidth;
|
||||
break;
|
||||
case 'penalty':
|
||||
// This is a hyphen or line break opportunity
|
||||
if (node.flagged && node.penalty < Infinity && j === layout.breaks[i].position) {
|
||||
const hyphenElement = document.createElement('span');
|
||||
hyphenElement.className = 'hyphen-marker';
|
||||
hyphenElement.textContent = '-';
|
||||
hyphenElement.style.position = 'absolute';
|
||||
hyphenElement.style.left = `${xPosition * 100 / containerWidth}%`;
|
||||
hyphenElement.style.top = `${(i - 1) * lineHeight}px`;
|
||||
|
||||
paragraphElement.appendChild(hyphenElement);
|
||||
wordElements.push(hyphenElement);
|
||||
}
|
||||
break;
|
||||
case 'tag':
|
||||
// This is a preserved tag
|
||||
if (typeof node.value === 'string') {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = node.value;
|
||||
while (tempDiv.firstChild) {
|
||||
const tagElement = tempDiv.firstChild;
|
||||
tagElement.style.position = 'absolute';
|
||||
tagElement.style.left = `${xPosition * 100 / containerWidth}%`;
|
||||
tagElement.style.top = `${(i - 1) * lineHeight}px`;
|
||||
|
||||
paragraphElement.appendChild(tagElement);
|
||||
|
||||
// Estimate width for positioning next element
|
||||
xPosition += 20; // Approximate width of tag
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [p, delay];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an element after a delay
|
||||
* @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 (fadeIn) {
|
||||
el.classList.add("fade-in");
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
} else {
|
||||
this.animationQueue.schedule(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an element after a delay
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @param {HTMLElement} el - The element to show
|
||||
*/
|
||||
showAfter(delay, el) {
|
||||
el.classList.add("hide");
|
||||
setTimeout(function() {
|
||||
setTimeout(function() { el.classList.remove("hide") }, delay);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a visual tag
|
||||
* @param {string} tagType - The type of tag (IMAGE, BACKGROUND, etc.)
|
||||
* @param {string} tagValue - The value of the tag
|
||||
* @param {HTMLElement} container - The container to append to
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
* @returns {HTMLElement|null} The created element or null
|
||||
*/
|
||||
renderVisualTag(tagType, tagValue, container, delay = 0) {
|
||||
switch (tagType) {
|
||||
case "IMAGE":
|
||||
const imageElement = document.createElement('img');
|
||||
imageElement.src = tagValue;
|
||||
container.appendChild(imageElement);
|
||||
this.showAfter(delay, imageElement);
|
||||
return imageElement;
|
||||
|
||||
case "BACKGROUND":
|
||||
const outerScrollContainer = document.querySelector('#book');
|
||||
outerScrollContainer.style.backgroundImage = 'url(' + tagValue + ')';
|
||||
return null;
|
||||
|
||||
case "CHAPTER":
|
||||
const h = document.createElement('H2');
|
||||
h.appendChild(document.createTextNode(tagValue));
|
||||
h.classList.add("chapter-heading");
|
||||
h.classList.add("fade-in");
|
||||
container.appendChild(h);
|
||||
return h;
|
||||
|
||||
case "SEPARATOR":
|
||||
const d = document.createElement('double');
|
||||
d.appendChild(document.createTextNode('\u2766'));
|
||||
d.classList.add("fade-in");
|
||||
d.classList.add("separator");
|
||||
container.appendChild(d);
|
||||
return d;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the fast forwarding state
|
||||
* @param {boolean} state - The fast forwarding state
|
||||
*/
|
||||
setFastForwardingAll(state) {
|
||||
this.fastForwardingAll = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fast forwarding state
|
||||
* @returns {boolean} The fast forwarding state
|
||||
*/
|
||||
getFastForwardingAll() {
|
||||
return this.fastForwardingAll;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth scroll to an element
|
||||
* @param {HTMLElement} target - The target element to scroll to
|
||||
* @param {number} duration - The duration of the scroll animation
|
||||
*/
|
||||
smoothScroll(target, duration) {
|
||||
const display = document.getElementById('page_right');
|
||||
const targetPosition = target.getBoundingClientRect().top;
|
||||
const startPosition = display.scrollTop;
|
||||
const distance = targetPosition;
|
||||
let startTime = null;
|
||||
// Add the paragraph to the container
|
||||
container.appendChild(paragraphElement);
|
||||
|
||||
if (duration < 5) {
|
||||
display.scrollTo(0, targetPosition);
|
||||
return;
|
||||
// Schedule animations for words if enabled
|
||||
if (animateWords && this.animationQueue) {
|
||||
// Schedule animations for each word with a faster timing
|
||||
const baseDelay = 0; // Starting delay
|
||||
const wordDelay = 20; // Delay between words in ms (reduced from 40)
|
||||
|
||||
wordElements.forEach((wordElement, index) => {
|
||||
const delay = baseDelay + (index * wordDelay);
|
||||
totalDelay = Math.max(totalDelay, delay);
|
||||
|
||||
this.scheduleWordAnimation(wordElement, delay, animationSpeed);
|
||||
});
|
||||
|
||||
// Schedule TTS if enabled - start it earlier in the animation sequence
|
||||
if (tts && this.ttsPlayer) {
|
||||
// Get the full text for TTS
|
||||
const fullText = layout.originalText || layout.processedText || paragraphElement.textContent;
|
||||
|
||||
// Schedule TTS with the animation queue - start after just a few words appear
|
||||
this.animationQueue.schedule(() => {
|
||||
this.ttsPlayer.speak(fullText, (result) => {
|
||||
if (!result || !result.success) {
|
||||
console.warn('TTS playback issue:', result ? result.reason : 'unknown');
|
||||
}
|
||||
});
|
||||
}, Math.min(100, wordDelay * 3)); // Start TTS earlier
|
||||
}
|
||||
|
||||
// Schedule completion callback
|
||||
if (onComplete && typeof onComplete === 'function') {
|
||||
const completionDelay = totalDelay + 200; // Reduced completion delay
|
||||
this.animationQueue.schedule(onComplete, completionDelay);
|
||||
}
|
||||
} else if (onComplete && typeof onComplete === 'function') {
|
||||
// If not animating, call onComplete immediately
|
||||
setTimeout(onComplete, 0);
|
||||
}
|
||||
|
||||
function animation(currentTime) {
|
||||
if (startTime === null) startTime = currentTime;
|
||||
const timeElapsed = currentTime - startTime;
|
||||
const run = ease(timeElapsed, startPosition, distance, duration);
|
||||
display.scrollTo(0, run);
|
||||
if (timeElapsed < duration) requestAnimationFrame(animation);
|
||||
return paragraphElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single word
|
||||
* @param {string} word - Word to render
|
||||
* @param {boolean} animate - Whether to prepare for animation
|
||||
* @returns {HTMLElement} - The created word element
|
||||
*/
|
||||
renderWord(word, animate = true) {
|
||||
const wordElement = this.createWordElement(word);
|
||||
|
||||
// Apply initial styles for animation
|
||||
if (animate) {
|
||||
wordElement.style.opacity = '0';
|
||||
wordElement.style.transform = 'translateY(5px)';
|
||||
wordElement.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
function ease(t, b, c, d) {
|
||||
t /= d / 2;
|
||||
if (t < 1) return c / 2 * t * t + b;
|
||||
t--;
|
||||
return -c / 2 * (t * (t - 2) - 1) + b;
|
||||
}
|
||||
return wordElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a word element
|
||||
* @param {string} word - Word to render
|
||||
* @returns {HTMLElement} - The created word element
|
||||
*/
|
||||
createWordElement(word) {
|
||||
const wordElement = document.createElement('span');
|
||||
wordElement.className = 'word';
|
||||
wordElement.textContent = word;
|
||||
return wordElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a word animation with the animation queue
|
||||
* @param {HTMLElement} wordElement - Word element to animate
|
||||
* @param {number} delay - Delay before animation starts
|
||||
* @param {number} speed - Animation speed factor
|
||||
*/
|
||||
scheduleWordAnimation(wordElement, delay, speed) {
|
||||
if (!this.animationQueue) return;
|
||||
|
||||
requestAnimationFrame(animation);
|
||||
const actualDelay = delay * speed;
|
||||
|
||||
this.animationQueue.schedule(() => {
|
||||
wordElement.style.opacity = '1';
|
||||
wordElement.style.transform = 'translateY(0)';
|
||||
wordElement.style.transition = `opacity 0.2s ease-out, transform 0.3s ease-out`;
|
||||
}, actualDelay);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const LayoutRenderer = new LayoutRendererModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(LayoutRenderer);
|
||||
|
||||
// Export the module
|
||||
export { LayoutRenderer };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.LayoutRenderer = LayoutRenderer;
|
||||
|
||||
+57
-2
@@ -7,6 +7,10 @@
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
// Ensure moduleRegistry is available globally before anything else runs
|
||||
window.moduleRegistry = moduleRegistry;
|
||||
console.log('Module registry initialized and assigned to window.moduleRegistry');
|
||||
|
||||
/**
|
||||
* Module States
|
||||
*/
|
||||
@@ -45,6 +49,20 @@ const ModuleLoader = (function() {
|
||||
}
|
||||
|
||||
console.log('Module Loader: Initialization started');
|
||||
|
||||
// Check for circular dependencies before proceeding
|
||||
const circularDependencies = moduleRegistry.checkForCircularDependencies();
|
||||
if (circularDependencies) {
|
||||
const errorMsg = `Circular dependency detected: ${circularDependencies.join(' -> ')} -> ${circularDependencies[0]}`;
|
||||
console.error(errorMsg);
|
||||
document.body.innerHTML = `<div style="padding: 20px; color: white; background-color: #ff3333;">
|
||||
<h2>Fatal Error: Circular Module Dependency</h2>
|
||||
<p>${errorMsg}</p>
|
||||
<p>Please check the browser console for more details.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the loading overlay
|
||||
createLoadingOverlay();
|
||||
|
||||
@@ -77,6 +95,7 @@ const ModuleLoader = (function() {
|
||||
* @returns {Promise} - Resolves when all module scripts are loaded
|
||||
*/
|
||||
async function loadModuleScripts() {
|
||||
|
||||
// Define modules with their weights
|
||||
const modulesToLoad = [
|
||||
// Core functionality modules
|
||||
@@ -84,10 +103,12 @@ const ModuleLoader = (function() {
|
||||
{ id: 'localization', script: '/js/localization.js', weight: 40 },
|
||||
{ id: 'text-processor', script: '/js/text-processor.js', weight: 40 },
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout.js', weight: 40 },
|
||||
{ id: 'layout-renderer', script: '/js/layout-renderer.js', weight: 45 }, // Add Layout Renderer module
|
||||
{ id: 'animation-queue', script: '/js/animation-queue.js', weight: 50 },
|
||||
|
||||
// Audio and TTS modules
|
||||
{ id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 },
|
||||
{ id: 'tts-factory', script: '/js/tts-factory.js', weight: 70 }, // TTSFactory must be loaded before TTSPlayer
|
||||
{ id: 'tts', script: '/js/tts-player.js', weight: 75 },
|
||||
|
||||
// UI and interaction modules
|
||||
@@ -143,6 +164,13 @@ const ModuleLoader = (function() {
|
||||
// Find the game loop module instance
|
||||
gameLoopModule = moduleRegistry.getModule('game-loop');
|
||||
|
||||
// Log dependency information for debugging
|
||||
console.log('Module dependencies:');
|
||||
Object.keys(modules).forEach(moduleId => {
|
||||
const dependencies = moduleRegistry.getDependencies(moduleId);
|
||||
console.log(`${moduleId} depends on: ${dependencies.length ? dependencies.join(', ') : 'none'}`);
|
||||
});
|
||||
|
||||
// For each registered module, start initialization
|
||||
Object.values(modules).forEach(async (module) => {
|
||||
try {
|
||||
@@ -165,9 +193,15 @@ const ModuleLoader = (function() {
|
||||
}
|
||||
};
|
||||
|
||||
// Log start of initialization
|
||||
console.log(`Starting initialization of module: ${module.id}`);
|
||||
|
||||
// Initialize the module with progress callback
|
||||
await module.initializeInterface(progressCallback);
|
||||
|
||||
// Log completion of initialization
|
||||
console.log(`Completed initialization of module: ${module.id}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error initializing module ${module.id}:`, error);
|
||||
}
|
||||
@@ -307,7 +341,18 @@ const ModuleLoader = (function() {
|
||||
});
|
||||
|
||||
if (allFinished && !isLoadingComplete) {
|
||||
console.log('All modules finished loading. Proceeding to finalization...');
|
||||
finalizeLoading();
|
||||
} else if (!allFinished) {
|
||||
// Log which modules are not finished yet
|
||||
const pendingModules = Object.values(modules).filter(module => {
|
||||
const state = module.getState();
|
||||
return state !== ModuleState.FINISHED && state !== ModuleState.ERROR;
|
||||
});
|
||||
|
||||
if (pendingModules.length > 0) {
|
||||
console.log('Modules still pending:', pendingModules.map(m => `${m.id} (${m.getState()})`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +361,13 @@ const ModuleLoader = (function() {
|
||||
*/
|
||||
function finalizeLoading() {
|
||||
console.log('Loading completed. Finalizing...');
|
||||
completeFinalization();
|
||||
try {
|
||||
completeFinalization();
|
||||
} catch (error) {
|
||||
console.error('Error during finalization:', error);
|
||||
// Force hide the overlay even if there was an error
|
||||
hideOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,7 +382,11 @@ const ModuleLoader = (function() {
|
||||
// Hide the overlay first, then start the game loop
|
||||
hideOverlay(() => {
|
||||
console.log("Loader: Overlay hidden, starting Game Loop.");
|
||||
gameLoopModule.start();
|
||||
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.");
|
||||
|
||||
+188
-183
@@ -1,214 +1,188 @@
|
||||
/**
|
||||
* Localization Module
|
||||
* Manages translations and locale settings for the application
|
||||
* Localization Module for AI Interactive Fiction
|
||||
* Handles translations and locale settings
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class LocalizationModule extends BaseModule {
|
||||
/**
|
||||
* Create a new localization module
|
||||
*/
|
||||
constructor() {
|
||||
super('localization', 'Localization');
|
||||
this.currentLocale = 'en-us'; // Default locale
|
||||
|
||||
// Current locale
|
||||
this.currentLocale = 'en-us';
|
||||
|
||||
// Available translations
|
||||
this.translations = {};
|
||||
this.observers = new Set(); // Modules that need to be notified of locale changes
|
||||
|
||||
// Language names mapping
|
||||
this.languageNames = {
|
||||
'en-us': 'English (US)',
|
||||
'en-gb': 'English (UK)',
|
||||
'de': 'Deutsch',
|
||||
'de-de': 'Deutsch (Deutschland)',
|
||||
'fr': 'Français',
|
||||
'fr-fr': 'Français (France)',
|
||||
'es': 'Español',
|
||||
'es-es': 'Español (España)',
|
||||
'it': 'Italiano',
|
||||
'ja': 'Japanese',
|
||||
'ko': 'Korean',
|
||||
'zh': 'Chinese (Simplified)',
|
||||
'zh-tw': 'Chinese (Traditional)',
|
||||
'ru': 'Russian',
|
||||
'pt': 'Portuguese',
|
||||
'pt-br': 'Portuguese (Brazil)'
|
||||
};
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'setLocale',
|
||||
'getLocale',
|
||||
'translate',
|
||||
'getAvailableLocales',
|
||||
'getLanguageName',
|
||||
'getLanguage'
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Load translations
|
||||
this.loadTranslations();
|
||||
this.reportProgress(10, "Initializing localization");
|
||||
|
||||
// Set global locale for SmartyPants
|
||||
window.locale = this.currentLocale;
|
||||
// Load default translations
|
||||
await this.loadTranslations('en-us');
|
||||
|
||||
this.reportProgress(100, "Localization module ready");
|
||||
// Try to load browser locale if available
|
||||
const browserLocale = navigator.language.toLowerCase();
|
||||
if (browserLocale && browserLocale !== 'en-us') {
|
||||
try {
|
||||
this.reportProgress(50, `Loading browser locale: ${browserLocale}`);
|
||||
await this.loadTranslations(browserLocale);
|
||||
this.currentLocale = browserLocale;
|
||||
} catch (localeError) {
|
||||
console.warn(`Failed to load browser locale ${browserLocale}:`, localeError);
|
||||
}
|
||||
}
|
||||
|
||||
// We don't check for persistence manager here to avoid circular dependency
|
||||
// The persistence manager will update our locale after it initializes if needed
|
||||
|
||||
this.reportProgress(100, "Localization ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing localization module:", error);
|
||||
console.error("Error initializing localization:", error);
|
||||
this.reportProgress(100, "Localization failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all translations
|
||||
*/
|
||||
loadTranslations() {
|
||||
// Add English translations (default)
|
||||
this.addTranslations('en-us', {
|
||||
// UI elements
|
||||
'by': 'powered by Generative AI',
|
||||
'title': 'AI Interactive Fiction',
|
||||
'subtitle': 'An open-world text adventure',
|
||||
'speech': 'speech',
|
||||
'speed': 'speed',
|
||||
'restart': 'restart',
|
||||
'save': 'save',
|
||||
'load': 'load',
|
||||
'prompt': 'What do you want to do next?',
|
||||
'remark': '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>',
|
||||
|
||||
// Tooltips
|
||||
'title_speech': 'Toggle text to speech',
|
||||
'title_speech_unavailable': 'Text-to-Speech not available',
|
||||
'title_restart': 'Restart story from beginning',
|
||||
'title_save': 'Save progress',
|
||||
'title_load': 'Reload from save point',
|
||||
|
||||
// Confirm dialogs
|
||||
'confirm_restart': 'Are you sure you want to restart the game? All progress will be lost.'
|
||||
});
|
||||
|
||||
// Add German translations
|
||||
this.addTranslations('de', {
|
||||
'by': 'unterstützt durch KI',
|
||||
'title': 'KI Interaktive Fiktion',
|
||||
'subtitle': 'Ein Textabenteuer in offener Welt',
|
||||
'speech': 'Sprache',
|
||||
'speed': 'Tempo',
|
||||
'restart': 'Neustart',
|
||||
'save': 'Speichern',
|
||||
'load': 'Laden',
|
||||
'prompt': 'Was möchtest du als nächstes tun?',
|
||||
'remark': '<i><sup>*</sup>Klicke auf die Seite oder drücke die Leertaste, um die Textanimation zu beschleunigen</i>',
|
||||
|
||||
'title_speech': 'Text-zu-Sprache umschalten',
|
||||
'title_speech_unavailable': 'Text-zu-Sprache nicht verfügbar',
|
||||
'title_restart': 'Geschichte von Anfang an neu starten',
|
||||
'title_save': 'Fortschritt speichern',
|
||||
'title_load': 'Von Speicherpunkt neu laden',
|
||||
|
||||
'confirm_restart': 'Bist du sicher, dass du das Spiel neu starten möchtest? Der gesamte Fortschritt geht verloren.'
|
||||
});
|
||||
|
||||
// Add French translations
|
||||
this.addTranslations('fr', {
|
||||
'by': 'propulsé par l\'IA',
|
||||
'title': 'Fiction Interactive IA',
|
||||
'subtitle': 'Une aventure textuelle en monde ouvert',
|
||||
'speech': 'parole',
|
||||
'speed': 'vitesse',
|
||||
'restart': 'recommencer',
|
||||
'save': 'sauver',
|
||||
'load': 'charger',
|
||||
'prompt': 'Que voulez-vous faire ensuite?',
|
||||
'remark': '<i><sup>*</sup>cliquez sur la page ou appuyez sur la barre d\'espace pour accélérer l\'animation du texte</i>',
|
||||
|
||||
'title_speech': 'Activer/désactiver la synthèse vocale',
|
||||
'title_speech_unavailable': 'Synthèse vocale non disponible',
|
||||
'title_restart': 'Redémarrer l\'histoire depuis le début',
|
||||
'title_save': 'Sauvegarder la progression',
|
||||
'title_load': 'Recharger depuis le point de sauvegarde',
|
||||
|
||||
'confirm_restart': 'Êtes-vous sûr de vouloir redémarrer le jeu? Tous les progrès seront perdus.'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translations for a specific locale
|
||||
* @param {string} locale - Locale code
|
||||
* @param {Object} translations - Translation key-value pairs
|
||||
* Load translations for a locale
|
||||
* @param {string} locale - Locale to load
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
addTranslations(locale, translations) {
|
||||
if (!this.translations[locale]) {
|
||||
this.translations[locale] = {};
|
||||
async loadTranslations(locale) {
|
||||
if (this.translations[locale]) {
|
||||
return; // Already loaded
|
||||
}
|
||||
|
||||
Object.assign(this.translations[locale], translations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation for a key in current locale
|
||||
* @param {string} key - Translation key
|
||||
* @param {string} [defaultValue] - Default value if translation not found
|
||||
* @returns {string} - Translated text or default value
|
||||
*/
|
||||
translate(key, defaultValue = null) {
|
||||
const localeTranslations = this.translations[this.currentLocale];
|
||||
|
||||
if (localeTranslations && localeTranslations[key] !== undefined) {
|
||||
return localeTranslations[key];
|
||||
try {
|
||||
// Normalize locale
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
|
||||
// Try to load the exact locale
|
||||
const response = await fetch(`/locales/${normalizedLocale}.json`);
|
||||
|
||||
if (response.ok) {
|
||||
const translations = await response.json();
|
||||
this.translations[normalizedLocale] = translations;
|
||||
} else {
|
||||
// Try to load the language part only
|
||||
const langPart = normalizedLocale.split('-')[0];
|
||||
if (langPart !== normalizedLocale) {
|
||||
const langResponse = await fetch(`/locales/${langPart}.json`);
|
||||
if (langResponse.ok) {
|
||||
const translations = await langResponse.json();
|
||||
this.translations[normalizedLocale] = translations;
|
||||
} else {
|
||||
// Fallback to English
|
||||
if (normalizedLocale !== 'en-us' && normalizedLocale !== 'en') {
|
||||
await this.loadTranslations('en-us');
|
||||
this.translations[normalizedLocale] = this.translations['en-us'];
|
||||
} else {
|
||||
// If English is not found, create an empty translation set
|
||||
console.warn('English translations not found, using empty set');
|
||||
this.translations[normalizedLocale] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading translations for ${locale}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Fall back to English if translation not found
|
||||
if (this.currentLocale !== 'en-us' && this.translations['en-us'] && this.translations['en-us'][key]) {
|
||||
return this.translations['en-us'][key];
|
||||
}
|
||||
|
||||
// Return default value or key if no translation found
|
||||
return defaultValue || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current locale
|
||||
* @param {string} locale - Locale code
|
||||
* @param {string} locale - Locale to set
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
setLocale(locale) {
|
||||
if (this.translations[locale]) {
|
||||
this.currentLocale = locale;
|
||||
|
||||
// Update global locale for SmartyPants
|
||||
window.locale = locale;
|
||||
|
||||
// Notify observers of locale change
|
||||
this.notifyObservers();
|
||||
|
||||
console.log(`Localization: Locale set to ${locale}`);
|
||||
return true;
|
||||
}
|
||||
async setLocale(locale) {
|
||||
if (!locale) return false;
|
||||
|
||||
console.warn(`Localization: Locale ${locale} not available`);
|
||||
return false;
|
||||
try {
|
||||
// Normalize locale
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
|
||||
// Load translations if not already loaded
|
||||
if (!this.translations[normalizedLocale]) {
|
||||
await this.loadTranslations(normalizedLocale);
|
||||
}
|
||||
|
||||
// Set current locale
|
||||
this.currentLocale = normalizedLocale;
|
||||
|
||||
// Update persistence
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
|
||||
}
|
||||
|
||||
// Dispatch locale change event
|
||||
this.dispatchEvent('locale-changed', {
|
||||
locale: normalizedLocale
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error setting locale to ${locale}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current locale
|
||||
* @returns {string} - Current locale code
|
||||
* @returns {string} - Current locale
|
||||
*/
|
||||
getLocale() {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module to be notified of locale changes
|
||||
* @param {Object} module - Module to register
|
||||
* @param {Function} updateMethod - Method to call on locale change
|
||||
* Get the language part of the current locale (e.g., 'en' from 'en-us')
|
||||
* @returns {string} - Language code
|
||||
*/
|
||||
registerObserver(module, updateMethod) {
|
||||
if (typeof updateMethod !== 'function') {
|
||||
console.error('Localization: Update method must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
this.observers.add({ module, updateMethod });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an observer module
|
||||
* @param {Object} module - Module to unregister
|
||||
*/
|
||||
unregisterObserver(module) {
|
||||
this.observers.forEach(observer => {
|
||||
if (observer.module === module) {
|
||||
this.observers.delete(observer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all observer modules of locale change
|
||||
*/
|
||||
notifyObservers() {
|
||||
this.observers.forEach(observer => {
|
||||
try {
|
||||
observer.updateMethod(this.currentLocale);
|
||||
} catch (error) {
|
||||
console.error(`Error notifying observer for locale change:`, error);
|
||||
}
|
||||
});
|
||||
getLanguage() {
|
||||
return this.currentLocale.split('-')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,34 +190,68 @@ class LocalizationModule extends BaseModule {
|
||||
* @returns {Array<string>} - Array of locale codes
|
||||
*/
|
||||
getAvailableLocales() {
|
||||
return Object.keys(this.translations);
|
||||
// Return the keys of the language names object
|
||||
// This is a simplification - in a real app, we would dynamically load available locales
|
||||
return Object.keys(this.languageNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translations for a specific locale
|
||||
* Get the language name for a locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {Object} - Translations for the locale
|
||||
* @returns {string} - Language name
|
||||
*/
|
||||
getTranslationsForLocale(locale) {
|
||||
return this.translations[locale] || {};
|
||||
getLanguageName(locale) {
|
||||
if (!locale) return '';
|
||||
|
||||
// Normalize locale
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
|
||||
// Try exact match
|
||||
if (this.languageNames[normalizedLocale]) {
|
||||
return this.languageNames[normalizedLocale];
|
||||
}
|
||||
|
||||
// Try language part only
|
||||
const langPart = normalizedLocale.split('-')[0];
|
||||
if (this.languageNames[langPart]) {
|
||||
return this.languageNames[langPart];
|
||||
}
|
||||
|
||||
// Fallback: return the locale code itself
|
||||
return locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current locale's direction (ltr or rtl)
|
||||
* @returns {string} - Text direction ('ltr' or 'rtl')
|
||||
* Translate a key
|
||||
* @param {string} key - Translation key
|
||||
* @param {Object} params - Parameters for interpolation
|
||||
* @returns {string} - Translated text
|
||||
*/
|
||||
getTextDirection() {
|
||||
// List of RTL languages
|
||||
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
|
||||
translate(key, params = {}) {
|
||||
if (!key) return '';
|
||||
|
||||
// Check if current locale starts with any RTL language code
|
||||
for (const rtl of rtlLocales) {
|
||||
if (this.currentLocale.startsWith(rtl)) {
|
||||
return 'rtl';
|
||||
// Get translations for current locale
|
||||
const translations = this.translations[this.currentLocale] || {};
|
||||
|
||||
// Get translation or fallback to key
|
||||
let translation = translations[key] || key;
|
||||
|
||||
// Interpolate parameters
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
for (const [param, value] of Object.entries(params)) {
|
||||
translation = translation.replace(new RegExp(`\{\{${param}\}\}`, 'g'), value);
|
||||
}
|
||||
}
|
||||
|
||||
return 'ltr';
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when module is disposed
|
||||
*/
|
||||
dispose() {
|
||||
// Clear translations
|
||||
this.translations = {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +263,3 @@ moduleRegistry.register(Localization);
|
||||
|
||||
// Export the module
|
||||
export { Localization };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.Localization = Localization;
|
||||
|
||||
@@ -6,20 +6,52 @@ export class ModuleRegistry {
|
||||
constructor() {
|
||||
this.modules = {};
|
||||
this.readyPromises = {};
|
||||
this.moduleDependencies = new Map(); // Track module dependencies
|
||||
this.visitedModules = new Set(); // For circular dependency detection
|
||||
this.recursionStack = new Set(); // For circular dependency detection
|
||||
this.untrackedDependencies = new Map(); // Track unregistered dependencies
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module
|
||||
* @param {BaseModule} module - Module to register
|
||||
* @param {Array<string>} [dependencies] - Optional array of module dependencies
|
||||
*/
|
||||
register(module) {
|
||||
register(module, dependencies = null) {
|
||||
if (!module || !module.id) {
|
||||
console.error('Invalid module - must have an id property');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the module
|
||||
this.modules[module.id] = module;
|
||||
|
||||
// Store dependencies if provided, otherwise use module.dependencies
|
||||
if (dependencies) {
|
||||
this.moduleDependencies.set(module.id, dependencies);
|
||||
|
||||
// Also set them on the module itself for backwards compatibility
|
||||
if (module.dependencies === undefined) {
|
||||
module.dependencies = [...dependencies];
|
||||
}
|
||||
} else if (module.dependencies) {
|
||||
// Use the module's own dependencies property
|
||||
this.moduleDependencies.set(module.id, [...module.dependencies]);
|
||||
} else {
|
||||
// No dependencies
|
||||
this.moduleDependencies.set(module.id, []);
|
||||
}
|
||||
|
||||
// Check for circular dependencies
|
||||
this.visitedModules.clear();
|
||||
this.recursionStack.clear();
|
||||
const circularDependency = this.detectCircularDependency(module.id);
|
||||
if (circularDependency) {
|
||||
const errorMsg = `Circular dependency detected: ${circularDependency.join(' -> ')} -> ${circularDependency[0]}`;
|
||||
console.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Create a promise that will resolve when this module is ready
|
||||
this.readyPromises[module.id] = new Promise((resolve) => {
|
||||
// Set up a state change listener for this module
|
||||
@@ -39,6 +71,76 @@ export class ModuleRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect circular dependencies using DFS algorithm
|
||||
* @param {string} moduleId - Starting module ID
|
||||
* @param {Array<string>} [path=[]] - Current dependency path
|
||||
* @returns {Array<string>|null} - Array representing the circular dependency path, or null if none
|
||||
*/
|
||||
detectCircularDependency(moduleId, path = []) {
|
||||
// If we've already checked this module completely, no need to check again
|
||||
if (this.visitedModules.has(moduleId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we're already visiting this module in the current path, we found a cycle
|
||||
if (this.recursionStack.has(moduleId)) {
|
||||
// Return the path that forms the cycle
|
||||
const cycleStartIndex = path.indexOf(moduleId);
|
||||
if (cycleStartIndex >= 0) {
|
||||
return path.slice(cycleStartIndex);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Add to recursion stack to mark as being visited
|
||||
this.recursionStack.add(moduleId);
|
||||
path.push(moduleId);
|
||||
|
||||
// Get dependencies for this module
|
||||
const dependencies = this.getDependencies(moduleId);
|
||||
|
||||
// Check each dependency
|
||||
for (const depId of dependencies) {
|
||||
// Even if the dependency isn't registered yet, we need to track it
|
||||
// for potential circular dependencies that will manifest later
|
||||
// Create a temporary placeholder in the path for unregistered dependencies
|
||||
const depPath = [...path];
|
||||
if (!this.modules[depId]) {
|
||||
// Log that we're tracking an unregistered dependency
|
||||
console.log(`Module Registry: Tracking potential circular dependency with unregistered module: ${depId}`);
|
||||
// Add to the dependency tracking for future checks
|
||||
this.trackDependency(moduleId, depId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = this.detectCircularDependency(depId, depPath);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from recursion stack as we're done with this module
|
||||
this.recursionStack.delete(moduleId);
|
||||
|
||||
// Mark as fully visited
|
||||
this.visitedModules.add(moduleId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an unregistered dependency
|
||||
* @param {string} moduleId - Module ID
|
||||
* @param {string} depId - Unregistered dependency ID
|
||||
*/
|
||||
trackDependency(moduleId, depId) {
|
||||
if (!this.untrackedDependencies.has(moduleId)) {
|
||||
this.untrackedDependencies.set(moduleId, new Set());
|
||||
}
|
||||
this.untrackedDependencies.get(moduleId).add(depId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module by id
|
||||
* @param {string} id - Module id
|
||||
@@ -56,6 +158,33 @@ export class ModuleRegistry {
|
||||
return this.modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dependencies for a module
|
||||
* @param {string} id - Module id
|
||||
* @returns {Array<string>} - Array of dependencies
|
||||
*/
|
||||
getDependencies(id) {
|
||||
return this.moduleDependencies.get(id) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the dependency graph has any circular dependencies
|
||||
* @returns {Array<string>|null} - Array representing the circular dependency path, or null if none
|
||||
*/
|
||||
checkForCircularDependencies() {
|
||||
this.visitedModules.clear();
|
||||
|
||||
for (const moduleId in this.modules) {
|
||||
this.recursionStack.clear();
|
||||
const result = this.detectCircularDependency(moduleId);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a module to be ready (in FINISHED state)
|
||||
* @param {string} id - Module id to wait for
|
||||
@@ -92,3 +221,6 @@ export class ModuleRegistry {
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const moduleRegistry = new ModuleRegistry();
|
||||
|
||||
// Make registry accessible globally
|
||||
window.moduleRegistry = moduleRegistry;
|
||||
|
||||
+331
-210
@@ -11,10 +11,15 @@ class OptionsUIModule extends BaseModule {
|
||||
*/
|
||||
constructor() {
|
||||
super('options-ui', 'Options UI');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['persistence-manager', 'localization'];
|
||||
|
||||
this.persistenceManager = null;
|
||||
this.ttsPlayer = null;
|
||||
this.audioManager = null;
|
||||
this.ttsFactory = null;
|
||||
this.localization = null;
|
||||
this.modal = null;
|
||||
this.isOpen = false;
|
||||
|
||||
@@ -25,8 +30,19 @@ class OptionsUIModule extends BaseModule {
|
||||
backdrop: true
|
||||
};
|
||||
|
||||
// Elements reference
|
||||
this.elements = null;
|
||||
|
||||
// Bound event handlers for proper this context
|
||||
this.handleTtsSystemChanged = this.handleTtsSystemChanged.bind(this);
|
||||
this.bindMethods([
|
||||
'handleTtsSystemChanged',
|
||||
'loadPreferences',
|
||||
'populateTtsSystems',
|
||||
'populateVoices',
|
||||
'resetToDefaults',
|
||||
'saveAndClose',
|
||||
'applySettings'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,31 +63,27 @@ class OptionsUIModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle TTS system changes
|
||||
* @param {CustomEvent} event - The event containing TTS system change details
|
||||
*/
|
||||
handleTtsSystemChanged(event) {
|
||||
console.log("TTS system changed:", event.detail);
|
||||
|
||||
if (this.isOpen) {
|
||||
// Refresh the voices list if the options UI is currently open
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready
|
||||
* @returns {Promise<boolean>} - Resolves when dependencies are ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for the persistence manager if available
|
||||
this.persistenceManager = moduleRegistry.getModule('persistence-manager');
|
||||
this.ttsPlayer = moduleRegistry.getModule('tts');
|
||||
// Get required modules
|
||||
this.persistenceManager = this.getModule('persistence-manager');
|
||||
if (!this.persistenceManager) {
|
||||
console.warn("Options UI: Persistence Manager not found");
|
||||
}
|
||||
|
||||
this.localization = this.getModule('localization');
|
||||
if (!this.localization) {
|
||||
console.warn("Options UI: Localization module not found");
|
||||
}
|
||||
|
||||
// These dependencies are optional - UI will adapt if not available
|
||||
this.audioManager = moduleRegistry.getModule('audio-manager');
|
||||
this.ttsFactory = this.getModule('tts-factory');
|
||||
this.ttsPlayer = this.getModule('tts');
|
||||
this.audioManager = this.getModule('audio-manager');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -574,6 +586,273 @@ class OptionsUIModule extends BaseModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current preferences into UI
|
||||
*/
|
||||
loadPreferences() {
|
||||
if (!this.persistenceManager || !this.elements) return;
|
||||
|
||||
// Wait for dependencies
|
||||
this.waitForDependencies().then(() => {
|
||||
// Get current preferences
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
|
||||
// Animation speed
|
||||
if (this.elements.animationSpeed) {
|
||||
this.elements.animationSpeed.value = prefs.animation.speed;
|
||||
this.elements.animationSpeedValue.textContent = prefs.animation.speed;
|
||||
}
|
||||
|
||||
// TTS enabled
|
||||
if (this.elements.ttsEnabled) {
|
||||
this.elements.ttsEnabled.checked = prefs.tts.enabled;
|
||||
|
||||
// Show/hide TTS options based on enabled state
|
||||
const ttsOptionsContainer = document.querySelector('.tts-options-container');
|
||||
if (ttsOptionsContainer) {
|
||||
ttsOptionsContainer.style.display = prefs.tts.enabled ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// TTS system
|
||||
this.populateTtsSystems();
|
||||
|
||||
// TTS volume
|
||||
if (this.elements.ttsVolume) {
|
||||
this.elements.ttsVolume.value = prefs.tts.volume * 100;
|
||||
this.elements.ttsVolumeValue.textContent = Math.round(prefs.tts.volume * 100);
|
||||
}
|
||||
|
||||
// TTS rate
|
||||
if (this.elements.ttsRate) {
|
||||
this.elements.ttsRate.value = prefs.tts.rate * 100;
|
||||
this.elements.ttsRateValue.textContent = Math.round(prefs.tts.rate * 100);
|
||||
}
|
||||
|
||||
// Language selection
|
||||
if (this.elements.language && this.localization) {
|
||||
const currentLocale = this.localization.getLocale();
|
||||
const availableLocales = this.localization.getAvailableLocales();
|
||||
|
||||
// Clear existing options
|
||||
this.elements.language.innerHTML = '';
|
||||
|
||||
// Add options for each available locale
|
||||
availableLocales.forEach(locale => {
|
||||
const option = document.createElement('option');
|
||||
option.value = locale;
|
||||
option.textContent = this.localization.getLanguageName(locale);
|
||||
option.selected = locale === currentLocale;
|
||||
this.elements.language.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Audio volumes
|
||||
if (this.elements.masterVolume) {
|
||||
this.elements.masterVolume.value = prefs.audio.masterVolume * 100;
|
||||
this.elements.masterVolumeValue.textContent = Math.round(prefs.audio.masterVolume * 100);
|
||||
}
|
||||
|
||||
if (this.elements.musicVolume) {
|
||||
this.elements.musicVolume.value = prefs.audio.musicVolume * 100;
|
||||
this.elements.musicVolumeValue.textContent = Math.round(prefs.audio.musicVolume * 100);
|
||||
}
|
||||
|
||||
if (this.elements.sfxVolume) {
|
||||
this.elements.sfxVolume.value = prefs.audio.sfxVolume * 100;
|
||||
this.elements.sfxVolumeValue.textContent = Math.round(prefs.audio.sfxVolume * 100);
|
||||
}
|
||||
|
||||
// Accessibility options
|
||||
if (this.elements.highContrast) {
|
||||
this.elements.highContrast.checked = prefs.accessibility.highContrast;
|
||||
}
|
||||
|
||||
if (this.elements.largerText) {
|
||||
this.elements.largerText.checked = prefs.accessibility.largerText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate TTS systems dropdown
|
||||
*/
|
||||
populateTtsSystems() {
|
||||
if (!this.elements || !this.elements.ttsSystem) return;
|
||||
|
||||
// Clear existing options
|
||||
this.elements.ttsSystem.innerHTML = '';
|
||||
|
||||
// Get current TTS preferences
|
||||
const currentProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
|
||||
|
||||
// Get available handlers from TTS factory
|
||||
let availableHandlers = {};
|
||||
if (this.ttsFactory) {
|
||||
availableHandlers = this.ttsFactory.getAvailableHandlers();
|
||||
} else {
|
||||
// Fallback if TTS factory not available
|
||||
availableHandlers = {
|
||||
browser: true, // Assume browser TTS is available
|
||||
api: false, // Assume API TTS is not available
|
||||
kokoro: false // Assume Kokoro is not available
|
||||
};
|
||||
}
|
||||
|
||||
// Add option for each handler
|
||||
const handlers = [
|
||||
{ id: 'browser', name: 'Browser TTS', description: 'Uses your browser\'s built-in speech synthesis' },
|
||||
{ id: 'api', name: 'API TTS', description: 'Uses a remote API for higher quality voices' },
|
||||
{ id: 'kokoro', name: 'Kokoro TTS', description: 'Uses local AI-powered speech synthesis' }
|
||||
];
|
||||
|
||||
handlers.forEach(handler => {
|
||||
const option = document.createElement('option');
|
||||
option.value = handler.id;
|
||||
|
||||
// Check if handler is available
|
||||
const isAvailable = availableHandlers[handler.id] === true;
|
||||
|
||||
// Format option text
|
||||
option.textContent = `${handler.name}${isAvailable ? '' : ' (unavailable)'}`;
|
||||
option.title = handler.description;
|
||||
|
||||
// Disable option if handler is not available
|
||||
option.disabled = !isAvailable;
|
||||
|
||||
// Select if this is the current provider
|
||||
option.selected = handler.id === currentProvider;
|
||||
|
||||
this.elements.ttsSystem.appendChild(option);
|
||||
});
|
||||
|
||||
// Populate voices for the selected system
|
||||
this.populateVoices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate voices dropdown for current TTS system
|
||||
*/
|
||||
populateVoices() {
|
||||
if (!this.elements || !this.elements.ttsVoice) return;
|
||||
|
||||
// Clear existing options
|
||||
this.elements.ttsVoice.innerHTML = '';
|
||||
|
||||
// Get current preferences
|
||||
const currentVoice = this.persistenceManager.getPreference('tts', 'voice', '');
|
||||
const currentProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
|
||||
|
||||
// Get current locale
|
||||
const currentLocale = this.localization ? this.localization.getLocale() : 'en-us';
|
||||
|
||||
// Get voices from TTS factory
|
||||
let voices = [];
|
||||
if (this.ttsFactory) {
|
||||
// Get active handler
|
||||
const activeHandler = this.ttsFactory.getActiveHandler();
|
||||
if (activeHandler) {
|
||||
voices = activeHandler.getVoices();
|
||||
}
|
||||
}
|
||||
|
||||
// If no voices available, add a placeholder
|
||||
if (voices.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'No voices available';
|
||||
this.elements.ttsVoice.appendChild(option);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort voices by language and name
|
||||
voices.sort((a, b) => {
|
||||
// First sort by matching current locale
|
||||
const aMatchesLocale = a.lang && a.lang.toLowerCase().startsWith(currentLocale.split('-')[0]);
|
||||
const bMatchesLocale = b.lang && b.lang.toLowerCase().startsWith(currentLocale.split('-')[0]);
|
||||
|
||||
if (aMatchesLocale && !bMatchesLocale) return -1;
|
||||
if (!aMatchesLocale && bMatchesLocale) return 1;
|
||||
|
||||
// Then sort by language name
|
||||
const aLang = this.getLanguageNameFromCode(a.lang);
|
||||
const bLang = this.getLanguageNameFromCode(b.lang);
|
||||
|
||||
if (aLang !== bLang) {
|
||||
return aLang.localeCompare(bLang);
|
||||
}
|
||||
|
||||
// Finally sort by voice name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Group voices by language
|
||||
const voicesByLang = {};
|
||||
voices.forEach(voice => {
|
||||
const langCode = voice.lang || 'unknown';
|
||||
const langName = this.getLanguageNameFromCode(langCode);
|
||||
|
||||
if (!voicesByLang[langName]) {
|
||||
voicesByLang[langName] = [];
|
||||
}
|
||||
|
||||
voicesByLang[langName].push(voice);
|
||||
});
|
||||
|
||||
// Add voices grouped by language
|
||||
Object.keys(voicesByLang).sort().forEach(langName => {
|
||||
// Create optgroup for language
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = langName;
|
||||
|
||||
// Add voices for this language
|
||||
voicesByLang[langName].forEach(voice => {
|
||||
const option = document.createElement('option');
|
||||
option.value = voice.name || voice.id;
|
||||
option.textContent = voice.name;
|
||||
option.selected = voice.name === currentVoice || voice.id === currentVoice;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
|
||||
this.elements.ttsVoice.appendChild(optgroup);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language name from language code
|
||||
* @param {string} code - Language code (e.g., 'en', 'de')
|
||||
* @returns {string} - Language name
|
||||
*/
|
||||
getLanguageNameFromCode(code) {
|
||||
// Use localization module if available
|
||||
if (this.localization && typeof this.localization.getLanguageName === 'function') {
|
||||
return this.localization.getLanguageName(code);
|
||||
}
|
||||
|
||||
// Fallback language names
|
||||
const languageNames = {
|
||||
'en': 'English',
|
||||
'de': 'German',
|
||||
'fr': 'French',
|
||||
'es': 'Spanish',
|
||||
'it': 'Italian',
|
||||
'ja': 'Japanese',
|
||||
'ko': 'Korean',
|
||||
'zh': 'Chinese',
|
||||
'ru': 'Russian',
|
||||
'ar': 'Arabic',
|
||||
'hi': 'Hindi',
|
||||
'pt': 'Portuguese',
|
||||
'nl': 'Dutch',
|
||||
'pl': 'Polish',
|
||||
'sv': 'Swedish',
|
||||
'tr': 'Turkish',
|
||||
'uk': 'Ukrainian'
|
||||
};
|
||||
|
||||
return languageNames[code] || code.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the options UI
|
||||
*/
|
||||
@@ -616,190 +895,15 @@ class OptionsUIModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current preferences into UI
|
||||
* Handle TTS system changes
|
||||
* @param {CustomEvent} event - The event containing TTS system change details
|
||||
*/
|
||||
loadPreferences() {
|
||||
if (!this.persistenceManager || !this.elements) return;
|
||||
handleTtsSystemChanged(event) {
|
||||
console.log("TTS system changed:", event.detail);
|
||||
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
|
||||
// Animation speed
|
||||
const animSpeed = this.persistenceManager.getPreference('animation', 'speed', 50);
|
||||
this.elements.animSpeed.value = animSpeed;
|
||||
this.elements.animSpeedValue.textContent = `${animSpeed}%`;
|
||||
|
||||
// TTS settings
|
||||
const ttsEnabled = this.persistenceManager.getPreference('tts', 'enabled', false);
|
||||
const ttsProvider = this.persistenceManager.getPreference('tts', 'provider', 'browser');
|
||||
const ttsVoice = this.persistenceManager.getPreference('tts', 'voice', '');
|
||||
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
|
||||
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
|
||||
|
||||
// TTS rate slider
|
||||
this.elements.speechRate.value = Math.round(ttsRate * 100);
|
||||
this.elements.speechRateValue.textContent = `${ttsRate.toFixed(1)}x`;
|
||||
|
||||
// TTS volume slider
|
||||
this.elements.ttsVolume.value = Math.round(ttsVolume * 100);
|
||||
this.elements.ttsVolumeValue.textContent = `${Math.round(ttsVolume * 100)}%`;
|
||||
|
||||
// Audio volumes
|
||||
const masterVolume = this.persistenceManager.getPreference('audio', 'masterVolume', 1.0);
|
||||
const musicVolume = this.persistenceManager.getPreference('audio', 'musicVolume', 0.7);
|
||||
const sfxVolume = this.persistenceManager.getPreference('audio', 'sfxVolume', 1.0);
|
||||
|
||||
this.elements.masterVolume.value = Math.round(masterVolume * 100);
|
||||
this.elements.masterVolumeValue.textContent = `${Math.round(masterVolume * 100)}%`;
|
||||
|
||||
this.elements.musicVolume.value = Math.round(musicVolume * 100);
|
||||
this.elements.musicVolumeValue.textContent = `${Math.round(musicVolume * 100)}%`;
|
||||
|
||||
this.elements.sfxVolume.value = Math.round(sfxVolume * 100);
|
||||
this.elements.sfxVolumeValue.textContent = `${Math.round(sfxVolume * 100)}%`;
|
||||
|
||||
// Accessibility settings
|
||||
const highContrast = this.persistenceManager.getPreference('accessibility', 'highContrast', false);
|
||||
const largerText = this.persistenceManager.getPreference('accessibility', 'largerText', false);
|
||||
|
||||
this.elements.highContrast.checked = highContrast;
|
||||
this.elements.largerText.checked = largerText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate TTS systems dropdown
|
||||
*/
|
||||
populateTtsSystems() {
|
||||
if (!this.ttsPlayer || !this.elements) return;
|
||||
|
||||
const systems = this.ttsPlayer.getAvailableSystems();
|
||||
const select = this.elements.ttsSystem;
|
||||
|
||||
// Clear existing options and listeners
|
||||
select.innerHTML = '';
|
||||
const newSelect = select.cloneNode(false);
|
||||
select.parentNode.replaceChild(newSelect, select);
|
||||
this.elements.ttsSystem = newSelect;
|
||||
select = newSelect;
|
||||
|
||||
// Get current TTS info
|
||||
const currentInfo = this.ttsPlayer.getTTSInfo();
|
||||
const currentId = currentInfo.type || '';
|
||||
|
||||
// Create an option for each available system
|
||||
systems.forEach(id => {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
|
||||
switch (id) {
|
||||
case 'browser':
|
||||
option.textContent = 'Browser Built-in TTS';
|
||||
break;
|
||||
case 'kokoro':
|
||||
option.textContent = 'Kokoro Neural TTS';
|
||||
break;
|
||||
case 'api':
|
||||
option.textContent = 'API-based TTS';
|
||||
break;
|
||||
default:
|
||||
option.textContent = id.charAt(0).toUpperCase() + id.slice(1);
|
||||
}
|
||||
|
||||
if (id === currentId) {
|
||||
option.selected = true;
|
||||
}
|
||||
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Add change listener
|
||||
select.addEventListener('change', () => {
|
||||
const selectedSystem = select.value;
|
||||
if (this.ttsPlayer) {
|
||||
this.ttsPlayer.switchTTS(selectedSystem);
|
||||
|
||||
// Update persistence
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'provider', selectedSystem);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate voices dropdown for current TTS system
|
||||
*/
|
||||
async populateVoices() {
|
||||
if (!this.ttsPlayer || !this.elements || !this.ttsPlayer.getVoices) return;
|
||||
|
||||
try {
|
||||
const voices = await this.ttsPlayer.getVoices();
|
||||
const select = this.elements.voiceSelect;
|
||||
|
||||
// Clear existing options and listeners
|
||||
select.innerHTML = '';
|
||||
const newSelect = select.cloneNode(false);
|
||||
select.parentNode.replaceChild(newSelect, select);
|
||||
this.elements.voiceSelect = newSelect;
|
||||
select = newSelect;
|
||||
|
||||
if (!voices || voices.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'No voices available';
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
select.disabled = false;
|
||||
|
||||
// Get current preference
|
||||
let currentVoice = '';
|
||||
if (this.persistenceManager) {
|
||||
currentVoice = this.persistenceManager.getPreference('tts', 'voice', '');
|
||||
}
|
||||
|
||||
// Add voices to dropdown
|
||||
voices.forEach(voice => {
|
||||
const option = document.createElement('option');
|
||||
option.value = voice.id || voice.name;
|
||||
option.textContent = voice.name;
|
||||
|
||||
if (voice.id === currentVoice || voice.name === currentVoice) {
|
||||
option.selected = true;
|
||||
}
|
||||
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Add change listener
|
||||
select.addEventListener('change', () => {
|
||||
const selectedVoice = select.value;
|
||||
|
||||
// Update TTS
|
||||
if (this.ttsPlayer) {
|
||||
this.ttsPlayer.setVoice(selectedVoice);
|
||||
}
|
||||
|
||||
// Update persistence
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'voice', selectedVoice);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Voices populated for current TTS system. Selected: ${select.value}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error populating voices:", error);
|
||||
|
||||
const select = this.elements.voiceSelect;
|
||||
select.innerHTML = '';
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'Error loading voices';
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
if (this.isOpen) {
|
||||
// Refresh the voices list if the options UI is currently open
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,18 +964,35 @@ class OptionsUIModule extends BaseModule {
|
||||
const ttsVolume = this.persistenceManager.getPreference('tts', 'volume', 1.0);
|
||||
const ttsRate = this.persistenceManager.getPreference('tts', 'rate', 1.0);
|
||||
|
||||
if (this.ttsPlayer) {
|
||||
// Set TTS system
|
||||
if (ttsProvider) {
|
||||
this.ttsPlayer.switchTTS(ttsProvider);
|
||||
if (this.ttsFactory) {
|
||||
// Set TTS provider if it's available
|
||||
const availableHandlers = this.ttsFactory.getAvailableHandlers();
|
||||
if (ttsProvider && availableHandlers[ttsProvider]) {
|
||||
this.ttsFactory.setActiveHandler(ttsProvider);
|
||||
}
|
||||
|
||||
// Apply voice options
|
||||
this.ttsPlayer.setVoiceOptions({
|
||||
voice: ttsVoice,
|
||||
volume: ttsVolume,
|
||||
rate: ttsRate
|
||||
});
|
||||
// Get the active handler
|
||||
const activeHandler = this.ttsFactory.getActiveHandler();
|
||||
if (activeHandler) {
|
||||
// Set voice if specified
|
||||
if (ttsVoice) {
|
||||
activeHandler.setVoice(ttsVoice);
|
||||
}
|
||||
|
||||
// Set options
|
||||
activeHandler.setOptions({
|
||||
volume: ttsVolume,
|
||||
rate: ttsRate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply language settings
|
||||
if (this.localization && this.elements && this.elements.language) {
|
||||
const selectedLocale = this.elements.language.value;
|
||||
if (selectedLocale && selectedLocale !== this.localization.getLocale()) {
|
||||
this.localization.setLocale(selectedLocale);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply audio volume settings
|
||||
|
||||
+256
-64
@@ -1,108 +1,300 @@
|
||||
/**
|
||||
* ParagraphLayout Module
|
||||
* Interfaces with the Knuth-Plass line breaking algorithm to calculate optimal line breaks.
|
||||
* Paragraph Layout Module
|
||||
* Implements the Knuth and Plass line breaking algorithm for optimal typography
|
||||
* and connects it to the text rendering pipeline.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class ParagraphLayoutModule extends BaseModule {
|
||||
/**
|
||||
* Create a new ParagraphLayout
|
||||
*/
|
||||
constructor() {
|
||||
super('paragraph-layout', 'Paragraph Layout');
|
||||
this.kapAlgorithm = null;
|
||||
this.measureText = null;
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['text-processor'];
|
||||
|
||||
// Caching canvas context for text measurements
|
||||
this.textMeasureCtx = null;
|
||||
|
||||
// Configuration - use parent's config system
|
||||
this.updateConfig({
|
||||
maxLineWidth: 600,
|
||||
hyphenationEnabled: true,
|
||||
defaultFontSize: '1rem',
|
||||
defaultFontFamily: "'EB Garamond', serif",
|
||||
defaultLineHeight: 1.5,
|
||||
debugMode: false
|
||||
});
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'calculateLayout',
|
||||
'measureText',
|
||||
'setDebugMode',
|
||||
'updateFont',
|
||||
'processTextForLayout',
|
||||
'initializeTextMeasurement',
|
||||
'setupEventListeners',
|
||||
'loadLayoutDependencies'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
async initialize() {
|
||||
try {
|
||||
// First load linebreak.js if needed
|
||||
if (!window.linebreak) {
|
||||
await this.loadScript('/js/linebreak.js');
|
||||
this.reportProgress(40, "Linebreak algorithm loaded");
|
||||
this.reportProgress(20, "Initializing paragraph layout");
|
||||
|
||||
// Get text processor using parent's getModule method
|
||||
this.textProcessor = this.getModule('text-processor');
|
||||
|
||||
if (!this.textProcessor) {
|
||||
console.warn("Paragraph Layout: Text Processor not found, will use fallback processing");
|
||||
}
|
||||
|
||||
// Then load knuth-and-plass.js if needed
|
||||
if (!window.kap) {
|
||||
await this.loadScript('/js/knuth-and-plass.js');
|
||||
this.reportProgress(60, "KAP algorithm loaded");
|
||||
}
|
||||
// Load required dependencies
|
||||
await this.loadLayoutDependencies();
|
||||
|
||||
this.kapAlgorithm = window.kap;
|
||||
// Create off-screen canvas for text measurements
|
||||
this.initializeTextMeasurement();
|
||||
|
||||
// Set up event listeners for config changes
|
||||
this.setupEventListeners();
|
||||
|
||||
this.reportProgress(100, "Paragraph layout ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading paragraph layout dependencies:", error);
|
||||
console.error("Error initializing Paragraph Layout:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a script dynamically
|
||||
* @param {string} src - Script source URL
|
||||
* @returns {Promise} - Resolves when script is loaded
|
||||
* Load required dependencies for layout calculations
|
||||
*/
|
||||
loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
async loadLayoutDependencies() {
|
||||
try {
|
||||
this.reportProgress(30, "Loading layout dependencies");
|
||||
|
||||
// Load LinkedList.js first as it's required by linebreak.js
|
||||
await this.loadScript('/js/linked-list.js');
|
||||
|
||||
// Load linebreak.js which is required by knuth-and-plass.js
|
||||
await this.loadScript('/js/linebreak.js');
|
||||
|
||||
// Load knuth-and-plass.js which contains the kap function
|
||||
await this.loadScript('/js/knuth-and-plass.js');
|
||||
|
||||
this.reportProgress(50, "Layout dependencies loaded");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading layout dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize text measurement canvas
|
||||
*/
|
||||
initializeTextMeasurement() {
|
||||
// Create off-screen canvas for text measurements
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2000;
|
||||
canvas.height = 100;
|
||||
this.textMeasureCtx = canvas.getContext('2d');
|
||||
|
||||
// Set default font
|
||||
this.updateFont(this.config.defaultFontSize, this.config.defaultFontFamily);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Use parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'ui:font:change', (event) => {
|
||||
if (event.detail) {
|
||||
const { fontSize, fontFamily } = event.detail;
|
||||
if (fontSize || fontFamily) {
|
||||
this.updateFont(
|
||||
fontSize || this.config.defaultFontSize,
|
||||
fontFamily || this.config.defaultFontFamily
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Use parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'ui:typography:hyphenation', (event) => {
|
||||
if (event.detail && typeof event.detail.enabled === 'boolean') {
|
||||
// Use parent's updateConfig method
|
||||
this.updateConfig({ hyphenationEnabled: event.detail.enabled });
|
||||
console.log(`Paragraph Layout: Hyphenation ${this.config.hyphenationEnabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
* Update the font for text measurements
|
||||
* @param {string} fontSize - Font size (with units)
|
||||
* @param {string} fontFamily - Font family
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// The measureText function will be provided by the game controller later
|
||||
this.reportProgress(100, "Paragraph layout initialized");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing paragraph layout:", error);
|
||||
return false;
|
||||
updateFont(fontSize, fontFamily) {
|
||||
if (!this.textMeasureCtx) return;
|
||||
|
||||
// Store the font settings
|
||||
this.config.defaultFontSize = fontSize;
|
||||
this.config.defaultFontFamily = fontFamily;
|
||||
|
||||
// Set the font on the canvas context
|
||||
const fontString = `${fontSize} ${fontFamily}`;
|
||||
this.textMeasureCtx.font = fontString;
|
||||
|
||||
if (this.config.debugMode) {
|
||||
console.log(`Paragraph Layout: Font updated to ${fontString}`);
|
||||
|
||||
// Test measurement
|
||||
const testText = "The quick brown fox jumps over the lazy dog";
|
||||
const width = this.measureText(testText);
|
||||
console.log(`Paragraph Layout: Test text width: ${width}px`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate layout for a paragraph
|
||||
* @param {string} processedText - The pre-processed text (with SmartyPants and hyphenation)
|
||||
* @param {Array<number>} measures - Array of line width measurements
|
||||
* @param {boolean} hyphenate - Whether to enable hyphenation
|
||||
* @param {Function} [measureFunc] - Optional specific measurement function for this call
|
||||
* @returns {Object} Layout data with nodes and breaks
|
||||
* Measure text width using canvas context
|
||||
* @param {string} text - Text to measure
|
||||
* @returns {number} - Text width in pixels
|
||||
*/
|
||||
calculateLayout(processedText, measures, hyphenate = true, measureFunc = null) {
|
||||
const measure = measureFunc || this.measureText; // Use provided func or fallback to instance default
|
||||
if (typeof measure !== 'function') {
|
||||
throw new Error('No text measurement function available');
|
||||
measureText(text) {
|
||||
if (!this.textMeasureCtx) {
|
||||
this.initializeTextMeasurement();
|
||||
}
|
||||
|
||||
return this.kapAlgorithm(processedText, measure, measures, hyphenate);
|
||||
if (!text) return 0;
|
||||
|
||||
return this.textMeasureCtx.measureText(text).width;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set a new text measurement function
|
||||
* @param {Function} measureFunc - The new measurement function
|
||||
* Process text for layout (apply hyphenation and smartypants)
|
||||
* @param {string} text - Text to process
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
setMeasureFunction(measureFunc) {
|
||||
this.measureText = measureFunc;
|
||||
processTextForLayout(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove extra whitespace
|
||||
text = text.trim().replace(/\s+/g, ' ');
|
||||
|
||||
try {
|
||||
// Apply text processor transformations if available
|
||||
if (this.textProcessor) {
|
||||
// Apply smartypants for typography improvements
|
||||
if (this.textProcessor.applySmartypants) {
|
||||
text = this.textProcessor.applySmartypants(text);
|
||||
}
|
||||
|
||||
// Apply hyphenation if enabled
|
||||
if (this.config.hyphenationEnabled && this.textProcessor.hyphenateText) {
|
||||
text = this.textProcessor.hyphenateText(text);
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error("Error processing text for layout:", error);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set a new Knuth and Plass algorithm implementation
|
||||
* @param {Function} kapFunc - The new KAP algorithm function
|
||||
* Calculate layout for a paragraph using Knuth and Plass algorithm
|
||||
* @param {string} text - Text to layout
|
||||
* @param {Object} options - Layout options
|
||||
* @returns {Object} - Layout data with line breaks
|
||||
*/
|
||||
setKapAlgorithm(kapFunc) {
|
||||
this.kapAlgorithm = kapFunc;
|
||||
calculateLayout(text, options = {}) {
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
// Check if the kap function is available
|
||||
if (typeof window.kap !== 'function') {
|
||||
console.error("Paragraph Layout: kap function not available. Make sure knuth-and-plass.js is loaded.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Process text for layout (hyphenation, etc)
|
||||
const processedText = this.processTextForLayout(text);
|
||||
|
||||
// Prepare options by merging with defaults
|
||||
const layoutOptions = {
|
||||
width: options.width || this.config.maxLineWidth,
|
||||
fontSize: options.fontSize || this.config.defaultFontSize,
|
||||
fontFamily: options.fontFamily || this.config.defaultFontFamily,
|
||||
lineHeight: options.lineHeight || this.config.defaultLineHeight,
|
||||
tolerance: options.tolerance || 3, // Tolerance for line breaking algorithm
|
||||
demerits: options.demerits || {
|
||||
line: 10, // Demerits for each line break
|
||||
flagged: 100, // Demerits for flagged break points (like hyphens)
|
||||
fitness: 3000 // Demerits for consecutive lines with very different looseness/tightness
|
||||
}
|
||||
};
|
||||
|
||||
// Update font for measurement
|
||||
this.updateFont(layoutOptions.fontSize, layoutOptions.fontFamily);
|
||||
|
||||
// Create measure array - this is crucial for proper line breaking
|
||||
// The first value is the full width, subsequent values can be for indented lines
|
||||
const measure = [layoutOptions.width];
|
||||
|
||||
if (this.config.debugMode) {
|
||||
console.log("Paragraph Layout: Calculating layout for text", {
|
||||
text: processedText,
|
||||
measure,
|
||||
options: layoutOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Use the global Knuth and Plass algorithm function with proper parameters
|
||||
const layout = window.kap(
|
||||
processedText,
|
||||
this.measureText.bind(this),
|
||||
measure,
|
||||
this.config.hyphenationEnabled,
|
||||
layoutOptions.tolerance,
|
||||
layoutOptions.demerits
|
||||
);
|
||||
|
||||
// If layout failed, return null
|
||||
if (!layout || !layout.breaks || !layout.nodes) {
|
||||
console.warn("Paragraph Layout: Failed to calculate layout for text");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.config.debugMode) {
|
||||
console.log("Paragraph Layout: Layout calculated", {
|
||||
breaks: layout.breaks.length,
|
||||
nodes: layout.nodes.length
|
||||
});
|
||||
}
|
||||
|
||||
// Return layout data with original text for reference
|
||||
return {
|
||||
...layout,
|
||||
originalText: text,
|
||||
processedText: processedText,
|
||||
width: layoutOptions.width,
|
||||
lineHeight: layoutOptions.lineHeight
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error calculating layout:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set debug mode
|
||||
* @param {boolean} enabled - Whether debug mode should be enabled
|
||||
*/
|
||||
setDebugMode(enabled) {
|
||||
// Use parent's updateConfig method
|
||||
this.updateConfig({ debugMode: enabled });
|
||||
console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+408
-232
@@ -11,35 +11,71 @@ class PersistenceManagerModule extends BaseModule {
|
||||
*/
|
||||
constructor() {
|
||||
super('persistence-manager', 'Persistence Manager');
|
||||
this.storage = window.localStorage;
|
||||
this.stateKey = 'ai_fiction_state';
|
||||
this.prefsKey = 'ai_fiction_prefs';
|
||||
|
||||
// Storage keys
|
||||
this.keys = {
|
||||
gameState: 'ai-interactive-fiction-state',
|
||||
preferences: 'ai-interactive-fiction-preferences',
|
||||
saveSlots: 'ai-interactive-fiction-saves'
|
||||
};
|
||||
|
||||
// Current game state
|
||||
this.gameState = null;
|
||||
|
||||
// User preferences
|
||||
this.preferences = null;
|
||||
|
||||
// Save slots
|
||||
this.saveSlots = {};
|
||||
|
||||
// Default preferences
|
||||
this.defaultPreferences = {
|
||||
animation: {
|
||||
enabled: true,
|
||||
speed: 50 // 0-100 scale, 50 is default
|
||||
},
|
||||
tts: {
|
||||
enabled: false,
|
||||
provider: 'browser', // 'browser', 'kokoro', 'elevenlabs'
|
||||
provider: 'browser', // 'browser', 'api', 'kokoro'
|
||||
voice: '',
|
||||
volume: 1.0
|
||||
volume: 1.0,
|
||||
rate: 1.0,
|
||||
language: 'en-us' // Default language, will be updated during initialization
|
||||
},
|
||||
audio: {
|
||||
masterVolume: 1.0,
|
||||
musicVolume: 0.7,
|
||||
sfxVolume: 1.0
|
||||
},
|
||||
animation: {
|
||||
speed: 50, // 0-100 scale
|
||||
fastForwardKey: ' ' // Space key
|
||||
sfxVolume: 1.0,
|
||||
musicEnabled: true,
|
||||
sfxEnabled: true
|
||||
},
|
||||
accessibility: {
|
||||
highContrast: false,
|
||||
largerText: false
|
||||
},
|
||||
app: {
|
||||
locale: 'en-us',
|
||||
theme: 'default'
|
||||
}
|
||||
};
|
||||
|
||||
// Current preferences (will be loaded from storage)
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'saveGameState',
|
||||
'loadGameState',
|
||||
'savePreferences',
|
||||
'loadPreferences',
|
||||
'getPreference',
|
||||
'updatePreference',
|
||||
'resetPreferences',
|
||||
'createSaveSlot',
|
||||
'loadSaveSlot',
|
||||
'deleteSaveSlot',
|
||||
'getAllSaveSlots'
|
||||
]);
|
||||
|
||||
// Add localization as a dependency
|
||||
this.dependencies = ['localization'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,224 +84,186 @@ class PersistenceManagerModule extends BaseModule {
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Test storage availability
|
||||
this.storage = this.getStorageObject();
|
||||
this.reportProgress(10, "Initializing persistence manager");
|
||||
|
||||
// Load preferences automatically
|
||||
// Load preferences first (with default language settings)
|
||||
this.loadPreferences();
|
||||
|
||||
// Load save slots
|
||||
this.loadSaveSlots();
|
||||
|
||||
// Get localization module
|
||||
const localization = this.getModule('localization');
|
||||
if (localization) {
|
||||
// Update language preferences with current language
|
||||
const language = localization.getLanguage();
|
||||
|
||||
// Update default preferences
|
||||
this.defaultPreferences.tts.language = language;
|
||||
this.defaultPreferences.app.locale = language;
|
||||
|
||||
// Update current preferences if they exist
|
||||
if (this.preferences) {
|
||||
// Only update if not already set by user
|
||||
if (!this.preferences.tts.language || this.preferences.tts.language === 'en-us') {
|
||||
this.preferences.tts.language = language;
|
||||
}
|
||||
|
||||
if (!this.preferences.app.locale || this.preferences.app.locale === 'en-us') {
|
||||
this.preferences.app.locale = language;
|
||||
}
|
||||
|
||||
// Save updated preferences
|
||||
this.savePreferences();
|
||||
}
|
||||
|
||||
this.reportProgress(80, "Updated language preferences");
|
||||
} else {
|
||||
console.warn("Localization module not found or not ready, using default language settings");
|
||||
// We'll continue without localization - it might initialize later
|
||||
this.reportProgress(80, "Using default language settings");
|
||||
}
|
||||
|
||||
this.reportProgress(100, "Persistence manager ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing persistence manager:", error);
|
||||
// Continue without persistence rather than failing
|
||||
return true;
|
||||
this.reportProgress(100, "Persistence manager failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate storage object, testing availability
|
||||
* @returns {Storage} - The storage object to use
|
||||
*/
|
||||
getStorageObject() {
|
||||
try {
|
||||
// Test if localStorage is available
|
||||
if (window.localStorage) {
|
||||
const testKey = '__storage_test__';
|
||||
window.localStorage.setItem(testKey, testKey);
|
||||
window.localStorage.removeItem(testKey);
|
||||
return window.localStorage;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('localStorage not available, using memory storage');
|
||||
// Create a memory-based storage fallback
|
||||
return this.createMemoryStorage();
|
||||
}
|
||||
|
||||
console.warn('localStorage not available, using memory storage');
|
||||
return this.createMemoryStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a memory-based storage fallback
|
||||
* @returns {Object} - A storage-like object
|
||||
*/
|
||||
createMemoryStorage() {
|
||||
const memoryStore = {};
|
||||
|
||||
return {
|
||||
getItem: (key) => memoryStore[key] || null,
|
||||
setItem: (key, value) => {
|
||||
memoryStore[key] = String(value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
delete memoryStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(memoryStore).forEach(key => {
|
||||
delete memoryStore[key];
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current game state
|
||||
* @param {Object} state - The game state to save
|
||||
* @param {Object} state - Game state to save
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
saveState(state) {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, game state not saved.');
|
||||
return false;
|
||||
}
|
||||
saveGameState(state) {
|
||||
if (!state) return false;
|
||||
|
||||
try {
|
||||
const stateString = JSON.stringify(state);
|
||||
this.storage.setItem(this.stateKey, stateString);
|
||||
console.log('Game state saved successfully.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving game state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the saved game state
|
||||
* @returns {Object|null} The loaded state or null if no state exists
|
||||
*/
|
||||
loadState() {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, cannot load game state.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stateString = this.storage.getItem(this.stateKey);
|
||||
if (!stateString) {
|
||||
console.info('No saved game state found.');
|
||||
return null;
|
||||
}
|
||||
this.gameState = state;
|
||||
localStorage.setItem(this.keys.gameState, JSON.stringify(state));
|
||||
|
||||
const state = JSON.parse(stateString);
|
||||
console.log('Game state loaded successfully.');
|
||||
return state;
|
||||
// Dispatch event
|
||||
this.dispatchEvent('game-state-saved', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error loading game state:', error);
|
||||
return null;
|
||||
console.error("Error saving game state:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a saved game state exists
|
||||
* @returns {boolean} Whether a saved state exists
|
||||
* Load the current game state
|
||||
* @returns {Object|null} - Loaded game state or null if not found
|
||||
*/
|
||||
hasSavedState() {
|
||||
if (!this.storage) return false;
|
||||
return !!this.storage.getItem(this.stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the saved game state
|
||||
* @returns {boolean} Whether the state was successfully deleted
|
||||
*/
|
||||
clearState() {
|
||||
if (!this.storage) return false;
|
||||
loadGameState() {
|
||||
try {
|
||||
this.storage.removeItem(this.stateKey);
|
||||
console.log('Game state cleared.');
|
||||
return true;
|
||||
const stateJson = localStorage.getItem(this.keys.gameState);
|
||||
if (!stateJson) return null;
|
||||
|
||||
this.gameState = JSON.parse(stateJson);
|
||||
return this.gameState;
|
||||
} catch (error) {
|
||||
console.error('Error clearing game state:', error);
|
||||
return false;
|
||||
console.error("Error loading game state:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user preferences
|
||||
* @param {Object} [preferences] - Preferences to save (defaults to current preferences)
|
||||
* @returns {boolean} Whether preferences were successfully saved
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
savePreferences(preferences = null) {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, preferences not saved.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use provided preferences or current preferences
|
||||
const prefsToSave = preferences || this.preferences;
|
||||
|
||||
savePreferences() {
|
||||
try {
|
||||
const prefsString = JSON.stringify(prefsToSave);
|
||||
this.storage.setItem(this.prefsKey, prefsString);
|
||||
console.log('Preferences saved successfully.');
|
||||
localStorage.setItem(this.keys.preferences, JSON.stringify(this.preferences));
|
||||
|
||||
// Update current preferences
|
||||
if (preferences) {
|
||||
this.preferences = { ...this.preferences, ...preferences };
|
||||
}
|
||||
// Dispatch event
|
||||
this.dispatchEvent('preferences-saved', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving preferences:', error);
|
||||
console.error("Error saving preferences:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user preferences
|
||||
* @returns {Object} The loaded preferences or default preferences if none exist
|
||||
* @returns {Object} - Loaded preferences or default preferences if not found
|
||||
*/
|
||||
loadPreferences() {
|
||||
if (!this.storage) {
|
||||
console.warn('No storage available, using default preferences.');
|
||||
return { ...this.defaultPreferences };
|
||||
}
|
||||
|
||||
try {
|
||||
const prefsString = this.storage.getItem(this.prefsKey);
|
||||
if (!prefsString) {
|
||||
console.info('No saved preferences found, using defaults.');
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
return this.preferences;
|
||||
const prefsJson = localStorage.getItem(this.keys.preferences);
|
||||
|
||||
if (prefsJson) {
|
||||
// Parse stored preferences
|
||||
const storedPrefs = JSON.parse(prefsJson);
|
||||
|
||||
// Merge with default preferences to ensure all keys exist
|
||||
this.preferences = this.mergeWithDefaults(storedPrefs, this.defaultPreferences);
|
||||
} else {
|
||||
// Use default preferences if none found
|
||||
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||
|
||||
// Try to set locale based on browser language
|
||||
const browserLocale = navigator.language.toLowerCase();
|
||||
if (browserLocale) {
|
||||
this.preferences.app.locale = browserLocale;
|
||||
}
|
||||
}
|
||||
|
||||
const loadedPrefs = JSON.parse(prefsString);
|
||||
|
||||
// Merge with default preferences to ensure all fields exist
|
||||
this.preferences = this.mergeWithDefaults(loadedPrefs, this.defaultPreferences);
|
||||
|
||||
console.log('Preferences loaded successfully.');
|
||||
return this.preferences;
|
||||
} catch (error) {
|
||||
console.error('Error loading preferences:', error);
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
console.error("Error loading preferences:", error);
|
||||
|
||||
// Fall back to default preferences
|
||||
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||
return this.preferences;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge loaded preferences with default values to ensure all fields exist
|
||||
* @param {Object} loaded - The loaded preferences
|
||||
* @param {Object} defaults - The default preferences
|
||||
* @returns {Object} Merged preferences
|
||||
* @private
|
||||
* Merge stored preferences with defaults to ensure all keys exist
|
||||
* @param {Object} stored - Stored preferences
|
||||
* @param {Object} defaults - Default preferences
|
||||
* @returns {Object} - Merged preferences
|
||||
*/
|
||||
mergeWithDefaults(loaded, defaults) {
|
||||
mergeWithDefaults(stored, defaults) {
|
||||
const result = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const key in defaults) {
|
||||
if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
|
||||
// Recurse for nested objects
|
||||
if (loaded && loaded[key]) {
|
||||
result[key] = this.mergeWithDefaults(loaded[key], defaults[key]);
|
||||
} else {
|
||||
result[key] = { ...defaults[key] };
|
||||
// For each category in defaults
|
||||
for (const category in defaults) {
|
||||
result[category] = {};
|
||||
|
||||
// Copy all settings from defaults for this category
|
||||
for (const setting in defaults[category]) {
|
||||
// Use stored value if it exists, otherwise use default
|
||||
result[category][setting] = (stored[category] && stored[category][setting] !== undefined)
|
||||
? stored[category][setting]
|
||||
: defaults[category][setting];
|
||||
}
|
||||
|
||||
// Copy any additional settings from stored that aren't in defaults
|
||||
if (stored[category]) {
|
||||
for (const setting in stored[category]) {
|
||||
if (result[category][setting] === undefined) {
|
||||
result[category][setting] = stored[category][setting];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use loaded value if available, otherwise default
|
||||
result[key] = (loaded && loaded[key] !== undefined) ? loaded[key] : defaults[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Copy any additional categories from stored that aren't in defaults
|
||||
for (const category in stored) {
|
||||
if (result[category] === undefined) {
|
||||
result[category] = stored[category];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,81 +271,262 @@ class PersistenceManagerModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific preferences
|
||||
* @param {string} category - The preference category (e.g., 'tts', 'audio')
|
||||
* @param {string} setting - The specific setting name
|
||||
* @param {any} value - The new value
|
||||
* @param {boolean} [saveImmediately=true] - Whether to save immediately
|
||||
*/
|
||||
updatePreference(category, setting, value, saveImmediately = true) {
|
||||
// Ensure the category exists
|
||||
if (!this.preferences[category]) {
|
||||
console.warn(`Preference category '${category}' doesn't exist.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the preference
|
||||
this.preferences[category][setting] = value;
|
||||
|
||||
// Save if requested
|
||||
if (saveImmediately) {
|
||||
return this.savePreferences();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific preference value
|
||||
* @param {string} category - The preference category
|
||||
* @param {string} setting - The specific setting name
|
||||
* @param {any} [defaultValue] - Default value if the preference doesn't exist
|
||||
* @returns {any} The preference value
|
||||
* Get a specific preference
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} setting - Preference setting
|
||||
* @param {*} defaultValue - Default value if preference not found
|
||||
* @returns {*} - Preference value
|
||||
*/
|
||||
getPreference(category, setting, defaultValue = null) {
|
||||
// Check if category exists
|
||||
if (!this.preferences[category]) {
|
||||
return defaultValue;
|
||||
if (!this.preferences) {
|
||||
this.loadPreferences();
|
||||
}
|
||||
|
||||
// Check if setting exists in category
|
||||
if (this.preferences[category].hasOwnProperty(setting)) {
|
||||
if (this.preferences[category] && this.preferences[category][setting] !== undefined) {
|
||||
return this.preferences[category][setting];
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
// If default value provided, use it
|
||||
if (defaultValue !== null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Otherwise check default preferences
|
||||
if (this.defaultPreferences[category] && this.defaultPreferences[category][setting] !== undefined) {
|
||||
return this.defaultPreferences[category][setting];
|
||||
}
|
||||
|
||||
// If all else fails, return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific preference
|
||||
* @param {string} category - Preference category
|
||||
* @param {string} setting - Preference setting
|
||||
* @param {*} value - New value
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
updatePreference(category, setting, value) {
|
||||
if (!this.preferences) {
|
||||
this.loadPreferences();
|
||||
}
|
||||
|
||||
// Create category if it doesn't exist
|
||||
if (!this.preferences[category]) {
|
||||
this.preferences[category] = {};
|
||||
}
|
||||
|
||||
// Update preference
|
||||
const oldValue = this.preferences[category][setting];
|
||||
this.preferences[category][setting] = value;
|
||||
|
||||
// Save preferences
|
||||
this.savePreferences();
|
||||
|
||||
// Dispatch event if value changed
|
||||
if (oldValue !== value) {
|
||||
this.dispatchEvent('preference-changed', {
|
||||
category,
|
||||
setting,
|
||||
value,
|
||||
oldValue
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset preferences to defaults
|
||||
* @param {string} [category] - Optional category to reset (resets all if not specified)
|
||||
* @param {boolean} [saveImmediately=true] - Whether to save immediately
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
resetPreferences(category = null, saveImmediately = true) {
|
||||
if (category) {
|
||||
// Reset only specified category
|
||||
if (this.defaultPreferences[category]) {
|
||||
this.preferences[category] = { ...this.defaultPreferences[category] };
|
||||
}
|
||||
} else {
|
||||
// Reset all preferences
|
||||
this.preferences = { ...this.defaultPreferences };
|
||||
resetPreferences() {
|
||||
try {
|
||||
// Clone default preferences
|
||||
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||
|
||||
// Save preferences
|
||||
this.savePreferences();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchEvent('preferences-reset', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error resetting preferences:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save if requested
|
||||
if (saveImmediately) {
|
||||
return this.savePreferences();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences
|
||||
* @returns {Object} The current preferences
|
||||
* @returns {Object} - All preferences
|
||||
*/
|
||||
getAllPreferences() {
|
||||
return { ...this.preferences };
|
||||
if (!this.preferences) {
|
||||
this.loadPreferences();
|
||||
}
|
||||
|
||||
return this.preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load save slots
|
||||
* @returns {Object} - Save slots
|
||||
*/
|
||||
loadSaveSlots() {
|
||||
try {
|
||||
const slotsJson = localStorage.getItem(this.keys.saveSlots);
|
||||
|
||||
if (slotsJson) {
|
||||
this.saveSlots = JSON.parse(slotsJson);
|
||||
} else {
|
||||
this.saveSlots = {};
|
||||
}
|
||||
|
||||
return this.saveSlots;
|
||||
} catch (error) {
|
||||
console.error("Error loading save slots:", error);
|
||||
this.saveSlots = {};
|
||||
return this.saveSlots;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save save slots
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
saveSaveSlots() {
|
||||
try {
|
||||
localStorage.setItem(this.keys.saveSlots, JSON.stringify(this.saveSlots));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving save slots:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new save slot
|
||||
* @param {string} name - Save slot name
|
||||
* @param {Object} state - Game state to save
|
||||
* @returns {string|null} - Save slot ID or null if failed
|
||||
*/
|
||||
createSaveSlot(name, state) {
|
||||
if (!name || !state) return null;
|
||||
|
||||
try {
|
||||
// Generate unique ID
|
||||
const id = `save_${Date.now()}`;
|
||||
|
||||
// Create save slot
|
||||
this.saveSlots[id] = {
|
||||
id,
|
||||
name,
|
||||
timestamp: new Date().toISOString(),
|
||||
state
|
||||
};
|
||||
|
||||
// Save save slots
|
||||
this.saveSaveSlots();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchEvent('save-slot-created', {
|
||||
id,
|
||||
name,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error("Error creating save slot:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a save slot
|
||||
* @param {string} id - Save slot ID
|
||||
* @returns {Object|null} - Game state or null if not found
|
||||
*/
|
||||
loadSaveSlot(id) {
|
||||
if (!id || !this.saveSlots[id]) return null;
|
||||
|
||||
try {
|
||||
const saveSlot = this.saveSlots[id];
|
||||
|
||||
// Set as current game state
|
||||
this.gameState = saveSlot.state;
|
||||
|
||||
// Save current game state
|
||||
this.saveGameState(this.gameState);
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchEvent('save-slot-loaded', {
|
||||
id,
|
||||
name: saveSlot.name,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return this.gameState;
|
||||
} catch (error) {
|
||||
console.error("Error loading save slot:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a save slot
|
||||
* @param {string} id - Save slot ID
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
deleteSaveSlot(id) {
|
||||
if (!id || !this.saveSlots[id]) return false;
|
||||
|
||||
try {
|
||||
// Get save slot name before deleting
|
||||
const name = this.saveSlots[id].name;
|
||||
|
||||
// Delete save slot
|
||||
delete this.saveSlots[id];
|
||||
|
||||
// Save save slots
|
||||
this.saveSaveSlots();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchEvent('save-slot-deleted', {
|
||||
id,
|
||||
name,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting save slot:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all save slots
|
||||
* @returns {Object} - All save slots
|
||||
*/
|
||||
getAllSaveSlots() {
|
||||
if (!this.saveSlots) {
|
||||
this.loadSaveSlots();
|
||||
}
|
||||
|
||||
return this.saveSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when module is disposed
|
||||
*/
|
||||
dispose() {
|
||||
// Nothing to clean up
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +538,3 @@ moduleRegistry.register(PersistenceManager);
|
||||
|
||||
// Export the module
|
||||
export { PersistenceManager };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.PersistenceManager = PersistenceManager;
|
||||
|
||||
+45
-74
@@ -8,38 +8,38 @@ import { moduleRegistry } from './module-registry.js';
|
||||
class SocketClientModule extends BaseModule {
|
||||
constructor() {
|
||||
super('socket-client', 'Socket Client');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['text-buffer'];
|
||||
|
||||
this.socket = null;
|
||||
this.textBuffer = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 2000; // 2 seconds
|
||||
this.reconnectDelay = 2000;
|
||||
this.url = null;
|
||||
this.eventListeners = {};
|
||||
this.defaultHost = 'localhost:3000'; // Default to localhost:3000 if not running in same origin
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
// We depend on the text-buffer module
|
||||
this.reportProgress(30, "Waiting for text buffer");
|
||||
|
||||
// Dynamically load Socket.IO client if not already loaded
|
||||
if (!window.io) {
|
||||
this.reportProgress(40, "Loading Socket.IO client");
|
||||
await this.loadSocketIO();
|
||||
this.reportProgress(45, "Socket.IO client loaded");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading Socket Client dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
this.defaultHost = 'localhost:3000';
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'connect',
|
||||
'disconnect',
|
||||
'send',
|
||||
'sendCommand',
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
'on',
|
||||
'off',
|
||||
'emitEvent',
|
||||
'setupGameEventHandlers',
|
||||
'processTextFragment',
|
||||
'attemptReconnect',
|
||||
'getConnectionStatus',
|
||||
'loadSocketIO'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,54 +47,8 @@ class SocketClientModule extends BaseModule {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
loadSocketIO() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if Socket.IO is already loaded
|
||||
if (typeof window.io !== 'undefined') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the Socket.IO client from the same server that served this page
|
||||
const script = document.createElement('script');
|
||||
script.src = '/socket.io/socket.io.js'; // Socket.IO automatically serves this
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
if (typeof window.io !== 'undefined') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Failed to load Socket.IO client'));
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load Socket.IO client script'));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for the text buffer module to be available
|
||||
const textBufferReady = await moduleRegistry.waitForModule('text-buffer', 10000);
|
||||
|
||||
if (textBufferReady) {
|
||||
this.textBuffer = window.TextBuffer;
|
||||
this.reportProgress(60, "Text buffer module ready");
|
||||
return true;
|
||||
} else {
|
||||
console.warn("Text buffer module not ready, Socket Client will have limited functionality");
|
||||
return true; // Continue anyway for graceful degradation
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error waiting for dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
// Use parent's loadScript method
|
||||
return this.loadScript('/socket.io/socket.io.js');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,8 +57,25 @@ class SocketClientModule extends BaseModule {
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(10, "Initializing Socket Client");
|
||||
|
||||
// Dynamically load Socket.IO client if not already loaded
|
||||
if (!window.io) {
|
||||
this.reportProgress(20, "Loading Socket.IO client");
|
||||
await this.loadSocketIO();
|
||||
this.reportProgress(30, "Socket.IO client loaded");
|
||||
}
|
||||
|
||||
// Get text buffer using parent's getModule method
|
||||
this.textBuffer = this.getModule('text-buffer');
|
||||
if (!this.textBuffer) {
|
||||
console.error("Socket Client: Failed to get text-buffer module");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.reportProgress(50, "Setting up connection parameters");
|
||||
|
||||
// Use the current origin for the socket connection
|
||||
// This automatically handles the Docker port mapping situation
|
||||
const currentUrl = window.location.origin;
|
||||
|
||||
console.log(`Socket Client: Using origin for connection: ${currentUrl}`);
|
||||
|
||||
+125
-13
@@ -9,9 +9,23 @@ class TextBufferModule extends BaseModule {
|
||||
constructor() {
|
||||
super('text-buffer', 'Text Buffer');
|
||||
this.buffer = '';
|
||||
this.sentenceEndRegex = /[.!?]\s+/g; // Detect sentence endings
|
||||
this.onSentenceReadyCallback = null; // Callback for complete sentences
|
||||
this.processingLock = false; // Lock to prevent concurrent processing
|
||||
this.sentenceEndRegex = /[.!?]\s+/g;
|
||||
this.onSentenceReadyCallback = null;
|
||||
this.processingLock = false;
|
||||
this.processingQueue = [];
|
||||
this.isProcessingActive = false;
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'addText',
|
||||
'processNextFromQueue',
|
||||
'processSentences',
|
||||
'processNextSentence',
|
||||
'clear',
|
||||
'getBuffer',
|
||||
'getStatus',
|
||||
'setOnSentenceReady'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +34,22 @@ class TextBufferModule extends BaseModule {
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Use parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'ui:paragraph:complete', (event) => {
|
||||
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
|
||||
console.log('TextBuffer: Previous paragraph complete, processing next from queue');
|
||||
this.processNextFromQueue();
|
||||
}
|
||||
});
|
||||
|
||||
// Use parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'animation:complete', () => {
|
||||
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
|
||||
console.log('TextBuffer: Animations complete, processing next from queue');
|
||||
this.processNextFromQueue();
|
||||
}
|
||||
});
|
||||
|
||||
this.reportProgress(100, "Text buffer ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -36,6 +66,11 @@ class TextBufferModule extends BaseModule {
|
||||
if (typeof callback === 'function') {
|
||||
this.onSentenceReadyCallback = callback;
|
||||
console.log("Text Buffer: Sentence ready callback set");
|
||||
|
||||
// Process any queued text immediately
|
||||
if (this.processingQueue.length > 0 && !this.isProcessingActive) {
|
||||
this.processNextFromQueue();
|
||||
}
|
||||
} else {
|
||||
console.warn("Text Buffer: Invalid sentence ready callback provided");
|
||||
}
|
||||
@@ -50,6 +85,30 @@ class TextBufferModule extends BaseModule {
|
||||
|
||||
console.log(`TextBuffer: Adding text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Add to processing queue instead of directly to buffer
|
||||
this.processingQueue.push(text);
|
||||
|
||||
// Process the queue if not already processing
|
||||
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
|
||||
this.processNextFromQueue();
|
||||
} else {
|
||||
console.log('TextBuffer: Text queued for processing');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the next text fragment from the queue
|
||||
*/
|
||||
processNextFromQueue() {
|
||||
if (this.processingQueue.length === 0 || this.isProcessingActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingActive = true;
|
||||
const text = this.processingQueue.shift();
|
||||
|
||||
console.log(`TextBuffer: Processing next fragment from queue, remaining: ${this.processingQueue.length}`);
|
||||
|
||||
// Add text to buffer
|
||||
this.buffer += text;
|
||||
|
||||
@@ -89,14 +148,22 @@ class TextBufferModule extends BaseModule {
|
||||
if (!foundSentence) {
|
||||
// No complete sentences yet
|
||||
this.processingLock = false;
|
||||
this.isProcessingActive = false;
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
super.dispatchEvent('buffer:waiting', {
|
||||
remainingText: this.buffer,
|
||||
queueLength: this.processingQueue.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each complete sentence
|
||||
// Process the next complete sentence
|
||||
this.processNextSentence();
|
||||
} catch (error) {
|
||||
console.error("Error processing sentences:", error);
|
||||
this.processingLock = false;
|
||||
this.isProcessingActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +188,7 @@ class TextBufferModule extends BaseModule {
|
||||
if (endIndex === -1) {
|
||||
// No complete sentence found
|
||||
this.processingLock = false;
|
||||
this.isProcessingActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,24 +199,52 @@ class TextBufferModule extends BaseModule {
|
||||
|
||||
console.log(`TextBuffer: Processing sentence: "${sentence.trim()}"`);
|
||||
|
||||
// Use parent's dispatchEvent method
|
||||
super.dispatchEvent('buffer:sentence', {
|
||||
sentence: sentence,
|
||||
remaining: this.buffer.length
|
||||
});
|
||||
|
||||
// Call the callback if set
|
||||
if (this.onSentenceReadyCallback) {
|
||||
this.onSentenceReadyCallback(sentence, () => {
|
||||
// After processing is complete, check for more sentences
|
||||
setTimeout(() => {
|
||||
if (this.buffer.length > 0) {
|
||||
this.processingLock = false; // Release lock immediately to allow processing of next sentence
|
||||
|
||||
// Check if there are more sentences to process
|
||||
if (this.buffer.length > 0) {
|
||||
// Use requestAnimationFrame to prevent stack overflow and ensure UI update between sentences
|
||||
requestAnimationFrame(() => {
|
||||
this.processSentences();
|
||||
} else {
|
||||
this.processingLock = false;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
} else if (this.processingQueue.length > 0) {
|
||||
// No more sentences in buffer but we have more text in the queue
|
||||
requestAnimationFrame(() => {
|
||||
this.isProcessingActive = false;
|
||||
this.processNextFromQueue();
|
||||
});
|
||||
} else {
|
||||
// All processed
|
||||
this.isProcessingActive = false;
|
||||
super.dispatchEvent('buffer:empty', {});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No callback set, just process the next sentence
|
||||
// No callback set, just release lock and continue processing
|
||||
this.processingLock = false;
|
||||
|
||||
if (this.buffer.length > 0) {
|
||||
this.processSentences();
|
||||
// Use requestAnimationFrame instead of setTimeout for better performance
|
||||
requestAnimationFrame(() => {
|
||||
this.processSentences();
|
||||
});
|
||||
} else if (this.processingQueue.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
this.isProcessingActive = false;
|
||||
this.processNextFromQueue();
|
||||
});
|
||||
} else {
|
||||
this.processingLock = false;
|
||||
this.isProcessingActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +254,9 @@ class TextBufferModule extends BaseModule {
|
||||
*/
|
||||
clear() {
|
||||
this.buffer = '';
|
||||
this.processingQueue = [];
|
||||
this.isProcessingActive = false;
|
||||
this.processingLock = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +266,19 @@ class TextBufferModule extends BaseModule {
|
||||
getBuffer() {
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current processing status
|
||||
* @returns {Object} - Processing status object
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
bufferLength: this.buffer.length,
|
||||
queueLength: this.processingQueue.length,
|
||||
isProcessing: this.isProcessingActive,
|
||||
isLocked: this.processingLock
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
|
||||
+228
-220
@@ -4,40 +4,118 @@
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import Hyphenopoly from './hyphenopoly.module.js';
|
||||
|
||||
class TextProcessorModule extends BaseModule {
|
||||
constructor() {
|
||||
super('text-processor', 'Text Processor');
|
||||
this.smartyPants = null; // Store the function reference here
|
||||
this.smartypantsu = null; // Store the function reference here
|
||||
this.hyphenator = null; // For hyphenation function
|
||||
this.smartyPants = null;
|
||||
this.smartypantsu = null;
|
||||
this.hyphenator = null;
|
||||
this.hyphenatorReady = false;
|
||||
this.locale = 'en-us';
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'loadSmartyPantsScript',
|
||||
'initializeHyphenation',
|
||||
'process',
|
||||
'isHyphenationAvailable',
|
||||
'hyphenate',
|
||||
'setLocale',
|
||||
'handleLocaleChanged'
|
||||
]);
|
||||
|
||||
// Add localization as a dependency
|
||||
this.dependencies = ['localization'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadDependencies() {
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(10, "Loading dependencies");
|
||||
this.reportProgress(10, "Initializing text processor");
|
||||
|
||||
// Load SmartyPants script dynamically
|
||||
await this.loadSmartyPantsScript();
|
||||
this.reportProgress(50, "SmartyPants loaded");
|
||||
// Get locale from Localization module if available
|
||||
const localizationModule = this.getModule('localization');
|
||||
if (!localizationModule) {
|
||||
console.error("Localization module not found, required dependency missing");
|
||||
this.reportProgress(100, "Text processor initialization failed - missing localization");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.locale = localizationModule.getLocale();
|
||||
|
||||
// Register for locale changes using the proper event pattern
|
||||
this.addEventListener(document, 'locale-changed', this.handleLocaleChanged);
|
||||
|
||||
this.reportProgress(30, `Locale set to ${this.locale}`);
|
||||
|
||||
// Initialize hyphenation in the background, but don't wait for it
|
||||
this.initializeHyphenation();
|
||||
|
||||
this.reportProgress(90, "Dependencies loaded");
|
||||
return true;
|
||||
// Ensure global locale is set for SmartyPants
|
||||
window.locale = this.locale;
|
||||
|
||||
// Load SmartyPants - critical dependency
|
||||
this.reportProgress(40, "Loading SmartyPants");
|
||||
try {
|
||||
await this.loadSmartyPantsScript();
|
||||
|
||||
// Verify SmartyPants is properly loaded
|
||||
if (!this.smartyPants || typeof this.smartyPants !== 'function') {
|
||||
throw new Error('SmartyPants not properly loaded');
|
||||
}
|
||||
|
||||
this.reportProgress(70, "SmartyPants loaded successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to load SmartyPants:", error);
|
||||
this.reportProgress(100, "Text processor initialization failed - SmartyPants not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize hyphenation (non-critical)
|
||||
this.reportProgress(80, "Initializing hyphenation");
|
||||
try {
|
||||
await this.initializeHyphenation();
|
||||
this.reportProgress(100, "Text processor ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Failed to initialize hyphenation:", error);
|
||||
// Continue without hyphenation, still mark as successful
|
||||
this.reportProgress(100, "Text processor ready (without hyphenation)");
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading Text Processor dependencies:", error);
|
||||
console.error("Error initializing text processor:", error);
|
||||
this.reportProgress(100, "Text processor initialization failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle locale changed event
|
||||
* @param {CustomEvent} event - The locale-changed event
|
||||
*/
|
||||
handleLocaleChanged(event) {
|
||||
if (event && event.detail && event.detail.locale) {
|
||||
this.setLocale(event.detail.locale);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale for the text processor
|
||||
* @param {string} locale - The locale to set
|
||||
*/
|
||||
setLocale(locale) {
|
||||
this.locale = locale;
|
||||
console.log(`Text processor locale set to ${locale}`);
|
||||
|
||||
// Reinitialize hyphenation with new locale if needed
|
||||
if (this.hyphenatorReady) {
|
||||
this.initializeHyphenation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the SmartyPants script dynamically and wait for it to be ready
|
||||
* @returns {Promise<void>}
|
||||
@@ -52,233 +130,146 @@ class TextProcessorModule extends BaseModule {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the script using a script tag
|
||||
|
||||
// Create script element
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/smartypants.js';
|
||||
script.async = false; // Load synchronously relative to other scripts
|
||||
|
||||
script.type = 'text/javascript';
|
||||
script.src = '/js/smartypants.js'; // Use relative URL
|
||||
script.async = true;
|
||||
|
||||
// Set up load and error handlers
|
||||
script.onload = () => {
|
||||
// Use a microtask to ensure the script has executed
|
||||
Promise.resolve().then(() => {
|
||||
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
|
||||
this.smartyPants = window.SmartyPants.smartypants;
|
||||
this.smartypantsu = window.SmartyPants.smartypantsu;
|
||||
console.log("SmartyPants loaded successfully via script tag");
|
||||
resolve();
|
||||
} else {
|
||||
console.error("SmartyPants script loaded but functions not found on window.SmartyPants");
|
||||
reject(new Error('SmartyPants functions not found after loading'));
|
||||
}
|
||||
});
|
||||
if (typeof window.SmartyPants === 'object' && typeof window.SmartyPants.smartypants === 'function') {
|
||||
this.smartyPants = window.SmartyPants.smartypants;
|
||||
this.smartypantsu = window.SmartyPants.smartypantsu;
|
||||
console.log("SmartyPants loaded successfully");
|
||||
resolve();
|
||||
} else {
|
||||
const error = new Error('SmartyPants loaded but functions not found');
|
||||
console.error(error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load smartypants.js script');
|
||||
reject(new Error('Failed to load smartypants.js script'));
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error("Error loading SmartyPants script:", error);
|
||||
reject(new Error('Failed to load SmartyPants script'));
|
||||
};
|
||||
|
||||
|
||||
// Add script to document
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(70, "Initializing text processor");
|
||||
|
||||
// Get locale from Localization module if available
|
||||
const localizationModule = moduleRegistry.getModule('localization');
|
||||
if (localizationModule) {
|
||||
this.locale = localizationModule.getLocale();
|
||||
// Register as an observer for locale changes
|
||||
localizationModule.registerObserver(this, (newLocale) => {
|
||||
this.setLocale(newLocale);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure global locale is set for SmartyPants
|
||||
window.locale = this.locale;
|
||||
|
||||
// Verify SmartyPants is available via the stored references
|
||||
if (typeof this.smartyPants !== 'function') {
|
||||
console.error("SmartyPants function not available for initialization");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Final initialization steps
|
||||
this.reportProgress(100, "Text processor ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Text Processor:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize hyphenation using Hyphenopoly
|
||||
* Initialize hyphenation using Hyphenopoly module
|
||||
* @returns {Promise<boolean>} - Resolves when hyphenation is initialized
|
||||
*/
|
||||
initializeHyphenation() {
|
||||
// Create custom events for hyphenation loading status
|
||||
const hyphenationLoadedEvent = new CustomEvent('hyphenation-loaded');
|
||||
|
||||
// Add listener for hyphenation loaded event
|
||||
document.addEventListener('hyphenation-loaded', () => {
|
||||
console.log('Hyphenation module loaded');
|
||||
this.hyphenatorReady = true;
|
||||
}, { once: true });
|
||||
|
||||
// Check if Hyphenopoly is loaded
|
||||
if (window.Hyphenopoly) {
|
||||
this.setupHyphenopoly();
|
||||
} else {
|
||||
// Set up listener for when Hyphenopoly might be loaded later
|
||||
window.addEventListener('hyphenopoly-loaded', () => {
|
||||
this.setupHyphenopoly();
|
||||
});
|
||||
|
||||
// Try loading Hyphenopoly if not already loading
|
||||
if (!document.querySelector('script[src*="Hyphenopoly_Loader.js"]')) {
|
||||
this.loadHyphenopolyScript();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Hyphenopoly script
|
||||
*/
|
||||
loadHyphenopolyScript() {
|
||||
// Create script element for loader
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/Hyphenopoly_Loader.js';
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
document.dispatchEvent(new CustomEvent('hyphenopoly-script-loaded'));
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('Failed to load Hyphenopoly:', error);
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-error', {
|
||||
detail: { error: 'Failed to load Hyphenopoly script' }
|
||||
}));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Set up configuration for Hyphenopoly
|
||||
window.Hyphenopoly = {
|
||||
require: {
|
||||
'en-us': 'FORCEHYPHENATION'
|
||||
},
|
||||
paths: {
|
||||
maindir: '/js/',
|
||||
patterndir: '/js/patterns/'
|
||||
},
|
||||
setup: {
|
||||
selectors: {
|
||||
'.hyphenate': {}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Hyphenopoly when it's available
|
||||
*/
|
||||
setupHyphenopoly() {
|
||||
// Wait for hyphenator to be available
|
||||
if (window.Hyphenopoly && window.Hyphenopoly.hyphenators) {
|
||||
// Get hyphenator for English
|
||||
window.Hyphenopoly.hyphenators['en-us'].then((hyphenator) => {
|
||||
console.log('Hyphenator ready');
|
||||
this.hyphenator = hyphenator;
|
||||
this.hyphenatorReady = true;
|
||||
|
||||
// Dispatch event that hyphenation is ready
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||
}).catch(err => {
|
||||
console.error('Error loading hyphenator:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hyphenator function
|
||||
* @param {Function} hyphenatorFunc - The hyphenator function
|
||||
*/
|
||||
setHyphenator(hyphenatorFunc) {
|
||||
if (typeof hyphenatorFunc === 'function') {
|
||||
this.hyphenator = hyphenatorFunc;
|
||||
this.hyphenatorReady = true;
|
||||
console.log("Hyphenator function set explicitly");
|
||||
} else {
|
||||
console.warn("Invalid hyphenator provided");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text with SmartyPants and optional hyphenation
|
||||
* @param {string} text - The text to process
|
||||
* @param {boolean} useHyphenation - Whether to apply hyphenation
|
||||
* @returns {string} - The processed text
|
||||
*/
|
||||
process(text, useHyphenation = false) {
|
||||
if (!text) return '';
|
||||
|
||||
let processed = text;
|
||||
|
||||
// Apply SmartyPants for typographic punctuation using stored references
|
||||
try {
|
||||
if (typeof this.smartyPants === 'function') {
|
||||
processed = this.smartyPants(processed);
|
||||
} else {
|
||||
console.warn("SmartyPants function not available for processing");
|
||||
}
|
||||
|
||||
// Convert HTML entities to UTF-8 characters
|
||||
if (typeof this.smartypantsu === 'function') {
|
||||
processed = this.smartypantsu(processed);
|
||||
} else {
|
||||
console.warn("smartypantsu function not available for processing");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error applying SmartyPants:", error);
|
||||
}
|
||||
|
||||
// Apply hyphenation if enabled and available
|
||||
if (useHyphenation && this.hyphenatorReady && this.hyphenator) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
processed = this.hyphenator(processed);
|
||||
console.log("Initializing hyphenation with Hyphenopoly module");
|
||||
|
||||
// Configure Hyphenopoly with our requirements
|
||||
const hyphenatorPromise = Hyphenopoly.config({
|
||||
require: [this.locale],
|
||||
hyphen: '\u00AD', // Soft hyphen character
|
||||
minWordLength: 5,
|
||||
leftmin: 2,
|
||||
rightmin: 2,
|
||||
compound: "hyphen",
|
||||
// Define a custom loader for the patterns
|
||||
loader: (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const patternPath = `/js/patterns/${file}`;
|
||||
console.log(`Loading hyphenation pattern: ${patternPath}`);
|
||||
|
||||
fetch(patternPath)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${file}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
})
|
||||
.then(arrayBuffer => {
|
||||
resolve(arrayBuffer);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error loading hyphenation pattern ${file}:`, error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
handleEvent: {
|
||||
error: (e) => {
|
||||
console.warn(`Hyphenopoly error: ${e.msg}`);
|
||||
},
|
||||
engineReady: (e) => {
|
||||
console.log(`Hyphenopoly engine ready for ${e.msg}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get the hyphenator for our locale
|
||||
hyphenatorPromise.get(this.locale)
|
||||
.then(hyphenator => {
|
||||
this.hyphenator = hyphenator;
|
||||
this.hyphenatorReady = true;
|
||||
console.log(`Hyphenator ready for ${this.locale}`);
|
||||
|
||||
// Dispatch event that hyphenation is ready
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||
resolve(true); // Successfully initialized
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Failed to initialize hyphenator for ${this.locale}:`, error);
|
||||
// Try to fall back to en-us if the current locale failed
|
||||
if (this.locale !== 'en-us') {
|
||||
console.log("Falling back to en-us hyphenation");
|
||||
return hyphenatorPromise.get('en-us');
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.then(fallbackHyphenator => {
|
||||
if (fallbackHyphenator) {
|
||||
this.hyphenator = fallbackHyphenator;
|
||||
this.hyphenatorReady = true;
|
||||
console.log("Using fallback en-us hyphenator");
|
||||
|
||||
// Dispatch event that hyphenation is ready
|
||||
document.dispatchEvent(new CustomEvent('hyphenation-loaded'));
|
||||
resolve(true); // Successfully initialized with fallback
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Failed to initialize hyphenation even with fallback:", error);
|
||||
reject(error); // Failed to initialize
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error applying hyphenation:", error);
|
||||
console.error("Error setting up hyphenation:", error);
|
||||
reject(error); // Failed to initialize
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hyphenation is available
|
||||
* @returns {boolean} - Whether hyphenation is available
|
||||
* @returns {boolean} - True if hyphenation is available
|
||||
*/
|
||||
isHyphenationAvailable() {
|
||||
return this.hyphenatorReady && this.hyphenator !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply only hyphenation to text
|
||||
* Hyphenate a text using the Hyphenopoly module
|
||||
* @param {string} text - The text to hyphenate
|
||||
* @returns {string} - The hyphenated text
|
||||
*/
|
||||
hyphenate(text) {
|
||||
if (!text || !this.hyphenatorReady || !this.hyphenator) {
|
||||
if (!this.isHyphenationAvailable()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
return this.hyphenator(text);
|
||||
} catch (error) {
|
||||
@@ -288,16 +279,33 @@ class TextProcessorModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale for text processing
|
||||
* @param {string} locale - The locale code (e.g., 'en-us', 'de')
|
||||
* Process text with typography enhancements
|
||||
* @param {string} text - The text to process
|
||||
* @param {Object} options - Processing options
|
||||
* @param {boolean} [options.smartypants=true] - Whether to apply SmartyPants processing
|
||||
* @param {boolean} [options.hyphenate=true] - Whether to apply hyphenation
|
||||
* @returns {string} - The processed text
|
||||
*/
|
||||
setLocale(locale) {
|
||||
if (locale && typeof locale === 'string') {
|
||||
this.locale = locale.toLowerCase();
|
||||
// Update global locale for SmartyPants
|
||||
window.locale = this.locale;
|
||||
console.log(`TextProcessor: Locale set to ${locale}`);
|
||||
process(text, options = {}) {
|
||||
const opts = {
|
||||
smartypants: true,
|
||||
hyphenate: true,
|
||||
...options
|
||||
};
|
||||
|
||||
let result = text;
|
||||
|
||||
// Apply SmartyPants if available and requested
|
||||
if (opts.smartypants && this.smartyPants) {
|
||||
result = this.smartyPants(result);
|
||||
}
|
||||
|
||||
// Apply hyphenation if available and requested
|
||||
if (opts.hyphenate && this.isHyphenationAvailable()) {
|
||||
result = this.hyphenate(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+364
-518
@@ -1,555 +1,401 @@
|
||||
/**
|
||||
* TTS Factory for AI Interactive Fiction
|
||||
* Manages different TTS implementations with a common interface
|
||||
* TTS Factory Module
|
||||
* Creates and manages TTS handler instances
|
||||
*/
|
||||
class TTSFactory {
|
||||
constructor() {
|
||||
this.ttsHandler = null;
|
||||
this.handlers = {};
|
||||
this.initializationAttempted = false;
|
||||
this.initializationPromise = null;
|
||||
this.ttsEnabled = true;
|
||||
this.progressCallback = null;
|
||||
this.persistenceManager = null;
|
||||
}
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { BrowserTTSHandler } from './browser-tts-handler.js';
|
||||
import { ApiTTSHandler } from './api-tts-handler.js';
|
||||
import { KokoroHandler } from './kokoro-handler.js';
|
||||
|
||||
/**
|
||||
* Initialize the TTS Factory - Static method for the module loader
|
||||
* @param {Function} reportProgress - Function to report loading progress to the loader
|
||||
* @returns {Promise} - Resolves when TTS is initialized
|
||||
*/
|
||||
static async initializeInterface(reportProgress = null) {
|
||||
console.log('TTS Factory: Initializing interface');
|
||||
|
||||
// Create singleton instance if needed
|
||||
if (!window.ttsFactory) {
|
||||
window.ttsFactory = new TTSFactory();
|
||||
}
|
||||
|
||||
// Initialize TTS with the progress callback
|
||||
window.ttsFactory.progressCallback = reportProgress;
|
||||
|
||||
try {
|
||||
// Start initialization process
|
||||
await window.ttsFactory.initialize();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing TTS Factory:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the TTS Factory
|
||||
* This will load and initialize all available TTS handlers
|
||||
* @returns {Promise} - Resolves when initialization is complete
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
this.initializationPromise = new Promise(async (resolve) => {
|
||||
this.initializationAttempted = true;
|
||||
|
||||
const reportProgress = (percent, message) => {
|
||||
console.log(`TTS progress: ${percent}% - ${message}`);
|
||||
if (this.progressCallback && typeof this.progressCallback === 'function') {
|
||||
this.progressCallback(percent, message);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Report starting initialization
|
||||
reportProgress(10, 'Loading TTS modules');
|
||||
class TTSFactoryModule extends BaseModule {
|
||||
/**
|
||||
* Create a new TTS factory
|
||||
*/
|
||||
constructor() {
|
||||
super('tts-factory', 'TTS Factory');
|
||||
|
||||
// Get persistence manager if available
|
||||
if (window.PersistenceManager) {
|
||||
this.persistenceManager = window.PersistenceManager;
|
||||
reportProgress(15, 'Persistence manager found, loading preferences');
|
||||
|
||||
// Load preferences to determine TTS enabled state and preferred provider
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
if (prefs && prefs.tts) {
|
||||
this.ttsEnabled = prefs.tts.enabled;
|
||||
console.log(`TTS Factory: Setting initial TTS enabled state to ${this.ttsEnabled ? 'enabled' : 'disabled'} from preferences`);
|
||||
}
|
||||
}
|
||||
// Available TTS handlers
|
||||
this.handlers = {};
|
||||
|
||||
// Import needed modules dynamically
|
||||
const [{ BrowserTTSHandler }, { KokoroHandler }, { ApiTTSHandler }] = await Promise.all([
|
||||
import('./browser-tts-handler.js'),
|
||||
import('./kokoro-handler.js'),
|
||||
import('./api-tts-handler.js')
|
||||
// Current active handler
|
||||
this.activeHandler = null;
|
||||
|
||||
// Handler initialization status
|
||||
this.initStatus = {
|
||||
browser: false,
|
||||
api: false,
|
||||
kokoro: false
|
||||
};
|
||||
|
||||
// TTS availability flag
|
||||
this.ttsAvailable = false;
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'registerHandler',
|
||||
'initializeHandler',
|
||||
'getHandler',
|
||||
'setActiveHandler',
|
||||
'getActiveHandler',
|
||||
'getAvailableHandlers',
|
||||
'speak',
|
||||
'stop',
|
||||
'pause',
|
||||
'resume',
|
||||
'getVoices',
|
||||
'getPreference'
|
||||
]);
|
||||
|
||||
reportProgress(20, 'TTS modules loaded');
|
||||
|
||||
// Create handlers
|
||||
const browserHandler = new BrowserTTSHandler();
|
||||
const kokoroHandler = new KokoroHandler();
|
||||
const apiHandler = new ApiTTSHandler();
|
||||
|
||||
// Store handlers
|
||||
this.handlers = {
|
||||
browser: browserHandler,
|
||||
kokoro: kokoroHandler,
|
||||
api: apiHandler
|
||||
};
|
||||
|
||||
// Get preferred TTS mode from options
|
||||
const preferredTTSMode = this.getPreferredTTSMode();
|
||||
|
||||
// Initialize the preferred handler first
|
||||
if (preferredTTSMode === 'browser') {
|
||||
// User prefers browser TTS
|
||||
await this.initializeBrowserTTS(browserHandler, reportProgress);
|
||||
} else if (preferredTTSMode === 'api') {
|
||||
// User prefers API TTS
|
||||
await this.initializeApiTTS(apiHandler, reportProgress);
|
||||
|
||||
// Fallback to browser TTS if API fails
|
||||
if (!apiHandler.isAvailable()) {
|
||||
await this.initializeBrowserTTS(browserHandler, reportProgress);
|
||||
}
|
||||
} else {
|
||||
// Default flow: prefer Kokoro, with browser as immediate fallback
|
||||
// Initialize browser TTS immediately for a responsive experience
|
||||
await this.initializeBrowserTTS(browserHandler, reportProgress);
|
||||
|
||||
// Then schedule Kokoro loading in the background
|
||||
reportProgress(75, 'Scheduling Kokoro TTS initialization');
|
||||
this.scheduleKokoroInitialization(kokoroHandler, reportProgress).then((kokoroAvailable) => {
|
||||
if (kokoroAvailable) {
|
||||
// Switch to Kokoro as it's the best option and set as preferred
|
||||
this.ttsHandler = kokoroHandler;
|
||||
this.setPreferredTTSMode('kokoro');
|
||||
this.dispatchTTSReadyEvent(true, 'kokoro', kokoroHandler);
|
||||
reportProgress(100, 'Kokoro TTS ready');
|
||||
|
||||
// Apply voice settings from preferences if available
|
||||
this.applyVoiceSettingsFromPreferences();
|
||||
} else if (!this.getPreferredTTSMode()) {
|
||||
// If Kokoro failed and no preference was previously set,
|
||||
// set browser as preferred mode
|
||||
this.setPreferredTTSMode('browser');
|
||||
// Add dependencies
|
||||
this.dependencies = ['persistence-manager', 'localization'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(10, "Initializing TTS factory");
|
||||
|
||||
// Get dependencies
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const localization = this.getModule('localization');
|
||||
|
||||
if (!persistenceManager || !localization) {
|
||||
console.error("TTS Factory: Required dependencies not found");
|
||||
this.reportProgress(100, "TTS factory failed - missing dependencies");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply voice settings from preferences for initial handler
|
||||
this.applyVoiceSettingsFromPreferences();
|
||||
|
||||
// Resolve initialization even though Kokoro is still loading in background
|
||||
reportProgress(80, 'TTS interface ready' +
|
||||
(preferredTTSMode !== 'kokoro' ? '' : ' (Kokoro loading in background)'));
|
||||
resolve(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing TTS Factory:', error);
|
||||
|
||||
// If we have any handler working, consider initialization successful
|
||||
if (this.ttsHandler) {
|
||||
reportProgress(100, `Using ${this.ttsHandler.getId()} TTS (fallback)`);
|
||||
resolve(true);
|
||||
} else {
|
||||
this.dispatchTTSReadyEvent(false);
|
||||
reportProgress(100, 'TTS initialization failed');
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stored voice settings from preferences
|
||||
* @private
|
||||
*/
|
||||
applyVoiceSettingsFromPreferences() {
|
||||
if (!this.ttsHandler || !this.persistenceManager) return;
|
||||
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
if (prefs && prefs.tts) {
|
||||
if (prefs.tts.voice) {
|
||||
console.log(`TTS Factory: Setting voice to ${prefs.tts.voice} from preferences`);
|
||||
// Check if setVoice exists, otherwise try setting through voiceOptions
|
||||
if (typeof this.ttsHandler.setVoice === 'function') {
|
||||
this.ttsHandler.setVoice(prefs.tts.voice);
|
||||
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
|
||||
this.ttsHandler.setVoiceOptions({ voice: prefs.tts.voice });
|
||||
}
|
||||
}
|
||||
|
||||
if (prefs.tts.rate !== undefined) {
|
||||
console.log(`TTS Factory: Setting speech rate to ${prefs.tts.rate} from preferences`);
|
||||
// Check if setSpeed exists, otherwise try setting through voiceOptions
|
||||
if (typeof this.ttsHandler.setSpeed === 'function') {
|
||||
this.ttsHandler.setSpeed(prefs.tts.rate);
|
||||
} else if (typeof this.ttsHandler.setVoiceOptions === 'function') {
|
||||
this.ttsHandler.setVoiceOptions({ rate: prefs.tts.rate });
|
||||
}
|
||||
}
|
||||
|
||||
if (prefs.tts.volume !== undefined && typeof this.ttsHandler.setVolume === 'function') {
|
||||
console.log(`TTS Factory: Setting volume to ${prefs.tts.volume} from preferences`);
|
||||
this.ttsHandler.setVolume(prefs.tts.volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize browser TTS
|
||||
* @param {BrowserTTSHandler} handler - The browser TTS handler
|
||||
* @param {Function} reportProgress - Progress reporting function
|
||||
* @returns {Promise<boolean>} - Resolves with availability status
|
||||
*/
|
||||
async initializeBrowserTTS(handler, reportProgress) {
|
||||
reportProgress(30, 'Initializing browser TTS');
|
||||
const browserAvailable = await handler.initialize();
|
||||
|
||||
if (browserAvailable) {
|
||||
this.ttsHandler = handler;
|
||||
this.dispatchTTSReadyEvent(true, 'browser', handler);
|
||||
reportProgress(40, 'Browser TTS ready');
|
||||
} else {
|
||||
reportProgress(40, 'Browser TTS not available');
|
||||
}
|
||||
|
||||
return browserAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize API TTS
|
||||
* @param {ApiTTSHandler} handler - The API TTS handler
|
||||
* @param {Function} reportProgress - Progress reporting function
|
||||
* @returns {Promise<boolean>} - Resolves with availability status
|
||||
*/
|
||||
async initializeApiTTS(handler, reportProgress) {
|
||||
reportProgress(50, 'Initializing API TTS');
|
||||
const apiAvailable = await handler.initialize();
|
||||
|
||||
if (apiAvailable) {
|
||||
this.ttsHandler = handler;
|
||||
this.dispatchTTSReadyEvent(true, 'api', handler);
|
||||
reportProgress(70, 'API TTS ready');
|
||||
}
|
||||
|
||||
return apiAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preferred TTS mode from storage
|
||||
* @returns {string|null} - Preferred TTS mode or null if not set
|
||||
*/
|
||||
getPreferredTTSMode() {
|
||||
// First check persistent settings if available
|
||||
if (this.persistenceManager) {
|
||||
const prefs = this.persistenceManager.getAllPreferences();
|
||||
if (prefs && prefs.tts && prefs.tts.provider) {
|
||||
console.log(`TTS Factory: Using preferred TTS mode '${prefs.tts.provider}' from persistence manager`);
|
||||
return prefs.tts.provider;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localStorage if persistence manager is not available
|
||||
try {
|
||||
const savedMode = localStorage.getItem('preferred-tts-mode');
|
||||
if (savedMode) {
|
||||
console.log(`TTS Factory: Using preferred TTS mode '${savedMode}' from localStorage`);
|
||||
return savedMode;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not read TTS preference from localStorage');
|
||||
}
|
||||
|
||||
// Default to Kokoro if no preference is found
|
||||
return "kokoro";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preferred TTS mode in storage
|
||||
* @param {string} mode - The TTS mode to save as preferred
|
||||
*/
|
||||
setPreferredTTSMode(mode) {
|
||||
// Update in persistence manager if available
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'provider', mode);
|
||||
console.log(`TTS Factory: Saved preferred TTS mode '${mode}' to persistence manager`);
|
||||
}
|
||||
|
||||
// Also save to localStorage as backup
|
||||
try {
|
||||
localStorage.setItem('preferred-tts-mode', mode);
|
||||
} catch (e) {
|
||||
console.warn('Could not save TTS preference to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule Kokoro initialization during idle time
|
||||
* @param {Object} kokoroHandler - The Kokoro handler instance
|
||||
* @param {Function} reportProgress - Progress reporting function
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
scheduleKokoroInitialization(kokoroHandler, reportProgress) {
|
||||
// Immediately dispatch the loading started event so tts-player can catch it
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-started'));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Create the initialization function
|
||||
const startKokoroInit = async () => {
|
||||
try {
|
||||
// Initialize Kokoro with progress callback
|
||||
const kokoroAvailable = await kokoroHandler.initialize((percent, message) => {
|
||||
// Scale progress to 80-95% range for the TTS module's overall progress
|
||||
const scaledProgress = 80 + Math.floor(percent * 0.15);
|
||||
reportProgress(scaledProgress, message || `Loading Kokoro TTS: ${percent}%`);
|
||||
});
|
||||
|
||||
// Register available handlers
|
||||
this.registerHandler('browser', new BrowserTTSHandler());
|
||||
this.registerHandler('api', new ApiTTSHandler());
|
||||
this.registerHandler('kokoro', new KokoroHandler());
|
||||
|
||||
this.reportProgress(30, "Registered TTS handlers");
|
||||
|
||||
// Get user preferences
|
||||
const ttsEnabled = this.getPreference('tts', 'enabled', false);
|
||||
const preferredProvider = this.getPreference('tts', 'provider', 'browser');
|
||||
|
||||
// Initialize handlers based on preferences
|
||||
let initSuccess = false;
|
||||
|
||||
if (ttsEnabled) {
|
||||
// Try to initialize preferred handler first
|
||||
this.reportProgress(50, `Initializing preferred TTS handler: ${preferredProvider}`);
|
||||
initSuccess = await this.initializeHandler(preferredProvider);
|
||||
|
||||
// Mark completion
|
||||
if (kokoroAvailable) {
|
||||
reportProgress(95, "Kokoro TTS initialized successfully");
|
||||
if (initSuccess) {
|
||||
this.setActiveHandler(preferredProvider);
|
||||
} else {
|
||||
reportProgress(95, "Kokoro TTS unavailable - using fallback");
|
||||
// If preferred handler failed, try alternatives based on priority: Kokoro -> Browser -> None
|
||||
console.warn(`Failed to initialize preferred TTS handler: ${preferredProvider}, trying alternatives`);
|
||||
|
||||
// Try Kokoro TTS as fallback if not already tried
|
||||
if (preferredProvider !== 'kokoro') {
|
||||
this.reportProgress(60, "Trying Kokoro TTS as fallback");
|
||||
initSuccess = await this.initializeHandler('kokoro');
|
||||
if (initSuccess) {
|
||||
this.setActiveHandler('kokoro');
|
||||
// Update preference to Kokoro since it worked
|
||||
this.getModule('persistence-manager').updatePreference('tts', 'provider', 'kokoro');
|
||||
}
|
||||
}
|
||||
|
||||
// If Kokoro TTS failed, try Browser TTS
|
||||
if (!initSuccess && preferredProvider !== 'browser') {
|
||||
this.reportProgress(70, "Trying Browser TTS as fallback");
|
||||
initSuccess = await this.initializeHandler('browser');
|
||||
if (initSuccess) {
|
||||
this.setActiveHandler('browser');
|
||||
// Update preference to browser since it worked
|
||||
this.getModule('persistence-manager').updatePreference('tts', 'provider', 'browser');
|
||||
}
|
||||
}
|
||||
|
||||
// Note: API TTS is not used as a fallback as it requires manual configuration
|
||||
}
|
||||
} else {
|
||||
// Even if TTS is disabled, initialize handlers in the background
|
||||
// so they're ready if the user enables TTS later
|
||||
this.reportProgress(50, "TTS disabled, initializing handlers in background");
|
||||
|
||||
// Always dispatch event to indicate completion status
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
||||
detail: { success: kokoroAvailable }
|
||||
}));
|
||||
// Initialize Kokoro and Browser handlers in parallel (not API as it requires configuration)
|
||||
const initPromises = [
|
||||
this.initializeHandler('kokoro'),
|
||||
this.initializeHandler('browser')
|
||||
];
|
||||
|
||||
resolve(kokoroAvailable);
|
||||
} catch (error) {
|
||||
console.error('Error initializing Kokoro:', error);
|
||||
reportProgress(95, 'Kokoro TTS failed to initialize - using fallback');
|
||||
// Wait for all handlers to initialize
|
||||
await Promise.allSettled(initPromises);
|
||||
|
||||
// Dispatch completion event with error information
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
||||
detail: { success: false, error: error.message }
|
||||
}));
|
||||
|
||||
resolve(false);
|
||||
// Check if any handler initialized successfully
|
||||
initSuccess = this.initStatus.kokoro || this.initStatus.browser;
|
||||
}
|
||||
};
|
||||
|
||||
// Add timeout protection with a reasonable timeout (30 seconds for resource-intensive operations)
|
||||
const timeoutId = setTimeout(() => {
|
||||
reportProgress(95, 'Kokoro initialization timed out - using fallback');
|
||||
window.dispatchEvent(new CustomEvent('kokoro-loading-complete', {
|
||||
detail: { success: false, error: "Timeout" }
|
||||
|
||||
// Set TTS availability flag and dispatch event
|
||||
this.ttsAvailable = initSuccess;
|
||||
|
||||
// Dispatch event to notify UI about TTS availability
|
||||
document.dispatchEvent(new CustomEvent('tts:availability', {
|
||||
detail: { available: this.ttsAvailable }
|
||||
}));
|
||||
resolve(false);
|
||||
}, 30000); // Increased timeout to 30 seconds since model loading is resource intensive
|
||||
|
||||
// Use requestIdleCallback to start initialization during idle time
|
||||
if (window.requestIdleCallback) {
|
||||
reportProgress(75, 'Scheduling Kokoro TTS for background loading');
|
||||
|
||||
window.requestIdleCallback(() => {
|
||||
startKokoroInit().then(() => clearTimeout(timeoutId));
|
||||
}, { timeout: 10000 });
|
||||
} else {
|
||||
reportProgress(75, 'Background loading not available, loading Kokoro normally');
|
||||
this.reportProgress(100, initSuccess ? "TTS factory ready" : "TTS factory ready (no handlers available)");
|
||||
|
||||
// Use a microtask to avoid blocking the UI thread
|
||||
Promise.resolve().then(() => startKokoroInit().then(() => clearTimeout(timeoutId)));
|
||||
// Always return true since TTS is optional for the application
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing TTS factory:", error);
|
||||
this.reportProgress(100, "TTS factory failed");
|
||||
|
||||
// Set TTS availability to false and dispatch event
|
||||
this.ttsAvailable = false;
|
||||
document.dispatchEvent(new CustomEvent('tts:availability', {
|
||||
detail: { available: false }
|
||||
}));
|
||||
|
||||
// Still return true since TTS is optional
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a custom event when TTS is ready
|
||||
* @param {boolean} available - Whether TTS is available
|
||||
* @param {string} type - The type of TTS
|
||||
* @param {Object} handler - The TTS handler object
|
||||
*/
|
||||
dispatchTTSReadyEvent(available, type = null, handler = null) {
|
||||
const event = new CustomEvent('tts-ready', {
|
||||
detail: {
|
||||
available,
|
||||
type,
|
||||
handler,
|
||||
enabled: this.ttsEnabled
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the active TTS system
|
||||
* @returns {Object} - TTS system info
|
||||
*/
|
||||
getActiveTTSInfo() {
|
||||
if (!this.ttsHandler) {
|
||||
return { available: false, type: 'none', name: 'None' };
|
||||
}
|
||||
|
||||
const id = this.ttsHandler.getId();
|
||||
const name = {
|
||||
'browser': 'Browser TTS',
|
||||
'kokoro': 'Kokoro Neural TTS',
|
||||
'api': 'ElevenLabs API TTS'
|
||||
}[id] || 'Unknown TTS';
|
||||
|
||||
return {
|
||||
available: true,
|
||||
type: id,
|
||||
name: name
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific TTS handler
|
||||
* @param {string} type - The handler ID to use
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
switchTTS(type) {
|
||||
if (!this.handlers[type] || !this.handlers[type].isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.ttsHandler = this.handlers[type];
|
||||
this.dispatchTTSReadyEvent(true, type, this.ttsHandler);
|
||||
|
||||
// Update preferred TTS mode
|
||||
this.setPreferredTTSMode(type);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using the active TTS handler
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
* @returns {boolean} - True if speech started successfully
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
if (!this.ttsEnabled || !this.ttsHandler) {
|
||||
console.warn("TTSFactory: No active TTS handler available or TTS disabled");
|
||||
if (callback) callback("No TTS handler");
|
||||
return false;
|
||||
/**
|
||||
* Register a TTS handler
|
||||
* @param {string} id - Handler ID
|
||||
* @param {Object} handler - TTS handler instance
|
||||
*/
|
||||
registerHandler(id, handler) {
|
||||
if (!id || !handler) return;
|
||||
this.handlers[id] = handler;
|
||||
}
|
||||
|
||||
const handlerType = this.ttsHandler.getId();
|
||||
console.log(`TTSFactory: Using ${handlerType} handler to speak "${text}"`);
|
||||
|
||||
try {
|
||||
this.ttsHandler.speak(text, (result) => {
|
||||
console.log(`TTSFactory: Speech completed using ${handlerType}`, result);
|
||||
if (callback) callback(result);
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error speaking:', error);
|
||||
if (callback) callback(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any ongoing speech
|
||||
*/
|
||||
stop() {
|
||||
if (this.ttsHandler) {
|
||||
this.ttsHandler.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options for the active handler
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options = {}) {
|
||||
if (this.ttsHandler && typeof this.ttsHandler.setVoiceOptions === 'function') {
|
||||
this.ttsHandler.setVoiceOptions(options);
|
||||
|
||||
// Save settings to persistence manager if available
|
||||
if (this.persistenceManager) {
|
||||
if (options.voice !== undefined) {
|
||||
this.persistenceManager.updatePreference('tts', 'voice', options.voice, false);
|
||||
/**
|
||||
* Initialize a specific TTS handler
|
||||
* @param {string} id - Handler ID
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async initializeHandler(id) {
|
||||
if (!id || !this.handlers[id]) {
|
||||
console.error(`TTS Factory: Handler '${id}' not found`);
|
||||
return false;
|
||||
}
|
||||
if (options.rate !== undefined) {
|
||||
this.persistenceManager.updatePreference('tts', 'rate', options.rate, false);
|
||||
|
||||
try {
|
||||
this.reportProgress(0, `Initializing ${id} TTS handler`);
|
||||
|
||||
// Initialize the handler
|
||||
const success = await this.handlers[id].initialize(
|
||||
(progress, message) => {
|
||||
this.reportProgress(progress, message);
|
||||
}
|
||||
);
|
||||
|
||||
// Update initialization status
|
||||
this.initStatus[id] = success;
|
||||
|
||||
if (success) {
|
||||
console.log(`TTS Factory: Successfully initialized ${id} TTS handler`);
|
||||
} else {
|
||||
console.error(`TTS Factory: Failed to initialize ${id} TTS handler`);
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error(`TTS Factory: Error initializing ${id} TTS handler:`, error);
|
||||
this.initStatus[id] = false;
|
||||
return false;
|
||||
}
|
||||
if (options.volume !== undefined) {
|
||||
this.persistenceManager.updatePreference('tts', 'volume', options.volume, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a TTS handler by ID
|
||||
* @param {string} id - Handler ID
|
||||
* @returns {Object|null} - TTS handler instance or null if not found
|
||||
*/
|
||||
getHandler(id) {
|
||||
if (!id || !this.handlers[id]) return null;
|
||||
return this.handlers[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active TTS handler
|
||||
* @param {string} id - Handler ID
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
setActiveHandler(id) {
|
||||
if (!id || !this.handlers[id] || !this.initStatus[id]) {
|
||||
console.warn(`Cannot set active handler to ${id}: handler not found or not initialized`);
|
||||
return false;
|
||||
}
|
||||
// Save all changes at once
|
||||
this.persistenceManager.savePreferences();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle TTS on/off
|
||||
* @returns {boolean} - New TTS enabled state
|
||||
*/
|
||||
toggle() {
|
||||
this.ttsEnabled = !this.ttsEnabled;
|
||||
console.log(`TTS Factory: Toggling TTS to ${this.ttsEnabled ? 'enabled' : 'disabled'}`);
|
||||
|
||||
if (!this.ttsEnabled && this.ttsHandler) {
|
||||
this.ttsHandler.stop();
|
||||
|
||||
// Stop current handler if active
|
||||
if (this.activeHandler) {
|
||||
this.handlers[this.activeHandler].stop();
|
||||
}
|
||||
|
||||
// Set new active handler
|
||||
this.activeHandler = id;
|
||||
|
||||
// Update preference
|
||||
this.getModule('persistence-manager').updatePreference('tts', 'provider', id);
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchEvent('tts-handler-changed', {
|
||||
handler: id
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save the new state to preferences if persistence manager is available
|
||||
if (this.persistenceManager) {
|
||||
this.persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
console.log(`TTS Factory: Saved enabled state (${this.ttsEnabled}) to persistence manager`);
|
||||
/**
|
||||
* Get the active TTS handler
|
||||
* @returns {Object|null} - Active TTS handler instance or null if none active
|
||||
*/
|
||||
getActiveHandler() {
|
||||
if (!this.activeHandler) return null;
|
||||
return this.handlers[this.activeHandler];
|
||||
}
|
||||
|
||||
return this.ttsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is enabled
|
||||
* @returns {boolean} - Current TTS enabled state
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.ttsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available handlers
|
||||
* @returns {Object} - Map of available handlers
|
||||
*/
|
||||
getAvailableHandlers() {
|
||||
const available = {};
|
||||
|
||||
Object.entries(this.handlers).forEach(([id, handler]) => {
|
||||
if (handler.isAvailable()) {
|
||||
available[id] = handler;
|
||||
}
|
||||
});
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices from active handler
|
||||
* @returns {Promise<Array>} - Array of available voices
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.ttsHandler || typeof this.ttsHandler.getVoices !== 'function') {
|
||||
return [];
|
||||
/**
|
||||
* Get all available TTS handlers
|
||||
* @returns {Object} - Map of handler IDs to initialization status
|
||||
*/
|
||||
getAvailableHandlers() {
|
||||
const available = {};
|
||||
|
||||
for (const id in this.handlers) {
|
||||
available[id] = this.initStatus[id];
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.ttsHandler.getVoices();
|
||||
} catch (error) {
|
||||
console.error('Error getting voices:', error);
|
||||
return [];
|
||||
/**
|
||||
* Speak text using the active TTS handler
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Object} options - TTS options
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async speak(text, options = {}) {
|
||||
if (!this.activeHandler) {
|
||||
console.warn("No active TTS handler");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.handlers[this.activeHandler].speak(text, options);
|
||||
} catch (error) {
|
||||
console.error("Error speaking text:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop speaking
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
stop() {
|
||||
if (!this.activeHandler) return false;
|
||||
|
||||
try {
|
||||
return this.handlers[this.activeHandler].stop();
|
||||
} catch (error) {
|
||||
console.error("Error stopping TTS:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause speaking
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
pause() {
|
||||
if (!this.activeHandler) return false;
|
||||
|
||||
try {
|
||||
return this.handlers[this.activeHandler].pause();
|
||||
} catch (error) {
|
||||
console.error("Error pausing TTS:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume speaking
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
resume() {
|
||||
if (!this.activeHandler) return false;
|
||||
|
||||
try {
|
||||
return this.handlers[this.activeHandler].resume();
|
||||
} catch (error) {
|
||||
console.error("Error resuming TTS:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices for the active TTS handler
|
||||
* @returns {Array} - Array of voice objects
|
||||
*/
|
||||
getVoices() {
|
||||
if (!this.activeHandler) return [];
|
||||
|
||||
try {
|
||||
return this.handlers[this.activeHandler].getVoices();
|
||||
} catch (error) {
|
||||
console.error("Error getting voices:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get 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');
|
||||
if (persistenceManager) {
|
||||
return persistenceManager.getPreference(category, key, defaultValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when module is disposed
|
||||
*/
|
||||
dispose() {
|
||||
// Stop any active TTS
|
||||
if (this.activeHandler) {
|
||||
this.handlers[this.activeHandler].stop();
|
||||
}
|
||||
|
||||
// Dispose all handlers
|
||||
for (const id in this.handlers) {
|
||||
if (this.handlers[id].dispose) {
|
||||
this.handlers[id].dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear handlers
|
||||
this.handlers = {};
|
||||
this.activeHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const ttsFactory = new TTSFactory();
|
||||
// Create the singleton instance
|
||||
const TTSFactory = new TTSFactoryModule();
|
||||
|
||||
// Export the factory
|
||||
export { ttsFactory };
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(TTSFactory);
|
||||
|
||||
// Keep global reference
|
||||
window.ttsFactory = ttsFactory;
|
||||
// Export the module
|
||||
export { TTSFactory };
|
||||
@@ -107,4 +107,18 @@ export class TTSHandler {
|
||||
removeEventListener(eventName, callback) {
|
||||
this.eventTarget.removeEventListener(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind methods to this instance
|
||||
* @param {Array<string>} methodNames - Array of method names to bind
|
||||
*/
|
||||
bindMethods(methodNames) {
|
||||
if (!Array.isArray(methodNames)) return;
|
||||
|
||||
methodNames.forEach(methodName => {
|
||||
if (typeof this[methodName] === 'function') {
|
||||
this[methodName] = this[methodName].bind(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+271
-198
@@ -1,59 +1,41 @@
|
||||
/**
|
||||
* TTS Player Module for AI Interactive Fiction
|
||||
* Handles Text-to-Speech functionality with resource-aware loading and progress reporting
|
||||
* TTS Player Module
|
||||
* Manages TTS functionality and interacts with available TTS handlers
|
||||
*/
|
||||
import { BaseModule, ModuleEvent } from './base-module.js';
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
class TTSPlayerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('tts', 'Text-to-Speech');
|
||||
this.ttsFactory = null;
|
||||
this.isInitialized = false;
|
||||
this.kokoroLoadingPromise = null;
|
||||
this.kokoroLoadingStarted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
// Import the TTS Factory module
|
||||
const { ttsFactory } = await import('./tts-factory.js');
|
||||
this.ttsFactory = ttsFactory;
|
||||
this.reportProgress(20, "TTS Factory loaded");
|
||||
|
||||
// Set up event listeners
|
||||
window.addEventListener('tts-ready', this.handleTTSReadyEvent.bind(this));
|
||||
|
||||
// Create a Promise that resolves when Kokoro is loaded
|
||||
this.kokoroLoadingPromise = new Promise(resolve => {
|
||||
// Listen for when Kokoro starts loading
|
||||
window.addEventListener('kokoro-loading-started', () => {
|
||||
this.kokoroLoadingStarted = true;
|
||||
this.reportProgress(50, "Loading Kokoro TTS");
|
||||
});
|
||||
|
||||
// Listen for when Kokoro completes loading
|
||||
window.addEventListener('kokoro-loading-complete', (event) => {
|
||||
// Check if loading was successful from the event details
|
||||
if (event.detail && event.detail.success === false) {
|
||||
this.reportProgress(95, "Kokoro TTS failed to load - using fallback");
|
||||
console.warn("Kokoro failed to load:", event.detail?.error || "unknown error");
|
||||
} else {
|
||||
this.reportProgress(95, "Kokoro TTS loaded");
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error loading TTS dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
super('tts-player', 'TTS Player');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['tts-factory'];
|
||||
|
||||
// TTS state
|
||||
this.enabled = true;
|
||||
this.currentSpeech = null;
|
||||
this.pendingCallback = null;
|
||||
|
||||
// Preloading mechanism
|
||||
this.preloadQueue = [];
|
||||
this.preloadedAudio = new Map(); // Cache for preloaded TTS
|
||||
this.isPreloading = false;
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'speak',
|
||||
'preloadSpeech',
|
||||
'processPreloadQueue',
|
||||
'stop',
|
||||
'enable',
|
||||
'isEnabled',
|
||||
'isSpeaking',
|
||||
'setVoice',
|
||||
'setSpeed',
|
||||
'getVoices',
|
||||
'toggle'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,197 +44,291 @@ class TTSPlayerModule extends BaseModule {
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Initialize TTS Factory
|
||||
await this.ttsFactory.constructor.initializeInterface((percent, message) => {
|
||||
// Scale to 20-90% of our progress range
|
||||
const scaledPercent = 20 + (percent * 0.7);
|
||||
this.reportProgress(scaledPercent, message);
|
||||
this.reportProgress(20, "Initializing TTS Player");
|
||||
|
||||
// Get TTS Factory dependency
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory) {
|
||||
console.error("TTS Player: TTS Factory dependency not found");
|
||||
this.reportProgress(100, "TTS Player failed - missing dependencies");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check TTS availability from TTS Factory
|
||||
this.enabled = ttsFactory.ttsAvailable && ttsFactory.getPreference('tts', 'enabled', false);
|
||||
|
||||
// Set up event listeners
|
||||
this.addEventListener(document, 'tts:enabled', (event) => {
|
||||
if (event.detail) {
|
||||
this.enabled = event.detail.enabled;
|
||||
console.log(`TTS Player: TTS ${this.enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// IMPORTANT: Always wait for Kokoro's loading promise to resolve
|
||||
this.reportProgress(90, "Waiting for Kokoro TTS to complete loading");
|
||||
// Listen for TTS availability changes
|
||||
this.addEventListener(document, 'tts:availability', (event) => {
|
||||
if (event.detail) {
|
||||
const available = event.detail.available;
|
||||
console.log(`TTS Player: TTS availability changed to ${available ? 'available' : 'unavailable'}`);
|
||||
|
||||
// If TTS becomes unavailable, disable it
|
||||
if (!available) {
|
||||
this.enabled = false;
|
||||
// Notify UI that TTS is disabled
|
||||
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||
detail: { enabled: false, available: false }
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the Kokoro loading promise to complete with a timeout
|
||||
try {
|
||||
// Add a timeout to prevent waiting forever
|
||||
const timeoutPromise = new Promise(resolve => setTimeout(() => {
|
||||
console.log("TTS Player: Kokoro loading timed out, continuing without Kokoro");
|
||||
resolve(false);
|
||||
}, 10000)); // 10 second timeout
|
||||
|
||||
// Race between normal completion and timeout
|
||||
await Promise.race([this.kokoroLoadingPromise, timeoutPromise]);
|
||||
|
||||
this.reportProgress(95, "Kokoro TTS loading completed or timed out");
|
||||
} catch (err) {
|
||||
console.warn("TTS Player: Error waiting for Kokoro:", err);
|
||||
this.reportProgress(95, "Error waiting for Kokoro, continuing anyway");
|
||||
}
|
||||
// Listen for TTS toggle events from UI
|
||||
this.addEventListener(document, 'tts:toggle', () => {
|
||||
this.toggle();
|
||||
// Dispatch state change event for UI to update
|
||||
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||
detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable }
|
||||
}));
|
||||
});
|
||||
|
||||
this.isInitialized = true;
|
||||
// Listen for sentence ready events to preload TTS
|
||||
this.addEventListener(document, 'buffer:sentence', (event) => {
|
||||
if (event.detail && event.detail.sentence && this.enabled) {
|
||||
// Add to preload queue
|
||||
this.preloadSpeech(event.detail.sentence);
|
||||
}
|
||||
});
|
||||
|
||||
// Final status check
|
||||
const ttsInfo = this.ttsFactory.getActiveTTSInfo();
|
||||
if (ttsInfo.available) {
|
||||
this.reportProgress(100, `TTS Player initialized using ${ttsInfo.name}`);
|
||||
return true;
|
||||
} else {
|
||||
this.reportProgress(100, "TTS initialization complete but no voices available");
|
||||
return true; // Still consider this a success, just with no voices
|
||||
}
|
||||
// Dispatch initial state to UI
|
||||
document.dispatchEvent(new CustomEvent('tts:stateChange', {
|
||||
detail: { enabled: this.enabled, available: ttsFactory.ttsAvailable }
|
||||
}));
|
||||
|
||||
this.reportProgress(100, "TTS Player ready");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing TTS Player:", error);
|
||||
this.reportProgress(100, "TTS initialization failed, continuing without TTS");
|
||||
this.isInitialized = true; // Mark as initialized anyway to not block other modules
|
||||
return true; // Return true to not block the application
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle TTS ready event from the factory
|
||||
* @param {CustomEvent} event - The TTS ready event
|
||||
* Preload speech for a sentence
|
||||
* @param {string} text - Text to preload
|
||||
*/
|
||||
handleTTSReadyEvent(event) {
|
||||
const { available, type } = event.detail;
|
||||
preloadSpeech(text) {
|
||||
if (!text || !this.enabled) return;
|
||||
|
||||
if (available && type) {
|
||||
this.reportProgress(95, `TTS system ready: ${type}`);
|
||||
} else {
|
||||
this.reportProgress(95, "No TTS system available");
|
||||
// Don't preload if already in cache
|
||||
if (this.preloadedAudio.has(text)) return;
|
||||
|
||||
// Add to preload queue
|
||||
this.preloadQueue.push(text);
|
||||
console.log(`TTS Player: Added to preload queue: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Start processing the queue if not already processing
|
||||
if (!this.isPreloading) {
|
||||
this.processPreloadQueue();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
|
||||
/**
|
||||
* Get information about the active TTS system
|
||||
* @returns {Object} - TTS system info
|
||||
* Process the preload queue
|
||||
*/
|
||||
getTTSInfo() {
|
||||
if (!this.ttsFactory) return { available: false, type: 'none', name: 'None' };
|
||||
return this.ttsFactory.getActiveTTSInfo();
|
||||
async processPreloadQueue() {
|
||||
if (this.preloadQueue.length === 0 || this.isPreloading) return;
|
||||
|
||||
this.isPreloading = true;
|
||||
const text = this.preloadQueue.shift();
|
||||
|
||||
try {
|
||||
// Get TTSFactory from module registry
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (!ttsFactory) {
|
||||
console.error("TTS Player: TTSFactory module not found in registry");
|
||||
this.isPreloading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only preload if we're not currently speaking or the text is different from current speech
|
||||
if (!this.isSpeaking() || (this.currentSpeech && this.currentSpeech !== text)) {
|
||||
console.log(`TTS Player: Preloading speech for: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Use the preload method of the TTS factory if available
|
||||
if (typeof ttsFactory.preloadSpeech === 'function') {
|
||||
await ttsFactory.preloadSpeech(text);
|
||||
this.preloadedAudio.set(text, true);
|
||||
} else {
|
||||
// Fallback: use normal speak method with a dummy callback
|
||||
ttsFactory.speak(text, () => {
|
||||
ttsFactory.stop(); // Stop immediately after generation
|
||||
this.preloadedAudio.set(text, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("TTS Player: Error preloading speech:", error);
|
||||
} finally {
|
||||
this.isPreloading = false;
|
||||
|
||||
// Process next in queue if available
|
||||
if (this.preloadQueue.length > 0) {
|
||||
// Use requestAnimationFrame to prevent blocking
|
||||
requestAnimationFrame(() => this.processPreloadQueue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle TTS functionality on/off
|
||||
* @returns {boolean} - New TTS enabled state
|
||||
*/
|
||||
toggle() {
|
||||
if (!this.ttsFactory) return false;
|
||||
return this.ttsFactory.toggle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using the active TTS system
|
||||
* Speak text
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Called when speech completes
|
||||
* @param {Function} callback - Optional callback for when speech completes
|
||||
* @returns {boolean} - True if speech started successfully
|
||||
*/
|
||||
speak(text, callback) {
|
||||
if (!this.ttsFactory) {
|
||||
console.warn("TTS Factory not available for speak");
|
||||
if (callback) callback("TTS not available");
|
||||
return;
|
||||
speak(text, callback = null) {
|
||||
if (!text) return false;
|
||||
|
||||
console.log(`TTS Player: Speaking "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`, this.enabled ? "(TTS enabled)" : "(TTS disabled)");
|
||||
|
||||
// Store the current speech text
|
||||
this.currentSpeech = text;
|
||||
|
||||
if (!this.enabled) {
|
||||
console.log("TTS Player: TTS is disabled, not speaking");
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'tts_disabled' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`TTS Player speaking: "${text}"`);
|
||||
this.ttsFactory.speak(text, (result) => {
|
||||
console.log("TTS Player speak complete", result);
|
||||
if (callback) callback(result);
|
||||
});
|
||||
// Get TTSFactory from module registry
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
this.pendingCallback = callback;
|
||||
|
||||
// Check if this text was preloaded
|
||||
const wasPreloaded = this.preloadedAudio.has(text);
|
||||
if (wasPreloaded) {
|
||||
console.log("TTS Player: Using preloaded speech");
|
||||
this.preloadedAudio.delete(text); // Remove from cache after use
|
||||
}
|
||||
|
||||
// Start TTS with minimal delay to synchronize with text rendering
|
||||
ttsFactory.speak(text, (result) => {
|
||||
// Store the completed result
|
||||
this.currentSpeech = null;
|
||||
|
||||
// Call the callback if provided
|
||||
if (this.pendingCallback) {
|
||||
this.pendingCallback(result);
|
||||
this.pendingCallback = null;
|
||||
}
|
||||
|
||||
// Process next in preload queue if any
|
||||
if (this.preloadQueue.length > 0 && !this.isPreloading) {
|
||||
this.processPreloadQueue();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error("TTS Player: TTSFactory module not found in registry");
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'no_tts_factory' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any ongoing speech
|
||||
* Stop speaking
|
||||
*/
|
||||
stop() {
|
||||
if (this.ttsFactory) {
|
||||
this.ttsFactory.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set voice options for the active TTS system
|
||||
* @param {Object} options - Voice options
|
||||
*/
|
||||
setVoiceOptions(options) {
|
||||
if (this.ttsFactory) {
|
||||
this.ttsFactory.setVoiceOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set speech rate/speed
|
||||
* @param {number} speed - Speech rate (0.5-2.0)
|
||||
*/
|
||||
setSpeed(speed) {
|
||||
this.setVoiceOptions({ rate: speed });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the volume for speech
|
||||
* @param {number} volume - Volume level (0.0-1.0)
|
||||
*/
|
||||
setVolume(volume) {
|
||||
this.setVoiceOptions({ volume: volume });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the voice for speech
|
||||
* @param {string} voice - Voice identifier
|
||||
*/
|
||||
setVoice(voice) {
|
||||
this.setVoiceOptions({ voice: voice });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific TTS system
|
||||
* @param {string} type - The TTS system to use ('kokoro', 'browser', or 'api')
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
switchTTS(type) {
|
||||
if (!this.ttsFactory) return false;
|
||||
const result = this.ttsFactory.switchTTS(type);
|
||||
|
||||
// If the switch was successful, refresh the voice list
|
||||
if (result) {
|
||||
// Notify listeners that the TTS system changed
|
||||
window.dispatchEvent(new CustomEvent('tts-system-changed', {
|
||||
detail: {
|
||||
type,
|
||||
info: this.getTTSInfo()
|
||||
}
|
||||
}));
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.stop();
|
||||
}
|
||||
|
||||
return result;
|
||||
this.currentSpeech = null;
|
||||
this.pendingCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available TTS systems
|
||||
* @returns {Array<string>} - Array of available TTS system IDs
|
||||
* Toggle TTS enabled state
|
||||
*/
|
||||
getAvailableSystems() {
|
||||
if (!this.ttsFactory) return [];
|
||||
const handlers = this.ttsFactory.getAvailableHandlers();
|
||||
return Object.keys(handlers);
|
||||
toggle() {
|
||||
this.enabled = !this.enabled;
|
||||
this.enable(this.enabled);
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices for the active TTS system
|
||||
* @returns {Promise<Array>} - Array of voice objects
|
||||
* Enable or disable TTS
|
||||
* @param {boolean} enabled - Whether TTS should be enabled
|
||||
*/
|
||||
async getVoices() {
|
||||
if (!this.ttsFactory) return [];
|
||||
return this.ttsFactory.getVoices();
|
||||
enable(enabled) {
|
||||
this.enabled = enabled;
|
||||
console.log(`TTS Player: ${this.enabled ? 'Enabled' : 'Disabled'}`);
|
||||
|
||||
// Save preference if persistence manager is available
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'enabled', this.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is TTS enabled currently
|
||||
* Check if TTS is enabled
|
||||
* @returns {boolean} - Whether TTS is enabled
|
||||
*/
|
||||
isEnabled() {
|
||||
if (!this.ttsFactory) return false;
|
||||
return this.ttsFactory.isEnabled();
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS is currently speaking
|
||||
* @returns {boolean} - Whether TTS is speaking
|
||||
*/
|
||||
isSpeaking() {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
return ttsFactory.isSpeaking();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the voice to use
|
||||
* @param {string} voice - Voice identifier
|
||||
*/
|
||||
setVoice(voice) {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.configure({ voice });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the speech rate/speed
|
||||
* @param {number} speed - Speech rate (0.5-2.0)
|
||||
*/
|
||||
setSpeed(speed) {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.configure({ speed });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices
|
||||
* @returns {Promise<Array>} - Resolves with array of voice objects
|
||||
*/
|
||||
async getVoices() {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
return ttsFactory.getVoices();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +340,3 @@ moduleRegistry.register(TTSPlayer);
|
||||
|
||||
// Export the module
|
||||
export { TTSPlayer };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.TTSPlayer = TTSPlayer;
|
||||
|
||||
+165
-162
@@ -4,10 +4,11 @@ import { ModuleEvent } from './base-module.js';
|
||||
|
||||
class UIController extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-controller');
|
||||
super('ui-controller', 'UI Controller');
|
||||
|
||||
// Declare dependencies on TTS, animation-queue, and our new UI modules
|
||||
this.dependencies = ['tts', 'animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects'];
|
||||
// Remove 'tts' from direct dependencies to break circular dependency
|
||||
// UI Controller will access TTS through the Game Loop instead
|
||||
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client'];
|
||||
|
||||
// References to sub-modules
|
||||
this.displayHandler = null;
|
||||
@@ -32,55 +33,74 @@ class UIController extends BaseModule {
|
||||
|
||||
// Add TTS toggle state
|
||||
this.ttsEnabled = false;
|
||||
this.ttsAvailable = true; // Add TTS availability state
|
||||
|
||||
// Bind methods that use 'this' internally or are used as callbacks/event handlers
|
||||
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
|
||||
this.handleCommand = this.handleCommand.bind(this); // Bind event handler
|
||||
this.displayText = this.displayText.bind(this); // Bind if passed as callback
|
||||
this.setupBookInterface = this.setupBookInterface.bind(this);
|
||||
this.applyBookSizing = this.applyBookSizing.bind(this);
|
||||
this.setupEventListeners = this.setupEventListeners.bind(this);
|
||||
this.setupMainUI = this.setupMainUI.bind(this);
|
||||
this.initializeTextBuffer = this.initializeTextBuffer.bind(this);
|
||||
this.showUI = this.showUI.bind(this);
|
||||
this.hideUI = this.hideUI.bind(this);
|
||||
this.clearDisplay = this.clearDisplay.bind(this);
|
||||
this.sendCommand = this.sendCommand.bind(this);
|
||||
this.updateButtonStates = this.updateButtonStates.bind(this);
|
||||
|
||||
// Store a bound version of dispatchEvent for use in methods
|
||||
this._dispatchModuleEvent = (name, detail) => {
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
// Bind methods using the parent class bindMethods utility
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'handleCommand',
|
||||
'displayText',
|
||||
'setupBookInterface',
|
||||
'applyBookSizing',
|
||||
'setupEventListeners',
|
||||
'setupMainUI',
|
||||
'initializeTextBuffer',
|
||||
'showUI',
|
||||
'hideUI',
|
||||
'clearDisplay',
|
||||
'sendCommand',
|
||||
'updateButtonStates'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.reportProgress(0, 'Initializing UI Controller');
|
||||
|
||||
try {
|
||||
this.reportProgress(0, 'Initializing UI Controller');
|
||||
|
||||
this.reportProgress(20, 'Setting up book interface');
|
||||
|
||||
// Set up book interface
|
||||
this.setupBookInterface();
|
||||
|
||||
this.reportProgress(30, 'Setting up UI components');
|
||||
this.reportProgress(30, 'Getting module dependencies');
|
||||
|
||||
// Get module references
|
||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
this.inputHandler = moduleRegistry.getModule('ui-input-handler');
|
||||
this.effects = moduleRegistry.getModule('ui-effects');
|
||||
// Get module references using parent's getModule method
|
||||
this.displayHandler = this.getModule('ui-display-handler');
|
||||
this.inputHandler = this.getModule('ui-input-handler');
|
||||
this.effects = this.getModule('ui-effects');
|
||||
this.textBuffer = this.getModule('text-buffer');
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
this.animationQueue = this.getModule('animation-queue');
|
||||
|
||||
// Get additional dependencies
|
||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
||||
this.ttsHandler = moduleRegistry.getModule('tts');
|
||||
this.socketClient = moduleRegistry.getModule('socket-client');
|
||||
this.animationQueue = moduleRegistry.getModule('animation-queue');
|
||||
// Check for required UI modules
|
||||
if (!this.displayHandler) {
|
||||
console.error('UI Controller: Display handler module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.displayHandler || !this.inputHandler || !this.effects) {
|
||||
console.error('UI Controller: Required UI modules not found');
|
||||
if (!this.inputHandler) {
|
||||
console.error('UI Controller: Input handler module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.effects) {
|
||||
console.error('UI Controller: UI effects module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for other required modules
|
||||
if (!this.textBuffer) {
|
||||
console.error('UI Controller: Text buffer module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.socketClient) {
|
||||
console.error('UI Controller: Socket client module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.animationQueue) {
|
||||
console.error('UI Controller: Animation queue module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -89,24 +109,25 @@ class UIController extends BaseModule {
|
||||
// Set up event listeners between components
|
||||
this.setupEventListeners();
|
||||
|
||||
this.reportProgress(80, 'Finalizing UI initialization');
|
||||
this.reportProgress(70, 'Setting up main UI');
|
||||
|
||||
// Initialize main UI container
|
||||
await this.setupMainUI();
|
||||
|
||||
this.reportProgress(80, 'Initializing text buffer');
|
||||
|
||||
// Initialize text buffer handler
|
||||
this.initializeTextBuffer();
|
||||
|
||||
this.reportProgress(100, 'UI Controller ready');
|
||||
|
||||
this.isReady = true;
|
||||
this.isVisible = true;
|
||||
this.reportProgress(100, 'UI Controller ready');
|
||||
this.dispatchEvent(new ModuleEvent('ui:ready', { controller: this }));
|
||||
|
||||
// Start ambient effects
|
||||
this.effects.startAmbientEffects();
|
||||
|
||||
// Use the DOM event API directly instead of this.dispatchEvent
|
||||
this._dispatchModuleEvent('ui:ready', { controller: this });
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing UI Controller:', error);
|
||||
@@ -151,95 +172,41 @@ class UIController extends BaseModule {
|
||||
});
|
||||
|
||||
// Listen for text display events - use arrow function to preserve context
|
||||
document.addEventListener('ui:text:complete', () => {
|
||||
// Use the DOM event API directly
|
||||
this._dispatchModuleEvent('ui:ready:for:next', {});
|
||||
document.addEventListener('ui:text:complete', (event) => {
|
||||
console.log('UIController: Text complete event received, ready for next text');
|
||||
});
|
||||
|
||||
// Listen for socket connection events
|
||||
document.addEventListener('socket:connected', () => {
|
||||
console.log('UI Controller: Socket connected');
|
||||
console.log('UIController: Socket connected');
|
||||
this.updateButtonStates();
|
||||
});
|
||||
|
||||
document.addEventListener('socket:disconnected', () => {
|
||||
console.log('UI Controller: Socket disconnected');
|
||||
console.log('UIController: Socket disconnected');
|
||||
this.updateButtonStates();
|
||||
});
|
||||
|
||||
// Handle speed reset
|
||||
const speedReset = document.getElementById('speed_reset');
|
||||
if (speedReset) {
|
||||
speedReset.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const speedSlider = document.getElementById('speed');
|
||||
if (speedSlider) {
|
||||
speedSlider.value = 50;
|
||||
if (this.animationQueue) {
|
||||
this.animationQueue.setSpeed(1.0);
|
||||
}
|
||||
// Listen for TTS state change events
|
||||
document.addEventListener('tts:stateChange', (event) => {
|
||||
if (event.detail) {
|
||||
if (typeof event.detail.enabled === 'boolean') {
|
||||
this.ttsEnabled = event.detail.enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle speed slider change for animation speed
|
||||
const speedSlider = document.getElementById('speed');
|
||||
if (speedSlider) {
|
||||
speedSlider.addEventListener('input', (e) => {
|
||||
if (this.animationQueue) {
|
||||
// Convert slider value (0-100) to animation speed
|
||||
// Using formula from Documentation.md: lower values = slower speed
|
||||
const value = parseInt(e.target.value);
|
||||
const speed = Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01;
|
||||
this.animationQueue.setSpeed(speed);
|
||||
console.log(`UI Controller: Animation speed set to ${speed.toFixed(3)}`);
|
||||
|
||||
// Save to persistence manager if available
|
||||
if (window.PersistenceManager) {
|
||||
window.PersistenceManager.updatePreference('animation', 'speed', value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial speed from persistence manager if available
|
||||
if (window.PersistenceManager) {
|
||||
const savedSpeed = window.PersistenceManager.getPreference('animation', 'speed', 50);
|
||||
speedSlider.value = savedSpeed;
|
||||
// Apply initial speed
|
||||
if (this.animationQueue) {
|
||||
const speed = Math.pow(100.0 - savedSpeed, 3) / 10000 * 10 + 0.01;
|
||||
this.animationQueue.setSpeed(speed);
|
||||
if (typeof event.detail.available === 'boolean') {
|
||||
this.ttsAvailable = event.detail.available;
|
||||
}
|
||||
this.updateButtonStates();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle speech toggle with proper state management
|
||||
const speechToggle = document.getElementById('speech');
|
||||
if (speechToggle && this.ttsHandler) {
|
||||
// Remove disabled attribute to make it clickable
|
||||
speechToggle.removeAttribute('disabled');
|
||||
|
||||
speechToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
console.log('Speech toggle clicked');
|
||||
|
||||
// Toggle TTS state
|
||||
if (this.ttsHandler && typeof this.ttsHandler.toggle === 'function') {
|
||||
this.ttsEnabled = this.ttsHandler.toggle();
|
||||
|
||||
// Update button text
|
||||
speechToggle.textContent = this.ttsEnabled ? 'mute' : 'speech';
|
||||
|
||||
// Save preference if persistence manager is available
|
||||
const persistenceManager = moduleRegistry.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled);
|
||||
}
|
||||
|
||||
console.log(`UI Controller: TTS ${this.ttsEnabled ? 'enabled' : 'disabled'}`);
|
||||
} else {
|
||||
console.warn('TTS Handler does not have toggle method');
|
||||
}
|
||||
});
|
||||
}
|
||||
// Listen for TTS availability events
|
||||
document.addEventListener('tts:availability', (event) => {
|
||||
if (event.detail && typeof event.detail.available === 'boolean') {
|
||||
this.ttsAvailable = event.detail.available;
|
||||
this.updateButtonStates();
|
||||
}
|
||||
});
|
||||
|
||||
// Add options button to controls section
|
||||
const controlsSection = document.getElementById('controls');
|
||||
@@ -251,53 +218,35 @@ class UIController extends BaseModule {
|
||||
optionsButton.href = '#';
|
||||
optionsButton.textContent = 'options';
|
||||
optionsButton.title = 'Show game options';
|
||||
|
||||
// Add event listener
|
||||
optionsButton.className = 'control-button';
|
||||
optionsButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const optionsUI = moduleRegistry.getModule('options-ui');
|
||||
if (optionsUI && optionsUI.toggle) {
|
||||
optionsUI.toggle();
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('ui:showOptions'));
|
||||
});
|
||||
|
||||
// Add to controls
|
||||
controlsSection.appendChild(document.createTextNode(' | '));
|
||||
controlsSection.appendChild(optionsButton);
|
||||
}
|
||||
|
||||
// Add speech toggle button
|
||||
const speechToggle = document.getElementById('speech-toggle');
|
||||
if (speechToggle) {
|
||||
speechToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
// Dispatch an event for the TTS module to handle instead of calling directly
|
||||
document.dispatchEvent(new CustomEvent('tts:toggle'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enable all controls buttons
|
||||
const controlButtons = document.querySelectorAll('#controls a');
|
||||
controlButtons.forEach(button => {
|
||||
button.removeAttribute('disabled');
|
||||
// Listen for window resize events
|
||||
window.addEventListener('resize', () => {
|
||||
this.applyBookSizing();
|
||||
});
|
||||
|
||||
// Book click for fast-forwarding - make sure it triggers the animation queue
|
||||
if (this.bookElement) {
|
||||
this.bookElement.addEventListener('click', (event) => {
|
||||
// Only if not clicking on a link or control
|
||||
if (event.target.tagName !== 'A' &&
|
||||
!event.target.closest('#controls') &&
|
||||
!event.target.closest('#command_input')) {
|
||||
if (this.animationQueue) {
|
||||
console.log('UI Controller: Fast-forwarding animations');
|
||||
this.animationQueue.fastForward();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Space key for fast-forwarding
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' &&
|
||||
document.activeElement.tagName !== 'TEXTAREA' &&
|
||||
document.activeElement.tagName !== 'INPUT') {
|
||||
if (this.animationQueue) {
|
||||
console.log('UI Controller: Fast-forwarding animations (space key)');
|
||||
this.animationQueue.fastForward();
|
||||
e.preventDefault(); // Prevent page scrolling
|
||||
}
|
||||
// Listen for key events
|
||||
document.addEventListener('keydown', (event) => {
|
||||
// Pass to input handler
|
||||
if (this.inputHandler) {
|
||||
this.inputHandler.handleKeyboardInput(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -319,10 +268,30 @@ class UIController extends BaseModule {
|
||||
initializeTextBuffer() {
|
||||
// Initialize text buffer handling
|
||||
if (this.textBuffer) {
|
||||
console.log('UIController: Setting up text buffer callback');
|
||||
this.textBuffer.setOnSentenceReady((text, callback) => {
|
||||
console.log('UI Controller: Displaying sentence');
|
||||
this.displayText(text).then(callback);
|
||||
console.log('UIController: Received sentence from text buffer, displaying');
|
||||
|
||||
// Use the display handler to show text with proper formatting and TTS
|
||||
this.displayText(text)
|
||||
.then(() => {
|
||||
console.log('UIController: Display of sentence completed, continuing...');
|
||||
|
||||
// Signal that we're ready to process the next sentence
|
||||
if (typeof callback === 'function') {
|
||||
// Use a small timeout to prevent potential stack overflow with many sentences
|
||||
setTimeout(() => callback(), 10);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('UIController: Error displaying text:', error);
|
||||
// Continue anyway to prevent blocking
|
||||
if (typeof callback === 'function') callback();
|
||||
});
|
||||
});
|
||||
console.log('UIController: Text buffer callback set up');
|
||||
} else {
|
||||
console.warn('UIController: Text buffer module not found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +311,20 @@ class UIController extends BaseModule {
|
||||
break;
|
||||
case 'input':
|
||||
if (this.socketClient) {
|
||||
this.socketClient.sendCommand(command.text);
|
||||
console.log(`UI Controller: Sending command to socket: "${command.text}"`);
|
||||
const success = this.socketClient.sendCommand(command.text);
|
||||
|
||||
if (success) {
|
||||
console.log('UI Controller: Command sent successfully');
|
||||
} else {
|
||||
console.error('UI Controller: Failed to send command to socket');
|
||||
// Display an error message to the user
|
||||
this.displayHandler.displayText('⚠️ Unable to send command. Server connection might be lost.', {
|
||||
style: { color: '#990000' }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error('UI Controller: Socket client not available for sending commands');
|
||||
}
|
||||
break;
|
||||
case 'menu':
|
||||
@@ -354,7 +336,7 @@ class UIController extends BaseModule {
|
||||
break;
|
||||
default:
|
||||
// Handle general UI commands or pass to game logic
|
||||
this._dispatchModuleEvent('ui:command', command);
|
||||
this.dispatchEvent(new ModuleEvent('ui:command', command));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +351,7 @@ class UIController extends BaseModule {
|
||||
const saveButton = document.getElementById('save');
|
||||
const loadButton = document.getElementById('reload');
|
||||
const restartButton = document.getElementById('rewind');
|
||||
const speechToggle = document.getElementById('speech-toggle');
|
||||
|
||||
// Update save button state
|
||||
if (saveButton) {
|
||||
@@ -396,6 +379,26 @@ class UIController extends BaseModule {
|
||||
restartButton.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Update speech toggle button state
|
||||
if (speechToggle) {
|
||||
// Update the button appearance based on TTS state
|
||||
if (this.ttsEnabled) {
|
||||
speechToggle.classList.add('active');
|
||||
speechToggle.title = 'Disable speech';
|
||||
} else {
|
||||
speechToggle.classList.remove('active');
|
||||
speechToggle.title = 'Enable speech';
|
||||
}
|
||||
|
||||
// Disable the button completely if TTS is not available
|
||||
if (this.ttsAvailable === false) {
|
||||
speechToggle.setAttribute('disabled', 'disabled');
|
||||
speechToggle.title = 'Speech not available';
|
||||
} else {
|
||||
speechToggle.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
|
||||
+436
-475
File diff suppressed because it is too large
Load Diff
+25
-33
@@ -1,10 +1,9 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
class UIEffects extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-effects');
|
||||
super('ui-effects', 'UI Effects');
|
||||
|
||||
// No external dependencies
|
||||
this.dependencies = [];
|
||||
@@ -13,8 +12,8 @@ class UIEffects extends BaseModule {
|
||||
this.activeEffects = new Map();
|
||||
this.ambientEffectsActive = false;
|
||||
|
||||
// Effects configuration
|
||||
this.effectsConfig = {
|
||||
// Effects configuration - use the config object from BaseModule
|
||||
this.updateConfig({
|
||||
candleFlicker: {
|
||||
intensity: 0.5,
|
||||
speed: 0.8
|
||||
@@ -26,32 +25,25 @@ class UIEffects extends BaseModule {
|
||||
backgroundEffects: {
|
||||
enabled: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Bind methods that use 'this' internally or are used as callbacks/event handlers
|
||||
this.initialize = this.initialize.bind(this); // Bind initialize as it calls dispatchEvent
|
||||
this.updateCandleEffect = this.updateCandleEffect.bind(this); // Used with requestAnimationFrame
|
||||
this.setupEffectElements = this.setupEffectElements.bind(this);
|
||||
this.createEffectsOverlay = this.createEffectsOverlay.bind(this);
|
||||
this.createCandleEffect = this.createCandleEffect.bind(this);
|
||||
this.createLightingElement = this.createLightingElement.bind(this);
|
||||
this.setupAmbientEffects = this.setupAmbientEffects.bind(this);
|
||||
this.setupCandleFlickerEffect = this.setupCandleFlickerEffect.bind(this);
|
||||
this.startAmbientEffects = this.startAmbientEffects.bind(this);
|
||||
this.stopAmbientEffects = this.stopAmbientEffects.bind(this);
|
||||
this.applyEffect = this.applyEffect.bind(this);
|
||||
this.applyShakeEffect = this.applyShakeEffect.bind(this);
|
||||
this.applyFlashEffect = this.applyFlashEffect.bind(this);
|
||||
this.applyTextEmphasis = this.applyTextEmphasis.bind(this);
|
||||
this.processCommand = this.processCommand.bind(this);
|
||||
|
||||
// Store a bound version of dispatchEvent for use in methods
|
||||
this._dispatchModuleEvent = (name, detail) => {
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
// Use bindMethods from parent class
|
||||
this.bindMethods([
|
||||
'updateCandleEffect',
|
||||
'setupEffectElements',
|
||||
'createEffectsOverlay',
|
||||
'createCandleEffect',
|
||||
'createLightingElement',
|
||||
'setupAmbientEffects',
|
||||
'setupCandleFlickerEffect',
|
||||
'startAmbientEffects',
|
||||
'stopAmbientEffects',
|
||||
'applyEffect',
|
||||
'applyShakeEffect',
|
||||
'applyFlashEffect',
|
||||
'applyTextEmphasis',
|
||||
'processCommand'
|
||||
]);
|
||||
|
||||
console.log('UIEffects: Constructor initialized');
|
||||
}
|
||||
@@ -72,8 +64,8 @@ class UIEffects extends BaseModule {
|
||||
|
||||
this.reportProgress(100, 'UI Effects ready');
|
||||
|
||||
// Use the DOM event API directly instead of this.dispatchEvent
|
||||
this._dispatchModuleEvent('ui:effects:ready', {});
|
||||
// Use the parent's dispatchEvent method
|
||||
this.dispatchEvent('ui:effects:ready', {});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -124,7 +116,7 @@ class UIEffects extends BaseModule {
|
||||
|
||||
setupAmbientEffects() {
|
||||
// Initialize candle flicker effect
|
||||
if (this.candleEffectElement && this.effectsConfig.candleFlicker.enabled !== false) {
|
||||
if (this.candleEffectElement && this.config.candleFlicker.enabled !== false) {
|
||||
this.setupCandleFlickerEffect();
|
||||
}
|
||||
}
|
||||
@@ -137,7 +129,7 @@ class UIEffects extends BaseModule {
|
||||
updateCandleEffect() {
|
||||
if (!this.candleEffectElement || !this.ambientEffectsActive) return;
|
||||
|
||||
const { intensity, speed } = this.effectsConfig.candleFlicker;
|
||||
const { intensity, speed } = this.config.candleFlicker;
|
||||
|
||||
// Create subtle random flickering effect
|
||||
const flickerAmount = Math.random() * intensity;
|
||||
|
||||
@@ -1,106 +1,58 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
import { ModuleEvent } from './base-module.js';
|
||||
|
||||
class UIInputHandler extends BaseModule {
|
||||
constructor() {
|
||||
super('ui-input-handler');
|
||||
super('ui-input-handler', 'UI Input Handler');
|
||||
|
||||
// Explicitly declare ui-display-handler as a dependency
|
||||
this.dependencies = ['ui-display-handler'];
|
||||
|
||||
// Reference to display handler
|
||||
this.displayHandler = null;
|
||||
|
||||
// Input elements
|
||||
this.inputArea = null;
|
||||
this.playerInput = null;
|
||||
this.cursor = null;
|
||||
this.commandHistoryElement = null; // Changed: renamed to avoid conflict
|
||||
this.commandHistoryElement = null;
|
||||
|
||||
// Input state
|
||||
this.inputEnabled = true;
|
||||
this.historyIndex = -1;
|
||||
this.commandHistory = []; // Now this is clearly the array of previous commands
|
||||
this.commandHistory = [];
|
||||
this.inputBuffer = '';
|
||||
|
||||
// Add this method to properly dispatch custom events
|
||||
this._dispatchModuleEvent = (name, detail) => {
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: { moduleId: this.id, ...detail },
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
|
||||
// Bind method contexts
|
||||
this.setupInputElements = this.setupInputElements.bind(this);
|
||||
this.handlePlayerInput = this.handlePlayerInput.bind(this);
|
||||
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
||||
this.positionCursor = this.positionCursor.bind(this);
|
||||
this.handleKeyboardInput = this.handleKeyboardInput.bind(this);
|
||||
// Bind methods using the parent class bindMethods utility
|
||||
this.bindMethods([
|
||||
'setupInputElements',
|
||||
'handlePlayerInput',
|
||||
'handleInputKeyDown',
|
||||
'positionCursor',
|
||||
'handleKeyboardInput',
|
||||
'submitCommand',
|
||||
'addToHistory',
|
||||
'resetCursorPosition'
|
||||
]);
|
||||
|
||||
console.log('UIInputHandler: Constructor initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies before initializing
|
||||
* This ensures displayHandler is ready before we try to use it
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Explicitly wait for the display handler to be ready
|
||||
console.log('UIInputHandler: Waiting for display handler to be ready');
|
||||
|
||||
// Get reference to the display handler
|
||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
|
||||
if (!this.displayHandler) {
|
||||
console.error('UIInputHandler: Display handler dependency not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for display handler to reach FINISHED state
|
||||
const displayHandlerReady = await moduleRegistry.waitForModule('ui-display-handler');
|
||||
if (!displayHandlerReady) {
|
||||
console.error('UIInputHandler: Display handler not ready after waiting');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('UIInputHandler: Display handler is ready');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('UIInputHandler: Error waiting for dependencies:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize input handler
|
||||
*/
|
||||
async initialize() {
|
||||
this.reportProgress(0, 'Initializing UI Input Handler');
|
||||
|
||||
try {
|
||||
// Double-check display handler reference
|
||||
this.reportProgress(0, 'Initializing UI Input Handler');
|
||||
|
||||
// Get display handler reference through the parent's getModule method
|
||||
this.displayHandler = this.getModule('ui-display-handler');
|
||||
if (!this.displayHandler) {
|
||||
this.displayHandler = moduleRegistry.getModule('ui-display-handler');
|
||||
|
||||
if (!this.displayHandler) {
|
||||
console.error('UIInputHandler: Display handler still not available');
|
||||
return false;
|
||||
}
|
||||
console.error('UIInputHandler: Display handler module not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.reportProgress(30, 'Setting up keyboard listeners');
|
||||
|
||||
// Set up keyboard event listeners
|
||||
document.addEventListener('keydown', (event) => {
|
||||
this.handleKeyboardInput(event);
|
||||
});
|
||||
// Use the parent's addEventListener for automatic cleanup
|
||||
this.addEventListener(document, 'keydown', this.handleKeyboardInput);
|
||||
|
||||
this.reportProgress(60, 'Setting up input elements');
|
||||
|
||||
// Set up input elements
|
||||
this.setupInputElements();
|
||||
|
||||
this.reportProgress(100, 'UI Input Handler ready');
|
||||
@@ -156,9 +108,9 @@ class UIInputHandler extends BaseModule {
|
||||
commandHistory = document.createElement('div');
|
||||
commandHistory.id = 'command_history';
|
||||
choicesContainer.appendChild(commandHistory);
|
||||
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
|
||||
this.commandHistoryElement = commandHistory;
|
||||
} else {
|
||||
this.commandHistoryElement = commandHistory; // Changed: store in renamed property
|
||||
this.commandHistoryElement = commandHistory;
|
||||
}
|
||||
|
||||
// Create input container if needed
|
||||
@@ -246,8 +198,8 @@ class UIInputHandler extends BaseModule {
|
||||
this.positionCursor(this.playerInput, this.cursor);
|
||||
}
|
||||
|
||||
// Dispatch event using the properly defined method
|
||||
this._dispatchModuleEvent('ui:input:change', {
|
||||
// Use the parent class dispatchEvent method instead of custom _dispatchModuleEvent
|
||||
this.dispatchEvent('ui:input:change', {
|
||||
text: this.playerInput.value
|
||||
});
|
||||
}
|
||||
@@ -280,11 +232,9 @@ class UIInputHandler extends BaseModule {
|
||||
const command = this.playerInput.value.trim();
|
||||
console.log(`UIInputHandler: Submitting command: "${command}"`);
|
||||
|
||||
// Add command to history
|
||||
this.addToHistory(command);
|
||||
|
||||
// Dispatch command event
|
||||
this._dispatchModuleEvent('ui:command', {
|
||||
this.dispatchEvent('ui:command', {
|
||||
type: 'input',
|
||||
text: command
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user