Compare commits

...

10 Commits

Author SHA1 Message Date
cottongin
875153ef63 fix: address code review issues from final review
- Override modes now actually affect component visibility (force_show,
  force_hide, auto respected in transitions and updates)
- RoomCodeDisplay.update() guarded by #active flag to prevent
  re-activation outside lobby state
- broadcastUpdate() only sends to components when in lobby state
- Relaxed idle→playing/ended transitions for out-of-order events
- room.disconnected now clears room code, players, lobby state context
- Empty room codes clear input fields instead of leaving stale values
- Removed dead auto-mode-toggle UI and associated CSS
- Fixed player list vertical centering when offset is applied

Made-with: Cursor
2026-03-20 13:08:52 -04:00
cottongin
19c94d294f feat: integrate module system, add player list + dashboard DOM
Made-with: Cursor
2026-03-20 13:02:37 -04:00
cottongin
f754b227b3 feat: add controls module with debug dashboard and override support
Made-with: Cursor
2026-03-20 13:00:22 -04:00
cottongin
cddfe9125d feat: add player list component with slot-based display
Made-with: Cursor
2026-03-20 12:58:29 -04:00
cottongin
f0db0e8642 feat: extract audio controller into ES module, fix restart bug
Made-with: Cursor
2026-03-20 12:57:07 -04:00
cottongin
6b78928269 feat: extract room code display into ES module component
Made-with: Cursor
2026-03-20 12:55:01 -04:00
cottongin
1ed647208e feat: extract WebSocket client into ES module
Made-with: Cursor
2026-03-20 12:52:59 -04:00
cottongin
284830a24b feat: add OverlayManager state machine module
Made-with: Cursor
2026-03-20 12:50:48 -04:00
cottongin
41773d0fef Add implementation plan for overlay manager and player list
8 tasks covering: state machine, WebSocket client extraction, room code
display component, audio controller (restart fix), player list, debug
dashboard, HTML integration, and end-to-end verification.

Made-with: Cursor
2026-03-20 12:47:37 -04:00
cottongin
c049cddb6d Add design doc for overlay manager and player list
Event-driven state machine to coordinate room code display, audio,
and a new player slot list. Fixes audio restart bug by centralizing
lifecycle management. Adds new shard-based WebSocket event handling.

Made-with: Cursor
2026-03-20 12:43:31 -04:00
9 changed files with 3899 additions and 764 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
# Overlay Manager & Player List Design
**Date:** 2026-03-20
**Status:** Approved
## Problem
The OBS overlay (`optimized-controls.html`) has two issues after the upstream Game Picker API migrated from Puppeteer to an ecast shard monitor:
1. **Audio doesn't restart on new room codes.** The overlay updates the room code text when `game.added` fires, but audio playback fails to reinitialize because `hideDisplay()` (triggered by `game.started` or `audience.joined`) leaves the audio element in a state that `startAnimation()` doesn't fully recover from. There is no centralized lifecycle management — show/hide logic is scattered across event handlers.
2. **No player list.** The new shard-based API pushes real-time player names and join events (`room.connected`, `lobby.player-joined`), but the overlay doesn't consume them. Players joining the lobby should be visible to viewers.
Additionally, several new WebSocket events (`room.connected`, `lobby.player-joined`, `lobby.updated`, `game.ended`, `room.disconnected`) are not handled, and the removed `audience.joined` event is still referenced.
## Design Decisions
- **Event-driven state machine** over reactive flags or per-component lifecycle management. A central `OverlayManager` coordinates all components through explicit state transitions, preventing the ad-hoc state bugs that cause the current audio issue.
- **ES module split** (no build step) over single-file or bundled architecture. Keeps OBS browser source simplicity while improving maintainability.
- **WebSocket-only player data** — no REST polling fallback for player lists.
- **Overlay visible only during lobby state** — room code, audio, and player list all share the same lifecycle.
## Architecture
### State Machine
```
idle → lobby → playing → ended → idle
↑ |
└─────────────────────────┘
(new game.added)
Any state → disconnected → idle (on reconnect, no active lobby)
→ lobby (on reconnect, active lobby)
```
| State | Entry Trigger | Visible Components |
|-------|--------------|-------------------|
| `idle` | Initial load, `session.ended`, `game.ended`, `room.disconnected` | None |
| `lobby` | `game.added`, `room.connected` (lobby state) | Room code + audio + player list |
| `playing` | `game.started` | None |
| `ended` | `game.ended` | None (transitions to `idle` after brief delay) |
| `disconnected` | WebSocket close/error | None (reconnect logic runs) |
Key transition: `game.added` while already in `lobby` triggers a **full reset** — deactivate all components, update context, reactivate. This fixes the audio restart bug by design.
### Component Interface
Every component implements:
- `activate(context)` — enter active state with room/game/player context
- `deactivate()` — fully clean up (stop audio, clear timers, hide elements)
- `update(context)` — handle in-state updates (new player joined, etc.)
- `getStatus()` — return current status for the debug panel
### File Layout
```
OBS-stuff/
├── optimized-controls.html # DOM + CSS + bootstrap script
├── js/
│ ├── state-manager.js # OverlayManager: state machine, component registry
│ ├── websocket-client.js # Auth, connect, reconnect, event routing
│ ├── room-code-display.js # Room code animation component
│ ├── audio-controller.js # Audio playback lifecycle
│ ├── player-list.js # Player slot list component
│ └── controls.js # Settings panel + debug dashboard
```
Loaded via `<script type="module">` — no build step.
## Player List
### Visual Design
Vertical numbered roster positioned to the left or right of the room code (configurable):
```
1. xXSlayerXx ← filled (bright text)
2. CoolPlayer42 ← filled
3. ──────────── ← empty (dim placeholder)
4. ────────────
5. ────────────
```
Slot count equals `maxPlayers` from the shard's `room.connected` event (fallback: game catalog `max_players` from `game.added`, default: 8).
### Configuration
- Enable/disable toggle
- Position: left or right of room code
- Font size, text color, empty slot color
- Vertical offset
### Behavior
1. `activate(ctx)`: Create `maxPlayers` empty slots. Fade in using the same timing curve as the room code animation.
2. `lobby.player-joined`: Fill next empty slot with player name (subtle fade-in on the name text). Uses the `players[]` array from the event to diff against displayed slots.
3. `room.connected` (with existing players — e.g., reconnect): Bulk-fill all known players.
4. `deactivate()`: Fade out all slots, clear the list.
### Edge Cases
- More players than `maxPlayers` (audience members): ignored, only player slots shown.
- Player names unavailable: fallback to "Player 1", "Player 2", etc.
- No `maxPlayers` data yet: default to 8 slots, update when data arrives.
## Debug Dashboard & Manual Overrides
New collapsible "Overlay Manager" section in the controls panel.
### State Display
- Current state badge (color-coded): `IDLE` / `LOBBY` / `PLAYING` / `ENDED` / `DISCONNECTED`
- Room code, session ID, game title + pack
- Player count: `3 / 8`
### Component Override Table
| Component | State | Override |
|-----------|-------|----------|
| Room Code | Active (cycling) | [Auto] / [Force Show] / [Force Hide] |
| Audio | Active (playing) | [Auto] / [Force Show] / [Force Hide] |
| Player List | Active (3/8) | [Auto] / [Force Show] / [Force Hide] |
- **Auto** (default): follows state machine
- **Force Show**: always visible, uses last known context
- **Force Hide**: always hidden regardless of state
- Overrides are session-only (not persisted to localStorage)
### Event Log
Scrollable log of last ~20 WebSocket events with timestamps.
## WebSocket Event Mapping
| Event | New Behavior |
|-------|-------------|
| `game.added` | Transition to `lobby` with room code + game metadata |
| `room.connected` | Enrich lobby context: `maxPlayers`, initial `players[]` |
| `lobby.player-joined` | Update player list with new player |
| `lobby.updated` | Update lobby context |
| `game.started` | Transition to `playing` |
| `game.ended` | Transition to `ended``idle` |
| `room.disconnected` | Transition to `idle` (with reason logging) |
| `session.started` | Subscribe to session, set session context |
| `session.ended` | Transition to `idle`, clear all context |
| `player-count.updated` | Update player count (manual REST override, less common now) |
| `vote.received` | Pass-through logging |
| `audience.joined` | **Removed** — no longer exists in the new API |
Auth flow unchanged: `POST /api/auth/login` with `{ key }`, JWT response, Bearer header.
Reconnection preserves current exponential backoff. On reconnect: re-auth, fetch active session, re-subscribe. If lobby is active, `room.connected` fires with current state.

