feat: add controls module with debug dashboard and override support
Made-with: Cursor
This commit is contained in:
360
js/controls.js
vendored
Normal file
360
js/controls.js
vendored
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user