'use strict'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let ws = null; let config = {}; // populated from IPC in init() — no hardcoded defaults here let everConnected = false; // true after first successful onopen let reconnectTimer = null; const audioQueue = []; const AUDIO_QUEUE_MAX = 25; let isPlaying = false; let consecutiveAudioErrors = 0; const AUDIO_ERROR_LIMIT = 5; let volumeSaveTimer = null; // DOM refs — populated after DOMContentLoaded let statusDot, statusText, speakingIndicator, lastMessage; let volumeSlider, hostInput, portInput, saveBtn; // --------------------------------------------------------------------------- // WebSocket URL // --------------------------------------------------------------------------- function getWsUrl() { return `ws://${config.host}:${config.port}/stream`; } // --------------------------------------------------------------------------- // Status display // --------------------------------------------------------------------------- function setStatus(state, text) { statusDot.className = 'status-dot ' + state; statusText.textContent = text; } function setSpeaking(active) { if (active) { speakingIndicator.classList.add('active'); } else { speakingIndicator.classList.remove('active'); } } // --------------------------------------------------------------------------- // WebSocket connection // --------------------------------------------------------------------------- function connect() { // Clean up any existing connection if (ws) { ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; ws.close(); ws = null; } clearReconnectTimer(); setStatus('connecting', 'Connecting...'); try { ws = new WebSocket(getWsUrl()); ws.binaryType = 'arraybuffer'; ws.onopen = () => { everConnected = true; // Socket open but no data yet — show a distinct waiting state setStatus('connected', 'Connected — waiting for audio'); }; ws.onmessage = (event) => { if (typeof event.data === 'string') { try { const msg = JSON.parse(event.data); if (msg.type === 'notification' && msg.message) { lastMessage.textContent = msg.message; setStatus('connected', 'Connected'); } } catch { // Ignore malformed JSON } } else if (event.data instanceof ArrayBuffer) { // First real data confirms the server is live setStatus('connected', 'Connected'); if (audioQueue.length < AUDIO_QUEUE_MAX) { audioQueue.push(event.data); if (!isPlaying) playNext(); } else { console.warn('Audio queue full — dropping incoming frame'); } } }; ws.onclose = () => { ws = null; if (everConnected) { // Was previously connected — show that we lost it setStatus('error', 'Disconnected — retrying...'); } else { // Never connected yet — stay in the neutral "Connecting..." state // so there is no blink during the retry loop setStatus('connecting', 'Connecting...'); } scheduleReconnect(); }; ws.onerror = () => { // onclose always fires after onerror — let onclose handle the status // update so there is no intermediate blink from this handler. if (ws) ws._hadError = true; }; } catch (e) { console.error('WebSocket connection failed:', e); setStatus('error', 'Connection error'); scheduleReconnect(); } } function scheduleReconnect() { clearReconnectTimer(); reconnectTimer = setTimeout(() => { if (ws === null) connect(); }, 3000); } function clearReconnectTimer() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } } // --------------------------------------------------------------------------- // Audio playback queue // --------------------------------------------------------------------------- async function playNext() { if (audioQueue.length === 0) { isPlaying = false; setSpeaking(false); return; } isPlaying = true; setSpeaking(true); const buffer = audioQueue.shift(); let url = null; try { const blob = new Blob([buffer], { type: 'audio/mpeg' }); url = URL.createObjectURL(blob); const audio = new Audio(url); audio.volume = Math.min(1, Math.max(0, config.volume / 100)); audio.onended = () => { URL.revokeObjectURL(url); consecutiveAudioErrors = 0; playNext(); }; audio.onerror = (e) => { console.error('Audio error:', e); URL.revokeObjectURL(url); consecutiveAudioErrors++; if (consecutiveAudioErrors >= AUDIO_ERROR_LIMIT) { console.error(`Audio failed ${AUDIO_ERROR_LIMIT} times in a row — stopping playback`); isPlaying = false; setSpeaking(false); setStatus('error', 'Audio error'); audioQueue.length = 0; return; } playNext(); }; await audio.play(); consecutiveAudioErrors = 0; } catch (e) { console.error('Audio playback failed:', e); if (url) URL.revokeObjectURL(url); // prevent leak on play() rejection consecutiveAudioErrors++; if (consecutiveAudioErrors >= AUDIO_ERROR_LIMIT) { console.error(`Audio failed ${AUDIO_ERROR_LIMIT} times in a row — stopping playback`); isPlaying = false; setSpeaking(false); setStatus('error', 'Audio error'); audioQueue.length = 0; return; } playNext(); } } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- async function init() { // Grab DOM refs statusDot = document.getElementById('statusDot'); statusText = document.getElementById('statusText'); speakingIndicator = document.getElementById('speakingIndicator'); lastMessage = document.getElementById('lastMessage'); volumeSlider = document.getElementById('volume'); hostInput = document.getElementById('hostInput'); portInput = document.getElementById('portInput'); saveBtn = document.getElementById('saveBtn'); // Load config from main process (merged saved + CLI args) try { config = await window.kokoro.getConfig(); } catch (e) { console.error('Failed to load config:', e); } // Populate UI with loaded values volumeSlider.value = config.volume; hostInput.value = config.host; portInput.value = config.port; // Volume slider: live update + debounce save volumeSlider.addEventListener('input', () => { config.volume = parseInt(volumeSlider.value, 10); clearTimeout(volumeSaveTimer); volumeSaveTimer = setTimeout(async () => { try { await window.kokoro.saveConfig({ volume: config.volume }); } catch (e) { console.error('Failed to save volume:', e); } }, 500); }); // Save button: update host/port, save, reconnect saveBtn.addEventListener('click', async () => { const newHost = hostInput.value.trim() || 'localhost'; const newPort = parseInt(portInput.value, 10); const validPort = (!isNaN(newPort) && newPort > 0 && newPort <= 65535) ? newPort : 8888; hostInput.value = newHost; portInput.value = validPort; try { config = await window.kokoro.saveConfig({ host: newHost, port: validPort }); // Visual feedback saveBtn.textContent = 'Saved!'; saveBtn.classList.add('saved'); setTimeout(() => { saveBtn.textContent = 'Save'; saveBtn.classList.remove('saved'); }, 1500); // Reconnect with new settings connect(); } catch (e) { console.error('Failed to save config:', e); } }); // Connect to WebSocket connect(); } document.addEventListener('DOMContentLoaded', init);