139
js/audio-controller.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* Theme audio for the room code lobby: reload, volume, loop, and fade-out.
* Implements the overlay component contract: init, activate, deactivate, update, getStatus.
*/
/**
* @typedef {object} AudioControllerInputs
* @property {HTMLInputElement} enabled
* @property {HTMLInputElement} volume
* @property {HTMLInputElement} soundUrl
*/
export class AudioController {
/** @type {HTMLAudioElement | null} */
#audio = null;
/** @type {AudioControllerInputs | null} */
#inputs = null;
/** @type {boolean} */
#active = false;
/** @type {ReturnType<typeof setInterval> | null} */
#fadeInterval = null;
/**
* @param {HTMLAudioElement} audioElement
* @param {AudioControllerInputs} inputs
*/
init(audioElement, inputs) {
this.#audio = audioElement;
this.#inputs = inputs;
}
/**
* @param {object} [_ctx]
*/
activate(_ctx) {
this.deactivate();
this.#active = true;
const inputs = this.#inputs;
const audio = this.#audio;
if (!inputs || !audio) {
return;
}
if (!inputs.enabled.checked) {
return;
}
audio.src = inputs.soundUrl.value;
audio.load();
audio.volume = parseFloat(inputs.volume.value);
audio.loop = true;
audio.currentTime = 0;
audio.play().catch((error) => {
console.log('Audio playback failed:', error);
});
}
deactivate() {
this.#active = false;
if (this.#fadeInterval != null) {
clearInterval(this.#fadeInterval);
this.#fadeInterval = null;
}
const audio = this.#audio;
const inputs = this.#inputs;
if (!audio) {
return;
}
audio.pause();
audio.currentTime = 0;
if (inputs) {
audio.volume = parseFloat(inputs.volume.value);
}
}
/**
* @param {object} [_ctx]
*/
update(_ctx) {
// No in-state audio updates.
}
getStatus() {
const audio = this.#audio;
const inputs = this.#inputs;
return {
active: this.#active,
enabled: inputs?.enabled.checked ?? false,
playing: Boolean(audio && !audio.paused),
volume: audio?.volume ?? 0,
src: audio?.currentSrc ?? audio?.src ?? '',
};
}
/**
* Gradually reduce volume to zero, then pause. No-op if audio is already paused.
* @param {number} durationMs
*/
startFadeOut(durationMs) {
const audio = this.#audio;
if (!audio || audio.paused) {
return;
}
if (this.#fadeInterval != null) {
clearInterval(this.#fadeInterval);
this.#fadeInterval = null;
}
const startVolume = audio.volume;
const stepCount = 10;
const intervalMs = Math.max(1, durationMs / stepCount);
let step = 0;
this.#fadeInterval = setInterval(() => {
step += 1;
audio.volume = Math.max(0, startVolume * (1 - step / stepCount));
if (step >= stepCount) {
audio.volume = 0;
audio.pause();
if (this.#fadeInterval != null) {
clearInterval(this.#fadeInterval);
this.#fadeInterval = null;
}
}
}, intervalMs);
}
}

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;
}
};
}

