// ============================================================ // WebSocket: Connection, reconnection, authentication // ============================================================ import { dom, t, setWs, getWs, WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS, authRequired, showUpdateBanner, } from './core.js'; import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js'; import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js'; import { loadCallbacksTable } from './callbacks.js'; import { loadHeaderLinks, loadLinksTable } from './links.js'; import { updateForegroundUI } from './foreground.js'; let reconnectTimeout = null; let wsReconnectAttempts = 0; // Track the ping interval against the socket that owns it so we never leak // a timer if connectWebSocket() is called while a previous socket is still // alive. The pair is wiped on close to avoid double-clear races. let activeSocket = null; let activePingInterval = null; function clearReconnect() { if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } } function clearPing() { if (activePingInterval) { clearInterval(activePingInterval); activePingInterval = null; } } export function showAuthForm(errorMessage = '') { const overlay = document.getElementById('auth-overlay'); overlay.classList.remove('hidden'); const errorEl = document.getElementById('auth-error'); if (errorMessage) { errorEl.textContent = errorMessage; errorEl.classList.add('visible'); } else { errorEl.classList.remove('visible'); } } function hideAuthForm() { document.getElementById('auth-overlay').classList.add('hidden'); } export function authenticate() { const token = document.getElementById('token-input').value.trim(); if (!token) { showAuthForm(t('auth.required')); return; } localStorage.setItem('media_server_token', token); connectWebSocket(token); } export function clearToken() { localStorage.removeItem('media_server_token'); const current = getWs(); if (current) { try { current.close(1000, 'token cleared'); } catch { /* ignore */ } } showAuthForm(t('auth.cleared')); } export function connectWebSocket(token) { // Always cancel a pending reconnect first — otherwise a user-triggered // reconnect can race a scheduled one and create two live sockets. clearReconnect(); clearPing(); // Close any previous socket cleanly before opening a new one. const previous = activeSocket; activeSocket = null; if (previous && previous.readyState <= WebSocket.OPEN) { try { previous.close(1000, 'reconnecting'); } catch { /* ignore */ } } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsBase = `${protocol}//${window.location.host}/api/media/ws`; // Prefer Sec-WebSocket-Protocol-based auth so the token never appears in // the URL (which would otherwise land in browser history, server access // logs, and Referer headers). Keep the ?token=... fallback for clients // that pre-date this change and don't speak the subprotocol. let newWs; if (token) { try { newWs = new WebSocket(wsBase, [`media-server.token.${token}`]); } catch (e) { console.warn('Subprotocol WS handshake failed, falling back to ?token=', e); newWs = new WebSocket(`${wsBase}?token=${encodeURIComponent(token)}`); } } else { newWs = new WebSocket(wsBase); } activeSocket = newWs; setWs(newWs); newWs.onopen = () => { console.log('WebSocket connected'); wsReconnectAttempts = 0; updateConnectionStatus(true); hideConnectionBanner(); hideAuthForm(); loadScripts(); loadScriptsTable(); loadCallbacksTable(); loadLinksTable(); loadHeaderLinks(); loadAudioDevices(); }; newWs.onmessage = (event) => { let msg; try { msg = JSON.parse(event.data); } catch (err) { console.warn('Ignoring malformed WebSocket frame:', err); return; } if (msg.type === 'status' || msg.type === 'status_update') { updateUI(msg.data); } else if (msg.type === 'foreground' || msg.type === 'foreground_update') { updateForegroundUI(msg.data); } else if (msg.type === 'scripts_changed') { console.log('Scripts changed, reloading...'); loadScripts(); loadScriptsTable(); } else if (msg.type === 'links_changed') { console.log('Links changed, reloading...'); loadHeaderLinks(); loadLinksTable(); displayQuickAccess(); } else if (msg.type === 'update_available') { showUpdateBanner(msg.data); } else if (msg.type === 'audio_data') { setFrequencyData(msg.data); } else if (msg.type === 'error') { console.error('WebSocket error:', msg.message); } }; newWs.onerror = (error) => { console.error('WebSocket error:', error); updateConnectionStatus(false); }; newWs.onclose = (event) => { console.log('WebSocket closed:', event.code); updateConnectionStatus(false); stopPositionInterpolation(); // Drop this socket's ping interval. Guard so we don't kill a newer // socket's interval if reconnect already started. if (activeSocket === newWs) { clearPing(); activeSocket = null; } if (event.code === 4001) { localStorage.removeItem('media_server_token'); showAuthForm(t('auth.invalid')); } else if (event.code !== 1000) { wsReconnectAttempts++; if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) { const delay = Math.min( WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1), WS_BACKOFF_MAX_MS ); console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`); if (wsReconnectAttempts >= 3) { showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false); } clearReconnect(); reconnectTimeout = setTimeout(() => { reconnectTimeout = null; const savedToken = localStorage.getItem('media_server_token'); if (savedToken || !authRequired) { connectWebSocket(savedToken || ''); } }, delay); } else { showConnectionBanner(t('connection.lost'), true); } } }; clearPing(); activePingInterval = setInterval(() => { if (newWs.readyState === WebSocket.OPEN) { try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ } } }, WS_PING_INTERVAL_MS); } export function updateConnectionStatus(connected) { if (connected) { dom.statusDot.classList.add('connected'); } else { dom.statusDot.classList.remove('connected'); } } function showConnectionBanner(message, showButton) { const banner = document.getElementById('connectionBanner'); const text = document.getElementById('connectionBannerText'); const btn = document.getElementById('connectionBannerBtn'); text.textContent = message; btn.style.display = showButton ? '' : 'none'; banner.classList.remove('hidden'); } function hideConnectionBanner() { const banner = document.getElementById('connectionBanner'); banner.classList.add('hidden'); } export function manualReconnect() { const savedToken = localStorage.getItem('media_server_token'); if (savedToken || !authRequired) { wsReconnectAttempts = 0; hideConnectionBanner(); connectWebSocket(savedToken || ''); } } // When the browser regains connectivity or the tab becomes visible again, // drop the backoff and reconnect immediately rather than waiting out the // current timer. function reconnectIfNeeded() { const current = activeSocket; if (current && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) { return; } const savedToken = localStorage.getItem('media_server_token'); if (savedToken || !authRequired) { wsReconnectAttempts = 0; connectWebSocket(savedToken || ''); } } window.addEventListener('online', reconnectIfNeeded); document.addEventListener('visibilitychange', () => { if (!document.hidden) reconnectIfNeeded(); });