Browsers block ES module imports over the file:// protocol due to CORS. Users opening the overlay by double-clicking the HTML file saw all JS fail to load. Replace import/export with a window.OBS global namespace and classic <script> tags so the overlay works without a local server. Made-with: Cursor
365 lines
12 KiB
JavaScript
365 lines
12 KiB
JavaScript
/**
|
|
* Debug dashboard, manual overrides, and bindings for the controls panel.
|
|
*/
|
|
|
|
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
|
|
*/
|
|
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(window.OBS.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, meterFill?: number }} */ (s);
|
|
if (!rc.active) return 'Inactive';
|
|
const meter = rc.meterFill != null ? ` | Meter: ${rc.meterFill}%` : '';
|
|
return `Active (${rc.roomCode ?? ''})${meter}`;
|
|
}
|
|
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}
|
|
*/
|
|
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;
|
|
}
|
|
};
|
|
}
|
|
|
|
window.OBS = window.OBS || {};
|
|
window.OBS.initControls = initControls;
|
|
window.OBS.initConnectionStatusHandler = initConnectionStatusHandler;
|