305
js/player-list.js Normal file
View File

@@ -0,0 +1,305 @@
/**
* Numbered player slot roster beside the room code. Implements the overlay component contract:
* init, activate, deactivate, update, getStatus.
*/
const DEFAULT_MAX_PLAYERS = 8;
/**
* @typedef {object} PlayerListInputs
* @property {HTMLInputElement} enabled
* @property {HTMLSelectElement | HTMLInputElement} position
* @property {HTMLInputElement} fontSize
* @property {HTMLInputElement} textColor
* @property {HTMLInputElement} emptyColor
* @property {HTMLInputElement} offset
*/
/**
* @typedef {object} PlayerListTimingInputs
* @property {HTMLInputElement} headerAppearDelay
* @property {HTMLInputElement} headerAppearDuration
*/
/**
* @param {unknown} p
* @returns {string}
*/
function displayName(p) {
if (p == null) {
return '';
}
if (typeof p === 'string') {
return p.trim();
}
if (typeof p === 'object' && p !== null && 'name' in p) {
return String(/** @type {{ name?: unknown }} */ (p).name ?? '').trim();
}
return String(p).trim();
}
export class PlayerList {
constructor() {
this._active = false;
/** @type {HTMLElement | null} */
this._container = null;
/** @type {PlayerListInputs | null} */
this._inputs = null;
/** @type {PlayerListTimingInputs | null} */
this._timingInputs = null;
/** @type {{ element: HTMLDivElement, nameEl: HTMLSpanElement, filled: boolean }[]} */
this._slots = [];
/** @type {unknown[]} */
this._players = [];
this._maxPlayers = DEFAULT_MAX_PLAYERS;
/** @type {ReturnType<typeof setTimeout>[]} */
this._animationTimers = [];
/** @type {ReturnType<typeof setTimeout> | null} */
this._deactivateFadeTimer = null;
}
/**
* @param {HTMLElement} container
* @param {PlayerListInputs} inputs
* @param {PlayerListTimingInputs} animationTimingInputs
*/
init(container, inputs, animationTimingInputs) {
this._container = container;
this._inputs = inputs;
this._timingInputs = animationTimingInputs;
}
/**
* @param {{ maxPlayers?: number, players?: unknown[], [key: string]: unknown }} ctx
*/
activate(ctx) {
this.deactivate();
this._active = true;
const inputs = this._inputs;
if (!inputs || !inputs.enabled.checked) {
return;
}
const rawMax = ctx.maxPlayers != null ? Number(ctx.maxPlayers) : DEFAULT_MAX_PLAYERS;
this._maxPlayers =
Number.isFinite(rawMax) && rawMax > 0 ? Math.floor(rawMax) : DEFAULT_MAX_PLAYERS;
this._players = Array.isArray(ctx.players) ? [...ctx.players] : [];
this._buildSlots();
this._applySettings();
this._fillSlots(this._players.map(displayName));
this._animateIn();
}
deactivate() {
this._active = false;
this._clearTimers();
if (this._deactivateFadeTimer != null) {
clearTimeout(this._deactivateFadeTimer);
this._deactivateFadeTimer = null;
}
const container = this._container;
if (!container) {
return;
}
container.style.transition = 'opacity 0.3s ease-out';
container.style.opacity = '0';
this._deactivateFadeTimer = setTimeout(() => {
this._deactivateFadeTimer = null;
if (!this._active && this._container) {
this._container.innerHTML = '';
this._slots = [];
this._players = [];
}
}, 300);
}
/**
* @param {{ maxPlayers?: number, players?: unknown[], [key: string]: unknown }} ctx
*/
update(ctx) {
const inputs = this._inputs;
if (!this._active || !inputs?.enabled.checked) {
return;
}
if (ctx.maxPlayers != null) {
const rawMax = Number(ctx.maxPlayers);
const nextMax =
Number.isFinite(rawMax) && rawMax > 0 ? Math.floor(rawMax) : DEFAULT_MAX_PLAYERS;
if (nextMax !== this._maxPlayers) {
this._maxPlayers = nextMax;
this._buildSlots();
this._applySettings();
const names = (ctx.players != null ? ctx.players : this._players).map(displayName);
this._players = ctx.players != null ? [...ctx.players] : [...this._players];
this._fillSlots(names);
return;
}
}
if (ctx.players != null && Array.isArray(ctx.players)) {
const prevNames = this._players.map(displayName);
const nextNames = ctx.players.map(displayName);
const prevSet = new Set(prevNames.filter(Boolean));
const newlyJoined = nextNames.filter((n) => n && !prevSet.has(n));
this._players = [...ctx.players];
this._fillSlots(nextNames, newlyJoined);
}
}
getStatus() {
return {
active: this._active,
enabled: this._inputs?.enabled.checked ?? false,
playerCount: this._players.length,
maxPlayers: this._maxPlayers,
players: this._players.map((p) => displayName(p)),
};
}
_buildSlots() {
const container = this._container;
if (!container) {
return;
}
container.innerHTML = '';
this._slots = [];
for (let i = 0; i < this._maxPlayers; i++) {
const slot = document.createElement('div');
slot.className = 'player-slot';
slot.dataset.index = String(i);
const number = document.createElement('span');
number.className = 'player-slot-number';
number.textContent = `${i + 1}.`;
const name = document.createElement('span');
name.className = 'player-slot-name empty';
name.textContent = '────────';
slot.appendChild(number);
slot.appendChild(name);
container.appendChild(slot);
this._slots.push({ element: slot, nameEl: name, filled: false });
}
}
_applySettings() {
const container = this._container;
const inputs = this._inputs;
if (!container || !inputs) {
return;
}
container.classList.remove('player-list-position-left', 'player-list-position-right');
const pos = String(inputs.position.value || 'left').toLowerCase();
if (pos === 'right') {
container.classList.add('player-list-position-right');
} else {
container.classList.add('player-list-position-left');
}
const fontPx = `${parseInt(inputs.fontSize.value, 10) || 14}px`;
const textColor = inputs.textColor.value;
const emptyColor = inputs.emptyColor.value;
const offsetY = `${parseInt(inputs.offset.value, 10) || 0}px`;
container.style.transform = `translateY(calc(-50% + ${offsetY}))`;
for (const slot of this._slots) {
slot.nameEl.style.fontSize = fontPx;
if (slot.nameEl.classList.contains('filled')) {
slot.nameEl.style.color = textColor;
} else {
slot.nameEl.style.color = emptyColor;
}
}
}
/**
* @param {string[]} players
* @param {string[]} [newlyJoined]
*/
_fillSlots(players, newlyJoined = []) {
const newSet = new Set(newlyJoined.filter(Boolean));
const inputs = this._inputs;
for (let i = 0; i < this._slots.length; i++) {
const slot = this._slots[i];
const name = players[i];
const hasPlayer = name != null && String(name).length > 0;
if (hasPlayer) {
const label = String(name);
slot.nameEl.textContent = label;
slot.nameEl.classList.remove('empty');
slot.nameEl.classList.add('filled');
slot.filled = true;
if (inputs) {
slot.nameEl.style.color = inputs.textColor.value;
}
if (newSet.has(label)) {
slot.nameEl.style.opacity = '0';
requestAnimationFrame(() => {
slot.nameEl.style.transition = 'opacity 0.4s ease-out';
slot.nameEl.style.opacity = '1';
});
} else {
slot.nameEl.style.transition = '';
slot.nameEl.style.opacity = '1';
}
} else if (slot.filled) {
slot.nameEl.textContent = '────────';
slot.nameEl.classList.remove('filled');
slot.nameEl.classList.add('empty');
slot.filled = false;
slot.nameEl.style.transition = '';
slot.nameEl.style.opacity = '1';
if (inputs) {
slot.nameEl.style.color = inputs.emptyColor.value;
}
}
}
}
_animateIn() {
const container = this._container;
const timing = this._timingInputs;
if (!container || !timing) {
return;
}
this._clearTimers();
container.style.opacity = '0';
container.style.display = 'flex';
const sec = (input) => parseInt(input.value, 10) * 1000;
const headerAppearDelayMs = sec(timing.headerAppearDelay);
const headerAppearDurationMs = sec(timing.headerAppearDuration);
this._animationTimers.push(
setTimeout(() => {
container.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
container.style.opacity = '1';
}, headerAppearDelayMs),
);
}
_clearTimers() {
this._animationTimers.forEach((id) => clearTimeout(id));
this._animationTimers = [];
}
}

