diff --git a/src/lib/components/MudConnection.svelte b/src/lib/components/MudConnection.svelte index c343964..b3e14bc 100644 --- a/src/lib/components/MudConnection.svelte +++ b/src/lib/components/MudConnection.svelte @@ -348,43 +348,62 @@ try { const isActiveProfile = get(activeProfileId) === profileId; - // Always add to output history for this profile - addToOutputHistory(text); - // Process triggers if available for this profile + let processedText = text; + let isGagged = false; + let triggerMatched = false; + if (triggerSystem) { try { - triggerSystem.processTriggers(text); + const result = triggerSystem.processTriggers(text); + processedText = result.processed; + isGagged = result.gagged; + triggerMatched = result.matched; + + console.log(`Trigger processing result - gagged: ${isGagged}, matched: ${triggerMatched}, modified: ${processedText !== text}`); } catch (error) { console.error(`Error processing triggers for ${profileId}:`, error); } } - - // Handle text-to-speech - console.log(`TTS check for ${profileId}: isTTS=${$accessibilitySettings.textToSpeech}, isActive=${isActiveProfile}, speakAll=${$accessibilitySettings.speakAllProfiles}`); - if (accessibilityManager && $accessibilitySettings.textToSpeech) { - // Speak if this is active profile OR speakAllProfiles is enabled - if (isActiveProfile || $accessibilitySettings.speakAllProfiles) { - console.log(`TTS condition satisfied, will attempt to speak for ${profileId}`); - // Use a small timeout to avoid UI blocking - setTimeout(() => { - try { - // If not active profile, add profile name prefix for context - const speechText = isActiveProfile ? text : `From ${getProfileName(profileId)}: ${text}`; - console.log(`Speaking text for ${profileId}:`, speechText.substring(0, 50) + (speechText.length > 50 ? '...' : '')); - accessibilityManager.speak(speechText); - } catch (error) { - console.error('Error using text-to-speech:', error); - } - }, 10); + + // Add to output history if not gagged + if (!isGagged) { + addToOutputHistory(processedText); + + // Handle text-to-speech for processed text + console.log(`TTS check for ${profileId}: isTTS=${$accessibilitySettings.textToSpeech}, isActive=${isActiveProfile}, speakAll=${$accessibilitySettings.speakAllProfiles}`); + if (accessibilityManager && $accessibilitySettings.textToSpeech) { + // Speak if this is active profile OR speakAllProfiles is enabled + if (isActiveProfile || $accessibilitySettings.speakAllProfiles) { + console.log(`TTS condition satisfied, will attempt to speak for ${profileId}`); + // Use a small timeout to avoid UI blocking + setTimeout(() => { + try { + // If not active profile, add profile name prefix for context + const speechText = isActiveProfile ? processedText : `From ${getProfileName(profileId)}: ${processedText}`; + console.log(`Speaking text for ${profileId}:`, speechText.substring(0, 50) + (speechText.length > 50 ? '...' : '')); + accessibilityManager.speak(speechText); + } catch (error) { + console.error('Error using text-to-speech:', error); + } + }, 10); + } else { + console.log(`TTS skipped: profile ${profileId} is not active and speakAll is disabled`); + } } else { - console.log(`TTS skipped: profile ${profileId} is not active and speakAll is disabled`); + console.log(`TTS not enabled for profile ${profileId} or accessibilityManager is null`); } } else { - console.log(`TTS not enabled for profile ${profileId} or accessibilityManager is null`); + console.log(`Text was gagged by trigger - not adding to output history: ${text.substring(0, 50)}...`); } - dispatch('received', { text }); + // Dispatch event with information about trigger processing + dispatch('received', { + originalText: text, + processedText: processedText, + gagged: isGagged, + triggerMatched: triggerMatched + }); } catch (error) { console.error(`Error handling received data for profile ${profileId}:`, error); } diff --git a/src/lib/components/TriggerEditor.svelte b/src/lib/components/TriggerEditor.svelte index 3c302af..9d2a8f2 100644 --- a/src/lib/components/TriggerEditor.svelte +++ b/src/lib/components/TriggerEditor.svelte @@ -16,9 +16,12 @@ isRegex: false, isEnabled: true, soundFile: '', + soundVolume: 0.7, // Default sound volume sendText: '', highlightColor: '', - priority: 0 + priority: 0, + replaceText: '', // New text replacement option + gag: false // Option to hide matched text }; // Available sounds @@ -135,6 +138,64 @@ {/if} + {#if localTrigger.soundFile} +
+ +
+ + {Math.round(localTrigger.soundVolume * 100)}% +
+ Trigger sound volume is multiplied by global volume setting +
+ {/if} + +
+ + +
+ + {#if !localTrigger.gag && localTrigger.replaceText !== undefined && localTrigger.replaceText !== null} +
+ + + {#if localTrigger.isRegex} + Use $1, $2, etc. to reference captured groups + {/if} +
+ {/if} +
@@ -306,6 +367,21 @@ font-weight: bold; } + .range-control { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .range-control input { + flex: 1; + } + + .range-value { + min-width: 40px; + text-align: right; + } + .match-list { margin-top: 10px; padding: 10px; diff --git a/src/lib/connection/MudConnection.ts b/src/lib/connection/MudConnection.ts index 7d9ec5c..df51272 100644 --- a/src/lib/connection/MudConnection.ts +++ b/src/lib/connection/MudConnection.ts @@ -133,7 +133,8 @@ export class MudConnection extends EventEmitter { // Binary data this.handleIncomingData(new Uint8Array(event.data)); } else if (typeof event.data === 'string') { - // Text data + // Text data - let listeners process it directly + // TriggerSystem will handle gagging and replacing in the component this.emit('received', event.data); } else if (event.data instanceof Blob) { // Blob data (sometimes WebSockets send this instead of ArrayBuffer) diff --git a/src/lib/triggers/TriggerSystem.ts b/src/lib/triggers/TriggerSystem.ts index f5a61b5..b00413e 100644 --- a/src/lib/triggers/TriggerSystem.ts +++ b/src/lib/triggers/TriggerSystem.ts @@ -10,10 +10,13 @@ export interface Trigger { isRegex: boolean; isEnabled: boolean; soundFile?: string; + soundVolume?: number; // Sound volume (0-1) action?: string; sendText?: string; highlightColor?: string; priority: number; + replaceText?: string; // New text to replace the matched text with + gag?: boolean; // If true, don't display the matched text at all } export class TriggerSystem extends EventEmitter { @@ -91,9 +94,11 @@ export class TriggerSystem extends EventEmitter { // Sort triggers by priority this.triggers.sort((a, b) => b.priority - a.priority); - // Preload sound if specified + // Preload sound if specified (now uses async method but doesn't wait) if (trigger.soundFile) { - this.loadSound(trigger.id, trigger.soundFile); + this.loadSound(trigger.id, trigger.soundFile).catch(error => { + console.error(`Failed to preload sound for trigger ${trigger.id}:`, error); + }); } // Save triggers to storage @@ -126,9 +131,18 @@ export class TriggerSystem extends EventEmitter { /** * Process text for triggers + * @returns Object with information about gagging and replacement */ - public processTriggers(text: string): void { - // Process only enabled triggers + public processTriggers(text: string): { + processed: string; // Text after all replacements + gagged: boolean; // Whether the text should be completely hidden + matched: boolean; // Whether any triggers matched + } { + let processedText = text; + let isGagged = false; + let anyTriggerMatched = false; + + // Process only enabled triggers in priority order for (const trigger of this.triggers.filter(t => t.isEnabled)) { let matched = false; let matches: RegExpMatchArray | null = null; @@ -136,81 +150,209 @@ export class TriggerSystem extends EventEmitter { if (trigger.isRegex) { try { const regex = new RegExp(trigger.pattern, 'g'); - matches = text.match(regex); - matched = matches !== null; + matches = processedText.match(regex); + matched = matches !== null && matches.length > 0; } catch (error) { console.error(`Invalid regex pattern in trigger ${trigger.name}:`, error); } } else { // Simple text matching - matched = text.includes(trigger.pattern); + matched = processedText.includes(trigger.pattern); } if (matched) { - this.executeTrigger(trigger, text, matches); + anyTriggerMatched = true; + + // Execute the trigger actions + this.executeTrigger(trigger, processedText, matches); + + // Handle gagging - if any trigger gags, the whole line is gagged + if (trigger.gag) { + isGagged = true; + } + + // Handle text replacement if not gagged + if (!isGagged && trigger.replaceText) { + if (trigger.isRegex) { + try { + let replacedText = trigger.replaceText; + + // If we have regex matches, replace $1, $2, etc. with capture groups + if (matches && matches.length > 0) { + // For actual text replacement, we need to use the regex directly + const regex = new RegExp(trigger.pattern, 'g'); + + // Use replace function to handle capture groups dynamically + processedText = processedText.replace(regex, (match, ...groups) => { + // Create a copy of the replacement template + let result = trigger.replaceText || ''; + + // Replace $1, $2, etc. with the actual captured values + for (let i = 0; i < groups.length - 2; i++) { // -2 to skip lastIndex and input + const captureValue = groups[i] || ''; + result = result.replace(new RegExp(`\\$${i + 1}`, 'g'), captureValue); + } + + return result; + }); + } + } catch (error) { + console.error(`Error replacing text with regex in trigger ${trigger.name}:`, error); + } + } else { + // Simple string replacement for non-regex triggers + processedText = processedText.replace( + new RegExp(this.escapeRegExp(trigger.pattern), 'g'), + trigger.replaceText + ); + } + } } } + + return { + processed: processedText, + gagged: isGagged, + matched: anyTriggerMatched + }; } /** - * Execute a triggered action - simplified for stability + * Helper to escape special regex characters in strings + */ + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + /** + * Execute a triggered action with support for advanced features */ private executeTrigger(trigger: Trigger, text: string, matches: RegExpMatchArray | null): void { - // Play sound if specified - with minimal options - if (trigger.soundFile && this.sounds.has(trigger.id)) { - try { - const sound = this.sounds.get(trigger.id); + // Get settings + const uiSettingsValue = get(uiSettings); + const globalVolume = uiSettingsValue.globalVolume || 0.7; + + // Handle sound playback, loading on demand if needed + if (trigger.soundFile) { + // Try to get the existing sound, or load it if not already loaded + const soundPromise = this.sounds.has(trigger.id) + ? Promise.resolve(this.sounds.get(trigger.id)!) + : this.loadSound(trigger.id, trigger.soundFile); + + soundPromise.then(sound => { if (sound) { - sound.volume(0.7); // Fixed volume for stability + // Calculate volume based on trigger-specific volume and global volume + const triggerVolume = trigger.soundVolume !== undefined ? trigger.soundVolume : 0.7; + const finalVolume = Math.min(triggerVolume * globalVolume, 1.0); // Cap at 1.0 + + console.log(`Playing sound with final volume: ${finalVolume} (trigger: ${triggerVolume} * global: ${globalVolume})`); + sound.volume(finalVolume); sound.play(); } - } catch (error) { - console.error('Error playing sound:', error); - } + }).catch(error => { + console.error(`Error playing sound for trigger ${trigger.id}:`, error); + }); } - // Send text if specified + // Send text if specified - support for regex capture group substitution if (trigger.sendText) { - this.emit('sendText', trigger.sendText); + let textToSend = trigger.sendText; + + // If we have regex matches, replace $1, $2, etc. with capture groups + if (trigger.isRegex && matches && matches.length > 1) { + // matches[0] is the full match, captures start at index 1 + for (let i = 1; i < matches.length; i++) { + const captureGroup = matches[i] || ''; + textToSend = textToSend.replace(new RegExp(`\\$${i}`, 'g'), captureGroup); + } + } + + this.emit('sendText', textToSend); } - // Skip custom actions for stability + // Handle text replacement or gagging + if (trigger.gag) { + // If gagged, emit a special event to prevent displaying the text + this.emit('gagText', text); + } else if (trigger.replaceText) { + let replacedText = trigger.replaceText; + + // If we have regex matches, replace $1, $2, etc. with capture groups + if (trigger.isRegex && matches && matches.length > 1) { + for (let i = 1; i < matches.length; i++) { + const captureGroup = matches[i] || ''; + replacedText = replacedText.replace(new RegExp(`\\$${i}`, 'g'), captureGroup); + } + } + + // Emit the replaced text instead of the original + this.emit('replaceText', { original: text, replacement: replacedText }); + } - // Emit highlight event if color specified + // Handle highlight if specified if (trigger.highlightColor) { this.emit('highlight', text, trigger.pattern, trigger.highlightColor, trigger.isRegex); } // Emit basic trigger fired event this.emit('triggerFired', trigger.id); + + // Execute custom action if specified and in browser environment + if (trigger.action && typeof window !== 'undefined') { + try { + // Create a sandboxed function with limited scope + const actionFn = new Function('text', 'matches', trigger.action); + actionFn(text, matches); + } catch (error) { + console.error(`Error executing custom action for trigger ${trigger.id}:`, error); + } + } } /** - * Load a sound file for a trigger - simplified version + * Load a sound file for a trigger with better error handling and loading status + * @returns Promise that resolves with the sound object when loaded */ - private loadSound(triggerId: string, soundFile: string): void { - try { - console.log(`Loading sound for trigger ${triggerId}: ${soundFile}`); - - // Build path based on whether it's a URL or local file - const soundPath = soundFile.startsWith('http') || soundFile.startsWith('/') - ? soundFile - : `/sounds/${soundFile}`; - - // Create minimal sound object - const sound = new Howl({ src: [soundPath] }); - - // Set only necessary handlers - sound.once('load', () => { - this.sounds.set(triggerId, sound); - }); - - sound.once('loaderror', () => { - console.error(`Failed to load sound ${soundFile}`); - }); - } catch (error) { - console.error('Error loading sound:', error); - } + private loadSound(triggerId: string, soundFile: string): Promise { + return new Promise((resolve, reject) => { + try { + console.log(`Loading sound for trigger ${triggerId}: ${soundFile}`); + + // If we already have this sound loaded, return it immediately + if (this.sounds.has(triggerId)) { + console.log(`Sound for trigger ${triggerId} already loaded, reusing`); + resolve(this.sounds.get(triggerId)!); + return; + } + + // Build path based on whether it's a URL or local file + const soundPath = soundFile.startsWith('http') || soundFile.startsWith('/') + ? soundFile + : `/sounds/${soundFile}`; + + // Create sound object with HTML5 audio to better support streaming + const sound = new Howl({ + src: [soundPath], + html5: true // Better for streaming and prevents global audio lock + }); + + // Set up handlers + sound.once('load', () => { + console.log(`Sound for trigger ${triggerId} loaded successfully`); + this.sounds.set(triggerId, sound); + resolve(sound); + }); + + sound.once('loaderror', (id, error) => { + console.error(`Failed to load sound ${soundFile}:`, error); + reject(new Error(`Failed to load sound: ${error || 'Unknown error'}`)); + }); + + } catch (error) { + console.error('Error setting up sound:', error); + reject(error); + } + }); } /** @@ -275,4 +417,4 @@ export class TriggerSystem extends EventEmitter { typeof obj.priority === 'number' ); } -} +} \ No newline at end of file