/** * SettingsPanel - Modular settings UI * Theme picker, face mode selector, and other settings */ window.SettingsPanel = { isOpen: false, currentPanel: null, container: null, _voicePreview: null, // TTSVoicePreview instance _playlistEditor: null, // PlaylistEditor instance _profileSwitcher: null, // ProfileSwitcher instance init() { // Create settings modal container this.createContainer(); // Listen for escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isOpen) { this.close(); } }); }, createContainer() { // Check if already exists if (document.getElementById('settings-panel-container')) return; const container = document.createElement('div'); container.id = 'settings-panel-container'; container.innerHTML = `

Settings

`; document.body.appendChild(container); this.container = container; }, open(panel = 'themes') { if (!this.container) this.createContainer(); this.isOpen = true; this.currentPanel = panel; this.container.classList.add('open'); // Load panel content this.loadPanel(panel); }, close() { this.isOpen = false; if (this.container) { this.container.classList.remove('open'); } // Clean up voice preview player on close if (this._voicePreview) { this._voicePreview.destroy(); this._voicePreview = null; } // Clean up playlist editor on close if (this._playlistEditor) { this._playlistEditor.destroy(); this._playlistEditor = null; } // Clean up profile switcher on close if (this._profileSwitcher) { this._profileSwitcher.destroy(); this._profileSwitcher = null; } }, loadPanel(panel) { const content = document.getElementById('settings-content'); if (!content) return; switch (panel) { case 'themes': content.innerHTML = this.renderThemesPanel(); this.attachThemeListeners(); break; case 'face': content.innerHTML = this.renderFacePanel(); this.attachFaceListeners(); break; case 'voice': content.innerHTML = this.renderVoicePanel(); this.mountVoicePreview(); this.attachSilenceSlider(); break; case 'playlist': content.innerHTML = this.renderPlaylistPanel(); this.mountPlaylistEditor(); break; case 'profiles': content.innerHTML = this.renderProfilesPanel(); this.mountProfileSwitcher(); break; case 'full': default: content.innerHTML = this.renderFullPanel(); this.attachAllListeners(); break; } }, renderFullPanel() { return `

▼ Themes & Colors

${this.renderThemesContent()}

▼ Face Display

${this.renderFaceContent()}

▼ Voice Preview

Loading voices\u2026

▼ Speech Detection

${this._renderSilenceSlider()}

▼ Playlist Editor

Loading playlist\u2026

▼ Agent Profiles

Loading profiles\u2026
`; }, renderThemesPanel() { return `

Themes & Colors

${this.renderThemesContent()}
`; }, renderThemesContent() { const theme = window.ThemeManager?.getCurrentTheme() || {}; const presets = window.ThemeManager?.presets || {}; return `
${Object.entries(presets).map(([name, colors]) => ` `).join('')}
`; }, renderFacePanel() { return `

Face Display

${this.renderFaceContent()}
`; }, renderFaceContent() { // Kept for backwards compatibility — used when FacePicker is unavailable const currentMode = window.FaceRenderer?.getCurrentMode() || 'eyes'; const modes = window.FaceRenderer?.getAvailableModes() || []; const benchRow = window.FaceBench ? `
Measures each face's FPS on this device (~25s, keep tab in foreground)
` : ''; return `
${modes.map(mode => ` `).join('')}
${benchRow}
`; }, renderVoicePanel() { const currentMs = window._sttInstance?.silenceDelayMs ?? window._activeProfileData?.stt?.silence_timeout_ms ?? 1500; const currentSec = (currentMs / 1000).toFixed(1); return `

Voice Preview

Click a voice to hear a sample.

Loading voices\u2026

Speech Detection

${currentSec}s

How long to wait after you stop speaking before sending to AI. Increase if you need time to think.

`; }, renderPlaylistPanel() { return `

Playlist Editor

Loading playlist\u2026
`; }, async mountPlaylistEditor() { const root = document.getElementById('playlist-editor-root'); if (!root) return; if (this._playlistEditor) { this._playlistEditor.destroy(); this._playlistEditor = null; } try { const mod = await import('/src/ui/settings/PlaylistEditor.js'); const PlaylistEditor = mod.PlaylistEditor || mod.default; this._playlistEditor = new PlaylistEditor(); await this._playlistEditor.mount(root); } catch (err) { console.error('[SettingsPanel] Failed to load PlaylistEditor:', err); root.innerHTML = '
Playlist editor unavailable.
'; } }, async mountVoicePreview() { const root = document.getElementById('voice-preview-root'); if (!root) return; // Destroy previous instance if any if (this._voicePreview) { this._voicePreview.destroy(); this._voicePreview = null; } // Try to load TTSVoicePreview module dynamically try { const mod = await import('/src/ui/settings/TTSVoicePreview.js'); const TTSVoicePreview = mod.TTSVoicePreview || mod.default; this._voicePreview = new TTSVoicePreview(); await this._voicePreview.mount(root); } catch (err) { console.error('[SettingsPanel] Failed to load TTSVoicePreview:', err); root.innerHTML = '
Voice preview unavailable.
'; } }, renderProfilesPanel() { return `

Agent Profiles

Select a profile to switch the agent's personality, voice, and settings.

Loading profiles\u2026
`; }, async mountProfileSwitcher() { const root = document.getElementById('profile-switcher-root'); if (!root) return; if (this._profileSwitcher) { this._profileSwitcher.destroy(); this._profileSwitcher = null; } try { const mod = await import('/src/ui/ProfileSwitcher.js'); const ProfileSwitcher = mod.ProfileSwitcher || mod.default; this._profileSwitcher = new ProfileSwitcher(); await this._profileSwitcher.mount(root); } catch (err) { console.error('[SettingsPanel] Failed to load ProfileSwitcher:', err); root.innerHTML = '
Profile switcher unavailable.
'; } }, attachAllListeners() { this.attachThemeListeners(); this.attachFaceListeners(); this.attachSilenceSlider(); // Voice preview mounts async after render — trigger it now if the root exists const voiceRoot = document.getElementById('voice-preview-root'); if (voiceRoot) { this.mountVoicePreview(); } // Playlist editor mounts async const playlistRoot = document.getElementById('playlist-editor-root'); if (playlistRoot) { this.mountPlaylistEditor(); } // Profile switcher mounts async const profileRoot = document.getElementById('profile-switcher-root'); if (profileRoot) { this.mountProfileSwitcher(); } }, _renderSilenceSlider() { const currentMs = window._sttInstance?.silenceDelayMs ?? window._activeProfileData?.stt?.silence_timeout_ms ?? 1500; const currentSec = (currentMs / 1000).toFixed(1); return `
${currentSec}s

How long to wait after you stop speaking before sending to AI. Increase if you need time to think.

`; }, attachSilenceSlider() { const slider = document.getElementById('stt-silence-slider'); if (!slider) return; let saveTimer = null; slider.addEventListener('input', () => { const ms = parseInt(slider.value, 10); const label = document.getElementById('stt-silence-value'); if (label) label.textContent = (ms / 1000).toFixed(1) + 's'; // Apply immediately to the live STT instance if (window._sttInstance) window._sttInstance.silenceDelayMs = ms; // Also update the legacy inline STT if present const legacyStt = window.ModeManager?.clawdbotMode?.stt; if (legacyStt && legacyStt !== window._sttInstance) legacyStt.silenceDelayMs = ms; // Debounce save to profile clearTimeout(saveTimer); saveTimer = setTimeout(() => { const profileId = window._activeProfileData?.id || localStorage.getItem('active_profile_id') || 'default'; fetch('/api/profiles/' + profileId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stt: { silence_timeout_ms: ms } }), }).catch(() => {}); console.log(`[Settings] silence_timeout_ms = ${ms}`); }, 600); }); }, attachThemeListeners() { // Color picker listeners const primaryPicker = document.getElementById('theme-primary'); const accentPicker = document.getElementById('theme-accent'); if (primaryPicker) { primaryPicker.addEventListener('input', (e) => { window.ThemeManager?.setPrimaryColor(e.target.value); }); } if (accentPicker) { accentPicker.addEventListener('input', (e) => { window.ThemeManager?.setAccentColor(e.target.value); }); } // Preset buttons document.querySelectorAll('.preset-btn').forEach(btn => { btn.addEventListener('click', () => { const preset = btn.dataset.preset; window.ThemeManager?.applyPreset(preset); // Update color pickers const theme = window.ThemeManager?.getCurrentTheme(); if (primaryPicker && theme) primaryPicker.value = theme.primary; if (accentPicker && theme) accentPicker.value = theme.accent; }); }); }, attachFaceListeners() { this.attachBenchButton(); const root = document.getElementById('face-picker-root'); if (root && window.FacePicker) { window.FacePicker.mount(root); return; } // Legacy fallback: plain mode buttons (FacePicker not loaded) document.querySelectorAll('.mode-btn').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; window.FaceRenderer?.setMode(mode); document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); }, attachBenchButton() { const benchBtn = document.getElementById('face-bench-btn'); if (!benchBtn || benchBtn._benchWired) return; benchBtn._benchWired = true; benchBtn.addEventListener('click', async () => { if (!window.FaceBench) return; // Close the settings modal first — its full-screen overlay adds // compositing load that drags EVERY face's numbers down equally // (measured: floor fps halved with the modal open). this.close(); try { await window.FaceBench.run(); } catch (e) { console.warn('[SettingsPanel] Face benchmark failed:', e); } }); }, toggleSection(header) { const section = header.parentElement; section.classList.toggle('open'); const arrow = header.querySelector('.section-arrow'); if (arrow) { arrow.innerHTML = section.classList.contains('open') ? '▼' : '▶'; } }, resetTheme() { window.ThemeManager?.resetTheme(); // Update pickers const theme = window.ThemeManager?.getCurrentTheme(); const primaryPicker = document.getElementById('theme-primary'); const accentPicker = document.getElementById('theme-accent'); if (primaryPicker && theme) primaryPicker.value = theme.primary; if (accentPicker && theme) accentPicker.value = theme.accent; } };