282
js/room-code-display.js Normal file
View File

@@ -0,0 +1,282 @@
/**
* Room code lobby display: staggered opacity animation for header, footer, and two code lines.
* Implements the overlay component contract: activate, deactivate, update, getStatus.
*/
/**
* @typedef {object} RoomCodeDisplayElements
* @property {HTMLElement} header
* @property {HTMLElement} footer
* @property {HTMLElement} codePart1
* @property {HTMLElement} codePart2
*/
/**
* @typedef {object} RoomCodeDisplayInputs
* @property {HTMLInputElement} code1
* @property {HTMLInputElement} code2
* @property {HTMLInputElement} color1
* @property {HTMLInputElement} color2
* @property {HTMLInputElement} offset1
* @property {HTMLInputElement} offset2
* @property {HTMLInputElement} size
* @property {HTMLInputElement} cycle
* @property {HTMLInputElement} headerText
* @property {HTMLInputElement} headerColor
* @property {HTMLInputElement} headerSize
* @property {HTMLInputElement} headerOffset
* @property {HTMLInputElement} footerText
* @property {HTMLInputElement} footerColor
* @property {HTMLInputElement} footerSize
* @property {HTMLInputElement} footerOffset
* @property {HTMLInputElement} headerAppearDelay
* @property {HTMLInputElement} headerAppearDuration
* @property {HTMLInputElement} headerHideTime
* @property {HTMLInputElement} headerHideDuration
* @property {HTMLInputElement} line1AppearDelay
* @property {HTMLInputElement} line1AppearDuration
* @property {HTMLInputElement} line1HideTime
* @property {HTMLInputElement} line1HideDuration
* @property {HTMLInputElement} line2AppearDelay
* @property {HTMLInputElement} line2AppearDuration
* @property {HTMLInputElement} line2HideTime
* @property {HTMLInputElement} line2HideDuration
*/
export class RoomCodeDisplay {
/** @type {RoomCodeDisplayElements | null} */
#elements = null;
/** @type {RoomCodeDisplayInputs | null} */
#inputs = null;
/** @type {ReturnType<typeof setTimeout>[]} */
#animationTimers = [];
/** @type {boolean} */
#active = false;
/**
* @param {RoomCodeDisplayElements} elements
* @param {RoomCodeDisplayInputs} inputs
*/
init(elements, inputs) {
this.#elements = elements;
this.#inputs = inputs;
}
/**
* @param {{ roomCode?: string, [key: string]: unknown }} ctx
*/
activate(ctx) {
this.deactivate();
const inputs = this.#inputs;
if (!inputs) {
return;
}
this.#active = true;
if (ctx?.roomCode != null && String(ctx.roomCode).trim().length > 0) {
const raw = String(ctx.roomCode).trim().toUpperCase();
const mid = Math.floor(raw.length / 2);
inputs.code1.value = raw.slice(0, mid);
inputs.code2.value = raw.slice(mid);
} else {
inputs.code1.value = '';
inputs.code2.value = '';
}
this.#applySettings();
this.#startAnimation();
}
deactivate() {
this.#active = false;
this.#clearAnimationTimers();
const { header, footer, codePart1, codePart2 } = this.#elements ?? {};
if (!header || !footer || !codePart1 || !codePart2) {
return;
}
const fadeTime = '0.3s';
header.style.transition = `opacity ${fadeTime} ease-out`;
footer.style.transition = `opacity ${fadeTime} ease-out`;
codePart1.style.transition = `opacity ${fadeTime} ease-out`;
codePart2.style.transition = `opacity ${fadeTime} ease-out`;
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
}
/**
* @param {{ roomCode?: string, [key: string]: unknown }} ctx
*/
update(ctx) {
if (!this.#active || !this.#inputs) {
return;
}
const next = ctx?.roomCode != null ? String(ctx.roomCode).trim().toUpperCase() : '';
const current =
this.#inputs.code1.value.toUpperCase() + this.#inputs.code2.value.toUpperCase();
if (next && next !== current) {
this.activate(ctx);
}
}
getStatus() {
const inputs = this.#inputs;
const roomCode = inputs
? (inputs.code1.value + inputs.code2.value).toUpperCase()
: '';
return {
active: this.#active,
roomCode,
timersRunning: this.#animationTimers.length > 0,
};
}
#clearAnimationTimers() {
this.#animationTimers.forEach((id) => clearTimeout(id));
this.#animationTimers = [];
}
#applySettings() {
const el = this.#elements;
const inputs = this.#inputs;
if (!el || !inputs) {
return;
}
const {
header,
footer,
codePart1,
codePart2,
} = el;
codePart1.textContent = inputs.code1.value.toUpperCase();
codePart2.textContent = inputs.code2.value.toUpperCase();
codePart1.style.color = inputs.color1.value;
codePart2.style.color = inputs.color2.value;
codePart1.style.transform = `translateY(${inputs.offset1.value}px)`;
codePart2.style.transform = `translateY(${inputs.offset2.value}px)`;
codePart1.style.fontSize = `${inputs.size.value}px`;
codePart2.style.fontSize = `${inputs.size.value}px`;
header.textContent = inputs.headerText.value;
header.style.color = inputs.headerColor.value;
header.style.fontSize = `${inputs.headerSize.value}px`;
header.style.transform = `translateY(${inputs.headerOffset.value}px)`;
footer.textContent = inputs.footerText.value;
footer.style.color = inputs.footerColor.value;
footer.style.fontSize = `${inputs.footerSize.value}px`;
footer.style.transform = `translateY(${inputs.footerOffset.value}px)`;
}
#startAnimation() {
const el = this.#elements;
const inputs = this.#inputs;
if (!el || !inputs) {
return;
}
const { header, footer, codePart1, codePart2 } = el;
this.#clearAnimationTimers();
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
const sec = (input) => parseInt(input.value, 10) * 1000;
const cycleDuration = sec(inputs.cycle);
const headerAppearDelayMs = sec(inputs.headerAppearDelay);
const headerAppearDurationMs = sec(inputs.headerAppearDuration);
this.#animationTimers.push(
setTimeout(() => {
header.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
header.style.opacity = 1;
footer.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
footer.style.opacity = 1;
}, headerAppearDelayMs),
);
const line1AppearDelayMs = sec(inputs.line1AppearDelay);
const line1AppearDurationMs = sec(inputs.line1AppearDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart1.style.transition = `opacity ${line1AppearDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 1;
}, line1AppearDelayMs),
);
const line1HideTimeMs = sec(inputs.line1HideTime);
const line1HideDurationMs = sec(inputs.line1HideDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart1.style.transition = `opacity ${line1HideDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 0;
}, line1HideTimeMs),
);
const line2AppearDelayMs = sec(inputs.line2AppearDelay);
const line2AppearDurationMs = sec(inputs.line2AppearDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart2.style.transition = `opacity ${line2AppearDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 1;
}, line2AppearDelayMs),
);
const line2HideTimeMs = sec(inputs.line2HideTime);
const line2HideDurationMs = sec(inputs.line2HideDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart2.style.transition = `opacity ${line2HideDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 0;
}, line2HideTimeMs),
);
const headerHideTimeMs = sec(inputs.headerHideTime);
const headerHideDurationMs = sec(inputs.headerHideDuration);
this.#animationTimers.push(
setTimeout(() => {
header.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
header.style.opacity = 0;
footer.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
footer.style.opacity = 0;
}, headerHideTimeMs),
);
this.#animationTimers.push(
setTimeout(() => {
if (this.#active) {
this.#startAnimation();
}
}, cycleDuration),
);
}
}

