Lightweight alternative to the Electron build (~5MB vs ~300MB). Uses system webview (WebKit2GTK on Linux, WKWebView on macOS, WebView2 on Windows). Same UI and feature set as the Electron build: WebSocket audio client, volume control, host/port settings, tray icon, hide-to-tray on close, CLI args (--host, --port, --volume). Note: Linux requires libwebkit2gtk-4.0 or libwebkit2gtk-4.1. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
335 lines
9.7 KiB
JavaScript
335 lines
9.7 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 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();
|
|
setStatus('connecting', 'Connecting...');
|
|
|
|
try {
|
|
ws = new WebSocket(getWsUrl());
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = () => {
|
|
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 = () => {
|
|
const hadError = ws && ws._hadError;
|
|
ws = null;
|
|
setStatus(hadError ? 'error' : 'connecting',
|
|
hadError ? 'Disconnected — retrying...' : 'Reconnecting...');
|
|
scheduleReconnect();
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (ws) ws._hadError = true;
|
|
setStatus('error', 'Connection error');
|
|
};
|
|
} 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);
|
|
|
|
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 after Neutralino is ready
|
|
Neutralino.init();
|
|
document.addEventListener('DOMContentLoaded', init);
|