feat: add controls module with debug dashboard and override support

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-20 13:00:22 -04:00
parent cddfe9125d
commit f754b227b3

360
js/controls.js vendored Normal file
View File

@@ -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<string, unknown>} */ (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;
}
};
}