394
js/state-manager.js Normal file
View File

@@ -0,0 +1,394 @@
/**
* Central overlay state machine: coordinates room/game lifecycle and registered UI components.
*/
export const OVERRIDE_MODES = Object.freeze({
AUTO: 'auto',
FORCE_SHOW: 'force_show',
FORCE_HIDE: 'force_hide',
});
const OVERRIDE_VALUES = new Set(Object.values(OVERRIDE_MODES));
/** @typedef {'idle'|'lobby'|'playing'|'ended'|'disconnected'} OverlayState */
const VALID_TRANSITIONS = Object.freeze({
idle: new Set(['lobby', 'playing', 'ended', 'disconnected']),
lobby: new Set(['lobby', 'playing', 'ended', 'idle', 'disconnected']),
playing: new Set(['ended', 'lobby', 'idle', 'disconnected']),
ended: new Set(['idle', 'lobby', 'disconnected']),
disconnected: new Set(['idle', 'lobby']),
});
const EVENT_LOG_MAX = 50;
function shallowClone(obj) {
return { ...obj };
}
/**
* @typedef {object} OverlayComponent
* @property {(context: object) => void} activate
* @property {() => void} deactivate
* @property {(context: object) => void} update
* @property {() => object} getStatus
*/
export class OverlayManager {
/** @type {OverlayState} */
#state = 'idle';
/** @type {Map<string, OverlayComponent>} */
#components = new Map();
/** @type {Map<string, string>} */
#overrides = new Map();
/** @type {{ roomCode?: string, game?: object, maxPlayers?: number, players?: unknown[], lobbyState?: unknown, playerCount?: number, sessionId?: string, lastJoinedPlayer?: unknown, [key: string]: unknown }} */
#context = {};
/** @type {Array<{ type: string, data: unknown, at: number }>} */
#eventLog = [];
/** @type {Set<(info: { state: OverlayState, context: object }) => void>} */
#listeners = new Set();
/** @type {ReturnType<typeof setTimeout> | null} */
#endedToIdleTimer = null;
/**
* @param {string} name
* @param {OverlayComponent} component
*/
registerComponent(name, component) {
this.#components.set(name, component);
}
/**
* @param {(info: { state: OverlayState, context: object }) => void} listener
* @returns {() => void}
*/
onChange(listener) {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
/**
* @param {string} type
* @param {unknown} [data]
*/
logEvent(type, data) {
this.#eventLog.push({ type, data, at: Date.now() });
if (this.#eventLog.length > EVENT_LOG_MAX) {
this.#eventLog.splice(0, this.#eventLog.length - EVENT_LOG_MAX);
}
}
getEventLog() {
return this.#eventLog.map((e) => ({ ...e, data: e.data }));
}
/** @returns {OverlayState} */
getState() {
return this.#state;
}
getContext() {
return shallowClone(this.#context);
}
/**
* @param {string} name
* @param {string} mode
*/
setOverride(name, mode) {
if (!OVERRIDE_VALUES.has(mode)) {
throw new Error(`Invalid override mode: ${mode}`);
}
this.#overrides.set(name, mode);
this.#applyOverride(name);
this.#notify();
}
#applyOverride(name) {
const component = this.#components.get(name);
if (!component) return;
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
const ctx = this.getContext();
if (mode === OVERRIDE_MODES.FORCE_SHOW) {
if (typeof component.activate === 'function') {
try { component.activate(ctx); } catch (_) { /* ignore */ }
}
} else if (mode === OVERRIDE_MODES.FORCE_HIDE) {
if (typeof component.deactivate === 'function') {
try { component.deactivate(); } catch (_) { /* ignore */ }
}
} else if (mode === OVERRIDE_MODES.AUTO) {
if (this.#state === 'lobby') {
if (typeof component.activate === 'function') {
try { component.activate(ctx); } catch (_) { /* ignore */ }
}
} else {
if (typeof component.deactivate === 'function') {
try { component.deactivate(); } catch (_) { /* ignore */ }
}
}
}
}
/**
* @param {string} name
* @returns {string}
*/
getOverride(name) {
return this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
}
getComponentStatuses() {
const out = {};
for (const [name, component] of this.#components) {
out[name] = {
status: typeof component.getStatus === 'function' ? component.getStatus() : null,
override: this.getOverride(name),
};
}
return out;
}
/**
* @param {string} eventType
* @param {unknown} [data]
*/
handleEvent(eventType, data) {
this.logEvent(eventType, data);
const d = data && typeof data === 'object' ? /** @type {Record<string, unknown>} */ (data) : {};
switch (eventType) {
case 'game.added':
this.#applyGameAdded(d);
this.#transitionTo('lobby');
break;
case 'room.connected':
this.#applyRoomConnected(d);
if (this.#state === 'idle' || this.#state === 'ended') {
this.#transitionTo('lobby');
} else {
this.#broadcastUpdate();
}
break;
case 'lobby.player-joined':
this.#applyLobbyPlayerJoined(d);
this.#broadcastUpdate();
break;
case 'lobby.updated':
this.#applyLobbyUpdated(d);
this.#broadcastUpdate();
break;
case 'game.started':
this.#transitionTo('playing');
break;
case 'game.ended':
this.#transitionTo('ended');
this.#scheduleEndedToIdle();
break;
case 'room.disconnected':
this.#context.roomCode = null;
this.#context.players = [];
this.#context.playerCount = 0;
this.#context.lobbyState = null;
this.#transitionTo('idle');
break;
case 'session.started':
if (d.sessionId != null) this.#context.sessionId = /** @type {string} */ (d.sessionId);
this.#notify();
break;
case 'session.ended':
this.#clearEndedToIdleTimer();
this.#clearContext();
this.#transitionTo('idle');
break;
case 'player-count.updated':
this.#applyPlayerCountUpdated(d);
this.#broadcastUpdate();
break;
default:
break;
}
}
// --- internal ---
#notify() {
const snapshot = { state: this.#state, context: this.getContext() };
for (const fn of this.#listeners) {
try {
fn(snapshot);
} catch (_) {
/* ignore listener errors */
}
}
}
#broadcastUpdate() {
if (this.#state !== 'lobby') {
this.#notify();
return;
}
const ctx = this.getContext();
for (const [name, component] of this.#components) {
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
if (mode === OVERRIDE_MODES.FORCE_HIDE) continue;
if (typeof component.update === 'function') {
try { component.update(ctx); } catch (_) { /* ignore */ }
}
}
this.#notify();
}
#clearContext() {
this.#context = {};
}
#clearEndedToIdleTimer() {
if (this.#endedToIdleTimer != null) {
clearTimeout(this.#endedToIdleTimer);
this.#endedToIdleTimer = null;
}
}
#scheduleEndedToIdle() {
this.#clearEndedToIdleTimer();
this.#endedToIdleTimer = setTimeout(() => {
this.#endedToIdleTimer = null;
if (this.#state === 'ended') {
this.#transitionTo('idle');
}
}, 2000);
}
/**
* @param {Record<string, unknown>} d
*/
#applyGameAdded(d) {
const game = /** @type {Record<string, unknown> | undefined} */ (d.game);
if (!game || typeof game !== 'object') return;
const code =
game.room_code ?? game.roomCode ?? game.code;
if (code != null) this.#context.roomCode = String(code);
this.#context.game = { ...game };
}
/**
* @param {Record<string, unknown>} d
*/
#applyRoomConnected(d) {
if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers);
if (Array.isArray(d.players)) this.#context.players = [...d.players];
if (d.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState;
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
}
/**
* @param {Record<string, unknown>} d
*/
#applyLobbyPlayerJoined(d) {
if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers);
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
if (Array.isArray(d.players)) this.#context.players = [...d.players];
if (d.player !== undefined) this.#context.lastJoinedPlayer = d.player;
if (d.lastJoinedPlayer !== undefined) this.#context.lastJoinedPlayer = d.lastJoinedPlayer;
}
/**
* @param {Record<string, unknown>} d
*/
#applyLobbyUpdated(d) {
if (d.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState;
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
}
/**
* @param {Record<string, unknown>} d
*/
#applyPlayerCountUpdated(d) {
const n = d.playerCount ?? d.count;
if (n != null) this.#context.playerCount = Number(n);
}
/**
* @param {OverlayState} next
*/
#transitionTo(next) {
const current = this.#state;
if (!VALID_TRANSITIONS.get(current)?.has(next)) {
return;
}
if (current === 'lobby' && next === 'lobby') {
this.#fullLobbyReset();
this.#notify();
return;
}
const leavingLobby = current === 'lobby' && next !== 'lobby';
const enteringLobby = next === 'lobby' && current !== 'lobby';
if (leavingLobby) {
for (const [name, component] of this.#components) {
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
if (mode !== OVERRIDE_MODES.AUTO) continue;
if (typeof component.deactivate === 'function') {
try { component.deactivate(); } catch (_) { /* ignore */ }
}
}
}
this.#state = next;
if (enteringLobby) {
const ctx = this.getContext();
for (const [name, component] of this.#components) {
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
if (mode !== OVERRIDE_MODES.AUTO) continue;
if (typeof component.activate === 'function') {
try { component.activate(ctx); } catch (_) { /* ignore */ }
}
}
}
if (next !== 'ended') {
this.#clearEndedToIdleTimer();
}
this.#notify();
}
#fullLobbyReset() {
for (const [name, component] of this.#components) {
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
if (mode !== OVERRIDE_MODES.AUTO) continue;
if (typeof component.deactivate === 'function') {
try { component.deactivate(); } catch (_) { /* ignore */ }
}
}
const ctx = this.getContext();
for (const [name, component] of this.#components) {
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
if (mode !== OVERRIDE_MODES.AUTO) continue;
if (typeof component.activate === 'function') {
try { component.activate(ctx); } catch (_) { /* ignore */ }
}
}
}
}

