Initial Neutralino.js POC for kokoro-widget
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>
This commit is contained in:
334
www/app.js
Normal file
334
www/app.js
Normal file
@@ -0,0 +1,334 @@
|
||||
'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);
|
||||
Reference in New Issue
Block a user