From f754b227b3e68d353c785ef06a6ef1f45d7dd90d Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 13:00:22 -0400 Subject: [PATCH] feat: add controls module with debug dashboard and override support Made-with: Cursor --- js/controls.js | 360 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 js/controls.js diff --git a/js/controls.js b/js/controls.js new file mode 100644 index 0000000..55bcca4 --- /dev/null +++ b/js/controls.js @@ -0,0 +1,360 @@ +/** + * Debug dashboard, manual overrides, and bindings for the controls panel. + */ + +import { OVERRIDE_MODES } from './state-manager.js'; + +const STATE_COLORS = Object.freeze({ + idle: '#888', + lobby: '#4CAF50', + playing: '#f0ad4e', + ended: '#d9534f', + disconnected: '#d9534f', +}); + +const STORAGE_API_URL = 'jackbox-api-url'; +const STORAGE_API_KEY = 'jackbox-api-key'; + +/** + * @param {import('./state-manager.js').OverlayManager} manager + * @param {import('./websocket-client.js').WebSocketClient} wsClient + * @param {{ roomCode?: unknown, audio?: unknown, playerList?: unknown }} components + */ +export function initControls(manager, wsClient, components) { + const stateEl = document.getElementById('manager-state'); + const roomCodeEl = document.getElementById('manager-room-code'); + const sessionIdEl = document.getElementById('manager-session-id'); + const gameTitleEl = document.getElementById('manager-game-title'); + const playerCountEl = document.getElementById('manager-player-count'); + const eventLogEl = document.getElementById('manager-event-log'); + + const componentNames = Object.keys(components); + + for (const name of componentNames) { + const select = document.getElementById(`override-${name}`); + if (!select) continue; + if (select.options.length === 0) { + for (const mode of Object.values(OVERRIDE_MODES)) { + const opt = document.createElement('option'); + opt.value = mode; + opt.textContent = mode.replace(/_/g, ' '); + select.appendChild(opt); + } + } + select.value = manager.getOverride(name); + select.addEventListener('change', () => { + manager.setOverride(name, select.value); + }); + } + + function formatGameTitleLine(ctx) { + const g = ctx.game; + if (!g || typeof g !== 'object') return '—'; + const rec = /** @type {Record} */ (g); + const title = + (typeof rec.title === 'string' && rec.title) || + (typeof rec.name === 'string' && rec.name) || + ''; + const pack = + (typeof rec.pack_name === 'string' && rec.pack_name) || + (typeof rec.packName === 'string' && rec.packName) || + (typeof rec.pack === 'string' && rec.pack) || + ''; + if (!title) return '—'; + return pack ? `${title} (${pack})` : title; + } + + function formatStatusRow(name, info) { + const s = info?.status; + if (!s || typeof s !== 'object') return '—'; + if (name === 'roomCode') { + const rc = /** @type {{ active?: boolean, roomCode?: string }} */ (s); + return rc.active ? `Active (${rc.roomCode ?? ''})` : 'Inactive'; + } + if (name === 'audio') { + const a = /** @type {{ active?: boolean, playing?: boolean }} */ (s); + if (!a.active) return 'Inactive'; + return a.playing ? 'Playing' : 'Active (muted)'; + } + if (name === 'playerList') { + const pl = /** @type {{ active?: boolean, playerCount?: number, maxPlayers?: number }} */ ( + s + ); + if (!pl.active) return 'Inactive'; + const n = pl.playerCount ?? 0; + const m = pl.maxPlayers ?? '?'; + return `Active (${n}/${m})`; + } + return /** @type {{ active?: boolean }} */ (s).active ? 'Active' : 'Inactive'; + } + + function updateDashboard() { + const state = manager.getState(); + const ctx = manager.getContext(); + + if (stateEl) { + stateEl.textContent = state.toUpperCase(); + stateEl.style.backgroundColor = STATE_COLORS[state] ?? '#888'; + } + if (roomCodeEl) { + roomCodeEl.textContent = ctx.roomCode != null ? String(ctx.roomCode) : '—'; + } + if (sessionIdEl) { + sessionIdEl.textContent = ctx.sessionId != null ? String(ctx.sessionId) : '—'; + } + if (gameTitleEl) { + gameTitleEl.textContent = formatGameTitleLine(ctx); + } + if (playerCountEl) { + const n = ctx.playerCount ?? 0; + const m = ctx.maxPlayers != null ? String(ctx.maxPlayers) : '?'; + playerCountEl.textContent = `${n}/${m}`; + } + + const statuses = manager.getComponentStatuses(); + for (const name of componentNames) { + const row = document.getElementById(`status-${name}`); + if (!row) continue; + const info = statuses[name]; + row.textContent = formatStatusRow(name, info); + } + + for (const name of componentNames) { + const sel = document.getElementById(`override-${name}`); + if (!sel) continue; + const want = manager.getOverride(name); + if (sel.value !== want) sel.value = want; + } + + if (eventLogEl) { + const log = manager.getEventLog(); + const last = log.slice(-20); + eventLogEl.replaceChildren(); + for (const e of last) { + const row = document.createElement('div'); + row.className = 'event-log-entry'; + const time = document.createElement('span'); + time.className = 'event-time'; + time.textContent = new Date(e.at).toLocaleTimeString(); + const typ = document.createElement('span'); + typ.className = 'event-type'; + typ.textContent = e.type; + row.appendChild(time); + row.appendChild(document.createTextNode(' ')); + row.appendChild(typ); + eventLogEl.appendChild(row); + } + eventLogEl.scrollTop = eventLogEl.scrollHeight; + } + + syncToggleDisplayLabel(); + } + + const toggleDisplayBtn = document.getElementById('toggle-display-btn'); + + function syncToggleDisplayLabel() { + if (!toggleDisplayBtn) return; + const s = manager.getState(); + toggleDisplayBtn.textContent = s === 'lobby' ? 'Hide Display' : 'Show Display'; + } + + manager.onChange(() => { + updateDashboard(); + }); + + updateDashboard(); + + const connectBtn = document.getElementById('ws-connect-btn'); + const disconnectBtn = document.getElementById('ws-disconnect-btn'); + const apiUrlInput = document.getElementById('api-url-input'); + const apiKeyInput = document.getElementById('api-key-input'); + + if (connectBtn && apiUrlInput && apiKeyInput) { + connectBtn.addEventListener('click', () => { + const url = apiUrlInput.value.trim(); + const key = apiKeyInput.value.trim(); + if (url) localStorage.setItem(STORAGE_API_URL, url); + if (key) localStorage.setItem(STORAGE_API_KEY, key); + void wsClient.connect(url, key); + }); + } + + if (disconnectBtn) { + disconnectBtn.addEventListener('click', () => { + wsClient.disconnect(); + manager.handleEvent('session.ended', { session: {} }); + }); + } + + apiUrlInput?.addEventListener('change', () => { + const url = apiUrlInput.value.trim(); + if (url) localStorage.setItem(STORAGE_API_URL, url); + }); + apiKeyInput?.addEventListener('change', () => { + localStorage.setItem(STORAGE_API_KEY, apiKeyInput.value.trim()); + }); + + const volumeSlider = document.getElementById('volume-slider'); + const volumeValue = document.getElementById('volume-value'); + const themeSound = /** @type {HTMLAudioElement | null} */ ( + document.getElementById('theme-sound') + ); + + if (volumeSlider && volumeValue) { + volumeSlider.addEventListener('input', () => { + const v = Number(volumeSlider.value); + volumeValue.textContent = `${Math.round(v * 100)}%`; + if (themeSound) themeSound.volume = v; + }); + } + + const testSoundBtn = document.getElementById('test-sound-btn'); + const soundUrlInput = document.getElementById('sound-url-input'); + if (testSoundBtn && themeSound && soundUrlInput && volumeSlider) { + testSoundBtn.addEventListener('click', () => { + themeSound.src = soundUrlInput.value; + themeSound.volume = Number(volumeSlider.value); + themeSound.currentTime = 0; + themeSound.loop = false; + themeSound.play().catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + console.log('[Test] Playback failed:', msg); + window.alert( + 'Audio playback failed. Check the URL or browser autoplay permissions.', + ); + }); + window.setTimeout(() => { + themeSound.pause(); + themeSound.loop = true; + }, 5000); + }); + } + + function synthesizeGameAddedFromInputs() { + const code1 = document.getElementById('code1-input')?.value ?? ''; + const code2 = document.getElementById('code2-input')?.value ?? ''; + const roomCode = `${code1}${code2}`.trim(); + if (!roomCode) return; + manager.handleEvent('game.added', { + session: {}, + game: { + room_code: roomCode, + title: 'Manual', + max_players: 8, + }, + }); + } + + const updateBtn = document.getElementById('update-btn'); + const previewBtn = document.getElementById('preview-btn'); + updateBtn?.addEventListener('click', () => { + synthesizeGameAddedFromInputs(); + }); + previewBtn?.addEventListener('click', () => { + synthesizeGameAddedFromInputs(); + }); + + if (toggleDisplayBtn) { + toggleDisplayBtn.addEventListener('click', () => { + const s = manager.getState(); + if (s === 'lobby') { + manager.handleEvent('session.ended', { session: {} }); + } else if (s === 'idle') { + const code1 = document.getElementById('code1-input')?.value ?? ''; + const code2 = document.getElementById('code2-input')?.value ?? ''; + const roomCode = `${code1}${code2}`.trim(); + if (roomCode) { + manager.handleEvent('game.added', { + session: {}, + game: { + room_code: roomCode, + title: 'Display', + max_players: 8, + }, + }); + } + } + syncToggleDisplayLabel(); + }); + } + + const showControlsBtn = document.getElementById('show-controls-btn'); + const controls = document.getElementById('controls'); + if (showControlsBtn && controls) { + showControlsBtn.addEventListener('click', () => { + if (controls.style.display === 'block') { + controls.style.display = 'none'; + showControlsBtn.textContent = 'Show Controls'; + } else { + controls.style.display = 'block'; + showControlsBtn.textContent = 'Hide Controls'; + } + }); + } + + document.querySelectorAll('.section-header').forEach((hdr) => { + hdr.addEventListener('click', () => { + const content = hdr.nextElementSibling; + if (!(content instanceof HTMLElement)) return; + const indicator = hdr.querySelector('.toggle-indicator'); + if (content.style.display === 'block') { + content.style.display = 'none'; + indicator?.classList.remove('active'); + hdr.classList.remove('active'); + } else { + content.style.display = 'block'; + indicator?.classList.add('active'); + hdr.classList.add('active'); + } + }); + }); + + const positionToggle = document.getElementById('position-toggle'); + if (positionToggle && controls) { + positionToggle.addEventListener('change', () => { + controls.classList.toggle('bottom-position', positionToggle.checked); + }); + } + + let inactivityTimer = 0; + function resetInactivityTimer() { + window.clearTimeout(inactivityTimer); + inactivityTimer = window.setTimeout(() => { + if (controls) controls.style.display = 'none'; + if (showControlsBtn) showControlsBtn.textContent = 'Show Controls'; + }, 20000); + } + document.addEventListener('mousemove', resetInactivityTimer); + document.addEventListener('keypress', resetInactivityTimer); + document.addEventListener('click', resetInactivityTimer); + resetInactivityTimer(); +} + +/** + * @returns {(state: string, message?: string) => void} + */ +export function initConnectionStatusHandler() { + const wsStatusDot = document.getElementById('ws-status-dot'); + const wsStatusText = document.getElementById('ws-status-text'); + const wsConnectBtn = document.getElementById('ws-connect-btn'); + const wsDisconnectRow = document.getElementById('ws-disconnect-row'); + const apiUrlInput = document.getElementById('api-url-input'); + const apiKeyInput = document.getElementById('api-key-input'); + + return (state, message) => { + if (wsStatusDot) wsStatusDot.className = `status-dot ${state}`; + if (wsStatusText) wsStatusText.textContent = message ?? String(state); + + if (state === 'connected') { + if (wsConnectBtn) wsConnectBtn.style.display = 'none'; + if (wsDisconnectRow) wsDisconnectRow.style.display = 'flex'; + if (apiUrlInput) apiUrlInput.disabled = true; + if (apiKeyInput) apiKeyInput.disabled = true; + } else { + if (wsConnectBtn) wsConnectBtn.style.display = 'block'; + if (wsDisconnectRow) wsDisconnectRow.style.display = 'none'; + if (apiUrlInput) apiUrlInput.disabled = false; + if (apiKeyInput) apiKeyInput.disabled = false; + } + }; +}