374
js/websocket-client.js Normal file
View File

@@ -0,0 +1,374 @@
/**
* WebSocket client for Jackbox Game Picker API: JWT auth, live session stream, reconnect, heartbeat.
*/
const MAX_RECONNECT_DELAY_MS = 30_000;
const HEARTBEAT_INTERVAL_MS = 30_000;
const INITIAL_RECONNECT_DELAY_MS = 1_000;
/**
* @typedef {'connecting'|'connected'|'disconnected'|'error'} WsConnectionState
*/
export class WebSocketClient {
constructor(options = {}) {
const {
onStatusChange = () => {},
onEvent = () => {},
onSessionSubscribed = () => {},
} = options;
/** @type {(state: WsConnectionState, message?: string) => void} */
this._onStatusChange = onStatusChange;
/** @type {(eventType: string, data: unknown) => void} */
this._onEvent = onEvent;
/** @type {(sessionId: string | number) => void} */
this._onSessionSubscribed = onSessionSubscribed;
/** @type {WebSocket | null} */
this._ws = null;
/** @type {string | null} */
this._jwtToken = null;
/** @type {string | null} */
this._apiUrl = null;
/** @type {ReturnType<typeof setInterval> | null} */
this._heartbeatInterval = null;
/** @type {ReturnType<typeof setTimeout> | null} */
this._reconnectTimeout = null;
this._reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this._intentionalDisconnect = false;
/** @type {boolean} */
this._authenticated = false;
}
/**
* @param {string} apiUrl
* @param {string} apiKey
*/
async connect(apiUrl, apiKey) {
const base = this._normalizeApiUrl(apiUrl);
const key = String(apiKey).trim();
if (!base) {
this._onStatusChange('error', 'API URL is required');
return;
}
if (!key) {
this._onStatusChange('error', 'API Key is required');
return;
}
this._intentionalDisconnect = false;
this._apiUrl = base;
this._onStatusChange('connecting', 'Authenticating...');
try {
const response = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
const msg =
typeof errData.error === 'string'
? errData.error
: response.statusText || String(response.status);
this._onStatusChange('error', `Auth failed: ${msg}`);
return;
}
const data = await response.json();
const token = data.token;
if (!token) {
this._onStatusChange('error', 'No token in auth response');
return;
}
this._jwtToken = token;
this._onStatusChange('connecting', 'Connecting WebSocket...');
this._connectWebSocket(base);
} catch (err) {
console.error('[WebSocketClient] Auth error:', err);
const message = err instanceof Error ? err.message : String(err);
this._onStatusChange('error', `Auth error: ${message}`);
}
}
disconnect() {
this._intentionalDisconnect = true;
if (this._reconnectTimeout) {
clearTimeout(this._reconnectTimeout);
this._reconnectTimeout = null;
}
this._stopHeartbeat();
this._authenticated = false;
this._jwtToken = null;
if (this._ws) {
this._ws.close();
this._ws = null;
}
this._onStatusChange('disconnected', 'Disconnected');
}
/**
* @param {string | number} sessionId
*/
subscribeToSession(sessionId) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
console.warn('[WebSocketClient] subscribeToSession: socket not open');
return;
}
this._ws.send(
JSON.stringify({ type: 'subscribe', sessionId }),
);
}
get isConnected() {
return (
this._authenticated &&
this._ws !== null &&
this._ws.readyState === WebSocket.OPEN
);
}
/**
* @param {string} apiUrl
*/
_normalizeApiUrl(apiUrl) {
return String(apiUrl).trim().replace(/\/+$/, '');
}
/**
* @param {string} apiUrl
*/
_connectWebSocket(apiUrl) {
const wsUrl = `${apiUrl.replace(/^http/, 'ws')}/api/sessions/live`;
try {
this._ws = new WebSocket(wsUrl);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this._onStatusChange('error', `WebSocket error: ${message}`);
return;
}
this._ws.addEventListener('open', () => {
this._authenticated = false;
this._onStatusChange('connecting', 'Authenticating via WebSocket...');
this._reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
if (!this._jwtToken) {
this._onStatusChange('error', 'Missing JWT for WebSocket auth');
return;
}
this._ws.send(JSON.stringify({ type: 'auth', token: this._jwtToken }));
});
this._ws.addEventListener('message', (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (e) {
console.error('[WebSocketClient] Failed to parse message:', event.data);
return;
}
this._handleMessage(message);
});
this._ws.addEventListener('close', (event) => {
console.log('[WebSocketClient] Disconnected, code:', event.code);
this._stopHeartbeat();
this._authenticated = false;
this._ws = null;
if (!this._intentionalDisconnect) {
const secs = Math.round(this._reconnectDelay / 1000);
this._onStatusChange(
'connecting',
`Reconnecting in ${secs}s...`,
);
this._scheduleReconnect();
} else {
this._onStatusChange('disconnected', 'Disconnected');
}
});
this._ws.addEventListener('error', (err) => {
console.error('[WebSocketClient] Socket error:', err);
});
}
/**
* @param {Record<string, unknown>} message
*/
_handleMessage(message) {
const type = message.type;
switch (type) {
case 'auth_success': {
this._startHeartbeat();
void this._fetchActiveSessionAndSubscribe();
this._authenticated = true;
this._onStatusChange('connected', 'Connected');
break;
}
case 'auth_error': {
const msg =
typeof message.message === 'string'
? message.message
: 'WebSocket authentication failed';
console.error('[WebSocketClient] Auth error:', msg);
this._onStatusChange('error', `WS auth failed: ${msg}`);
this._intentionalDisconnect = true;
if (this._ws) {
this._ws.close();
}
break;
}
case 'subscribed': {
const sessionId = message.sessionId;
const label =
sessionId !== undefined && sessionId !== null
? `Connected (session ${sessionId})`
: 'Connected (subscribed)';
this._onStatusChange('connected', label);
if (sessionId !== undefined && sessionId !== null) {
this._onSessionSubscribed(sessionId);
}
break;
}
case 'pong':
break;
case 'error': {
const serverMsg =
typeof message.message === 'string'
? message.message
: JSON.stringify(message);
console.error('[WebSocketClient] Server error:', serverMsg);
break;
}
case 'session.started': {
const data = message.data;
/** @type {{ session?: { id?: string | number } } | undefined} */
let payload;
if (data && typeof data === 'object' && data !== null) {
payload = /** @type {{ session?: { id?: string | number } }} */ (
data
);
}
const id = payload?.session?.id;
if (
id !== undefined &&
id !== null &&
this._ws &&
this._ws.readyState === WebSocket.OPEN
) {
this._ws.send(
JSON.stringify({ type: 'subscribe', sessionId: id }),
);
}
if (data !== undefined) {
this._onEvent('session.started', data);
}
break;
}
default: {
if (
Object.prototype.hasOwnProperty.call(message, 'data') &&
message.data !== undefined
) {
if (typeof type === 'string') {
this._onEvent(type, message.data);
}
} else {
console.log('[WebSocketClient] Unhandled message type:', type);
}
}
}
}
async _fetchActiveSessionAndSubscribe() {
const apiUrl = this._apiUrl;
const token = this._jwtToken;
if (!apiUrl || !token) return;
try {
const response = await fetch(`${apiUrl}/api/sessions/active`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const session = await response.json();
if (session && session.id !== undefined && session.id !== null) {
console.log(
'[WebSocketClient] Found active session:',
session.id,
'— subscribing',
);
this.subscribeToSession(session.id);
} else {
console.log(
'[WebSocketClient] No active session found; waiting for session.started',
);
}
} else {
console.log(
'[WebSocketClient] Could not fetch active session:',
response.status,
);
}
} catch (err) {
console.error('[WebSocketClient] Error fetching active session:', err);
}
}
_startHeartbeat() {
this._stopHeartbeat();
this._heartbeatInterval = setInterval(() => {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'ping' }));
}
}, HEARTBEAT_INTERVAL_MS);
}
_stopHeartbeat() {
if (this._heartbeatInterval) {
clearInterval(this._heartbeatInterval);
this._heartbeatInterval = null;
}
}
_scheduleReconnect() {
if (this._reconnectTimeout) {
clearTimeout(this._reconnectTimeout);
}
const delay = this._reconnectDelay;
this._reconnectTimeout = setTimeout(() => {
this._reconnectTimeout = null;
const apiUrl = this._apiUrl;
const token = this._jwtToken;
if (apiUrl && token && !this._intentionalDisconnect) {
console.log('[WebSocketClient] Attempting reconnect...');
this._onStatusChange('connecting', 'Reconnecting...');
this._connectWebSocket(apiUrl);
}
this._reconnectDelay = Math.min(
this._reconnectDelay * 2,
MAX_RECONNECT_DELAY_MS,
);
}, delay);
}
}

File diff suppressed because it is too large Load Diff