Files
nonna add0f803cd Fix status flicker during WebSocket reconnect loop
connect() was unconditionally calling setStatus('connecting', ...) at the
top of each attempt, causing a visible flicker over "Disconnected —
retrying..." every 3 seconds during the retry loop.

Fix: add everConnected flag (was missing from Neutralino version);
connect() only shows "Connecting..." on the first attempt. onclose now
uses everConnected instead of _hadError to determine state. onerror no
longer sets its own status — onclose is the sole status updater on
disconnect, eliminating a second flicker source. everConnected is reset
on Save so pointing at a new server shows "Connecting..." cleanly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:19:51 +00:00

356 lines
10 KiB
JavaScript

'use strict';
// ---------------------------------------------------------------------------
// Config defaults and storage key
// ---------------------------------------------------------------------------
const CONFIG_DEFAULTS = { host: 'localhost', port: 8888, volume: 80 };
const STORAGE_KEY = 'kokoro-config';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let config = { ...CONFIG_DEFAULTS };
let ws = null;
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
let statusDot, statusText, speakingIndicator, lastMessage;
let volumeSlider, hostInput, portInput, saveBtn;
// ---------------------------------------------------------------------------
// Config persistence via Neutralino storage
// ---------------------------------------------------------------------------
async function loadConfig() {
try {
const raw = await Neutralino.storage.getData(STORAGE_KEY);
return Object.assign({}, CONFIG_DEFAULTS, JSON.parse(raw));
} catch {
return { ...CONFIG_DEFAULTS };
}
}
async function saveConfig(partial) {
config = Object.assign({}, config, partial);
try {
await Neutralino.storage.setData(STORAGE_KEY, JSON.stringify(config));
} catch (e) {
console.error('Failed to save config:', e);
}
return config;
}
// ---------------------------------------------------------------------------
// CLI argument parsing — Neutralino exposes args via NL_ARGS global
// ---------------------------------------------------------------------------
function parseCliArgs() {
const overrides = {};
// NL_ARGS is a space-joined string of all args passed after --
const args = (typeof NL_ARGS !== 'undefined' ? NL_ARGS : '').split(' ').filter(Boolean);
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--host':
if (args[i + 1]) overrides.host = args[++i];
break;
case '--port': {
const p = parseInt(args[++i], 10);
if (!isNaN(p)) overrides.port = p;
break;
}
case '--volume': {
const v = parseInt(args[++i], 10);
if (!isNaN(v)) overrides.volume = Math.min(100, Math.max(0, v));
break;
}
}
}
return overrides;
}
// ---------------------------------------------------------------------------
// Tray setup
// ---------------------------------------------------------------------------
async function setupTray() {
try {
await Neutralino.os.setTray({
icon: '/www/icon.png',
menuItems: [
{ id: 'tray_show', text: 'Show' },
{ id: 'tray_sep', text: '-' },
{ id: 'tray_quit', text: 'Quit' },
],
});
} catch (e) {
console.warn('Tray setup failed (may not be supported on this platform):', e);
}
}
// Handle tray menu clicks
Neutralino.events.on('trayMenuItemClicked', async (evt) => {
switch (evt.detail.id) {
case 'tray_show':
await Neutralino.window.show();
await Neutralino.window.focus();
break;
case 'tray_quit':
await Neutralino.app.exit();
break;
}
});
// Intercept window close — hide to tray instead of quitting
Neutralino.events.on('windowClose', async () => {
await Neutralino.window.hide();
});
// ---------------------------------------------------------------------------
// Status / speaking display
// ---------------------------------------------------------------------------
function setStatus(state, text) {
statusDot.className = 'status-dot ' + state;
statusText.textContent = text;
}
function setSpeaking(active) {
speakingIndicator.classList.toggle('active', active);
}
// ---------------------------------------------------------------------------
// WebSocket connection
// ---------------------------------------------------------------------------
function getWsUrl() {
return `ws://${config.host}:${config.port}/stream`;
}
function connect() {
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
clearReconnectTimer();
// Only show "Connecting..." on the very first attempt.
// Once we've ever connected, preserve "Disconnected — retrying..." until
// onopen fires so there's no flicker during the reconnect loop.
if (!everConnected) {
setStatus('connecting', 'Connecting...');
}
try {
ws = new WebSocket(getWsUrl());
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
everConnected = true;
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) {
setStatus('connected', 'Connected');
if (audioQueue.length < AUDIO_QUEUE_MAX) {
audioQueue.push(event.data);
if (!isPlaying) playNext();
} else {
console.warn('Audio queue full — dropping frame');
}
}
};
ws.onclose = () => {
ws = null;
if (everConnected) {
setStatus('error', 'Disconnected — retrying...');
} else {
setStatus('connecting', 'Connecting...');
}
scheduleReconnect();
};
ws.onerror = () => {
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) {
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);
consecutiveAudioErrors++;
if (consecutiveAudioErrors >= AUDIO_ERROR_LIMIT) {
isPlaying = false;
setSpeaking(false);
setStatus('error', 'Audio error');
audioQueue.length = 0;
return;
}
playNext();
}
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
async function init() {
// Wire 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 persisted config then apply CLI overrides (CLI does not persist)
config = await loadConfig();
const cliOverrides = parseCliArgs();
if (cliOverrides.host !== undefined) config.host = cliOverrides.host;
if (cliOverrides.port !== undefined) config.port = cliOverrides.port;
if (cliOverrides.volume !== undefined) config.volume = cliOverrides.volume;
// Populate UI
volumeSlider.value = config.volume;
hostInput.value = config.host;
portInput.value = config.port;
// Volume: live update + debounce save
volumeSlider.addEventListener('input', () => {
config.volume = parseInt(volumeSlider.value, 10);
clearTimeout(volumeSaveTimer);
volumeSaveTimer = setTimeout(() => saveConfig({ volume: config.volume }), 500);
});
// Save button
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;
await saveConfig({ host: newHost, port: validPort });
saveBtn.textContent = 'Saved!';
saveBtn.classList.add('saved');
setTimeout(() => {
saveBtn.textContent = 'Save';
saveBtn.classList.remove('saved');
}, 1500);
// Treat as fresh connection to the new server
everConnected = false;
connect();
});
// Draggable header
try {
await Neutralino.window.setDraggableRegion('dragRegion');
} catch (e) {
console.warn('setDraggableRegion not supported:', e);
}
// Set up tray
await setupTray();
// Connect to voice server
connect();
}
// Boot: wait for BOTH the DOM and the Neutralino runtime to be ready.
// "ready" guarantees native APIs (storage, tray, window) are live.
// DOMContentLoaded guarantees DOM refs exist.
// We race neither — resolve both before calling init().
let domReady = false;
let neuReady = false;
function maybeInit() {
if (domReady && neuReady) init();
}
Neutralino.init();
Neutralino.events.on('ready', () => { neuReady = true; maybeInit(); });
document.addEventListener('DOMContentLoaded', () => { domReady = true; maybeInit(); });