diff --git a/README.md b/README.md index 0b7b063..42b4fb7 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,11 @@ Schema migrations run automatically on first boot — no manual migration comman | `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ | | `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` | | `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` | +| `PING_INTERVAL` | Socket.IO Engine.IO ping interval (ms). Raise for slow TV WebKits that miss pongs under decode load. | `30000` | +| `PING_TIMEOUT` | Socket.IO Engine.IO pong wait (ms). Lower = faster dead-socket detection; higher = more forgiving of laggy clients. | `30000` | +| `HEARTBEAT_INTERVAL` | App-level offline-checker frequency (ms). How often the server sweeps the device list looking for stale heartbeats. | `10000` | +| `HEARTBEAT_TIMEOUT` | How long without an app-level heartbeat (ms) before marking a device offline. Raise for slow/jittery networks. | `45000` | +| `COMMAND_QUEUE_TTL_MS` | How long the server holds commands and playlist-updates for a device that's offline at emit time (ms). Flushed in order on reconnect within this window; dropped past TTL. | `30000` | ### Optional Integrations diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index d6088f9..b2e9b40 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -369,6 +369,9 @@ export default { 'device.toast.launch_sent': 'Launch command sent', 'device.toast.update_triggered': 'Update check triggered', 'device.toast.remote_started': 'Remote session started', + 'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect', + 'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable', + 'device.toast.command_no_ack': '{cmd} — no server response', // Settings 'settings.title': 'Settings', diff --git a/frontend/js/socket.js b/frontend/js/socket.js index 3ba61bf..069428a 100644 --- a/frontend/js/socket.js +++ b/frontend/js/socket.js @@ -119,8 +119,20 @@ export function sendKey(deviceId, keycode) { if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode }); } -export function sendCommand(deviceId, type, payload) { - if (dashboardSocket) dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload }); +// Optional callback receives the server-side ack: { delivered, queued, reason }. +// Callers without a callback keep firing-and-forgetting (no behavior change). +// With a callback, we use Socket.IO's .timeout() so the callback always fires - +// either with the ack or with an Error if the server doesn't respond in 5s. +export function sendCommand(deviceId, type, payload, callback) { + if (!dashboardSocket) return; + if (typeof callback === 'function') { + dashboardSocket.timeout(5000).emit('dashboard:device-command', { device_id: deviceId, type, payload }, (err, ack) => { + if (err) callback({ delivered: false, reason: 'no_ack' }); + else callback(ack || { delivered: false, reason: 'no_ack' }); + }); + } else { + dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload }); + } } export function getSocket() { return dashboardSocket; } diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index dc9356e..c44ba51 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -705,14 +705,26 @@ async function setupActions(device) { }, 3000); }); + // Send a command and surface the three-state ack as a toast. + // - delivered: device received it (green/success) + // - queued: device is offline, will deliver on reconnect (amber/warning) + // - no_ack / fallback: server didn't respond or queue unavailable (red/error) + function sendWithFeedback(type, cmdLabel, successKey) { + sendCommand(device.id, type, {}, (ack) => { + if (ack?.delivered) showToast(t(successKey), 'success'); + else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning'); + else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error'); + else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error'); + }); + } + // Reboot (double-click to confirm) const rebootBtn = document.getElementById('rebootBtn'); let rebootConfirming = false; let rebootTimeout = null; rebootBtn?.addEventListener('click', () => { if (rebootConfirming) { - sendCommand(device.id, 'reboot', {}); - showToast(t('device.toast.reboot_sent'), 'info'); + sendWithFeedback('reboot', 'Reboot', 'device.toast.reboot_sent'); rebootConfirming = false; rebootBtn.textContent = t('device.ctl.reboot_device'); return; @@ -732,8 +744,7 @@ async function setupActions(device) { let shutdownTimeout = null; shutdownBtn?.addEventListener('click', () => { if (shutdownConfirming) { - sendCommand(device.id, 'shutdown', {}); - showToast(t('device.toast.shutdown_sent'), 'info'); + sendWithFeedback('shutdown', 'Shutdown', 'device.toast.shutdown_sent'); shutdownConfirming = false; shutdownBtn.textContent = t('device.ctl.shutdown'); return; @@ -753,26 +764,22 @@ async function setupActions(device) { // Screen Off document.getElementById('screenOffBtn')?.addEventListener('click', () => { - sendCommand(device.id, 'screen_off', {}); - showToast(t('device.toast.screen_off_sent'), 'info'); + sendWithFeedback('screen_off', 'Screen off', 'device.toast.screen_off_sent'); }); // Screen On document.getElementById('screenOnBtn')?.addEventListener('click', () => { - sendCommand(device.id, 'screen_on', {}); - showToast(t('device.toast.screen_on_sent'), 'info'); + sendWithFeedback('screen_on', 'Screen on', 'device.toast.screen_on_sent'); }); // Launch Player document.getElementById('launchAppBtn')?.addEventListener('click', () => { - sendCommand(device.id, 'launch', {}); - showToast(t('device.toast.launch_sent'), 'info'); + sendWithFeedback('launch', 'Launch', 'device.toast.launch_sent'); }); // Force Update document.getElementById('forceUpdateBtn')?.addEventListener('click', () => { - sendCommand(device.id, 'update', {}); - showToast(t('device.toast.update_triggered'), 'info'); + sendWithFeedback('update', 'Update', 'device.toast.update_triggered'); }); } diff --git a/frontend/landing.html b/frontend/landing.html index 3ff6fd3..30d64db 100644 --- a/frontend/landing.html +++ b/frontend/landing.html @@ -85,6 +85,24 @@ .platform-item .icon { font-size:40px; margin-bottom:8px; } .platform-item .name { font-size:13px; color:var(--muted); } + /* Modal (mirrors frontend/css/main.css conventions so screenshots look + consistent across landing and dashboard; copied inline since + landing.html doesn't import main.css). */ + .modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); display:flex; align-items:center; justify-content:center; z-index:1000; padding:16px; } + .modal { background:var(--card); border:1px solid var(--border); border-radius:12px; width:100%; max-width:560px; max-height:90vh; overflow-y:auto; } + .modal-header { padding:20px 24px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; } + .modal-header h3 { font-size:18px; margin:0; } + .modal-close { background:none; border:none; color:var(--muted); font-size:24px; cursor:pointer; padding:0; line-height:1; } + .modal-body { padding:20px 24px; } + .modal-description { color:var(--muted); font-size:14px; margin-bottom:16px; } + .modal-footer { padding:16px 24px; border-top:1px solid var(--border); display:flex; gap:12px; justify-content:flex-end; } + .modal-body label { display:block; margin-bottom:12px; font-size:13px; color:var(--text); } + .modal-body input, .modal-body select, .modal-body textarea { width:100%; margin-top:4px; padding:8px 10px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-size:14px; font-family:inherit; box-sizing:border-box; } + .modal-body input:focus, .modal-body select:focus, .modal-body textarea:focus { outline:none; border-color:var(--accent); } + .modal-body textarea { resize:vertical; } + .contact-status-success { color:#10b981; font-size:13px; } + .contact-status-error { color:#f87171; font-size:13px; } + /* Pricing */ .pricing { max-width:1200px; margin:0 auto; padding:80px 24px; } .pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; } @@ -376,6 +394,56 @@ + +
+