Files
OBS-overlay/docs/plans/2026-03-20-overlay-manager-implementation.md

1580 lines
52 KiB
Markdown
Raw Permalink Normal View History

# Overlay Manager & Player List Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Refactor the OBS overlay from a monolithic single-file design to a modular state-machine architecture, fix the audio restart bug, add a player list component, and handle all new shard-based WebSocket events.
**Architecture:** Central `OverlayManager` state machine coordinates registered components (room code display, audio controller, player list) through explicit lifecycle transitions. WebSocket client routes events to the manager. Components implement a standard `activate`/`deactivate`/`update` interface.
**Tech Stack:** Vanilla JS (ES modules), no build step, loaded via `<script type="module">` in OBS browser source.
---
## Reference: Upstream API Event Payloads
These are the WebSocket event payload shapes from `/api/sessions/live` (see `docs/api/websocket.md` in the `jackboxpartypack-gamepicker` repo):
- **`game.added`** `data`: `{ session: { id, is_active, games_played }, game: { id, title, pack_name, min_players, max_players, manually_added, room_code } }`
- **`room.connected`** `data`: `{ sessionId, gameId, roomCode, appTag, maxPlayers, playerCount, players[], lobbyState, gameState }`
- **`lobby.player-joined`** `data`: `{ sessionId, gameId, roomCode, playerName, playerCount, players[], maxPlayers }`
- **`lobby.updated`** `data`: `{ sessionId, gameId, roomCode, lobbyState, gameCanStart, gameIsStarting, playerCount }`
- **`game.started`** `data`: `{ sessionId, gameId, roomCode, playerCount, players[], maxPlayers }`
- **`game.ended`** `data`: `{ sessionId, gameId, roomCode, playerCount, players[] }`
- **`room.disconnected`** `data`: `{ sessionId, gameId, roomCode, reason, finalPlayerCount }`
- **`session.started`** `data`: `{ session: { id, is_active, created_at, notes } }`
- **`session.ended`** `data`: `{ session: { id, is_active, games_played } }`
- **`player-count.updated`** `data`: `{ sessionId, gameId, playerCount, status }`
- **`vote.received`** `data`: `{ sessionId, game: { id, title, pack_name }, vote: { username, type, timestamp }, totals: { upvotes, downvotes, popularity_score } }`
---
## Task 1: Create `js/state-manager.js` — OverlayManager State Machine
**Files:**
- Create: `js/state-manager.js`
**Step 1: Create the OverlayManager class**
Create `js/state-manager.js` with the following:
```javascript
const VALID_STATES = ['idle', 'lobby', 'playing', 'ended', 'disconnected'];
const VALID_TRANSITIONS = {
idle: ['lobby', 'disconnected'],
lobby: ['lobby', 'playing', 'ended', 'idle', 'disconnected'],
playing: ['ended', 'lobby', 'idle', 'disconnected'],
ended: ['idle', 'lobby', 'disconnected'],
disconnected: ['idle', 'lobby'],
};
const OVERRIDE_MODES = { AUTO: 'auto', FORCE_SHOW: 'force_show', FORCE_HIDE: 'force_hide' };
export class OverlayManager {
constructor() {
this._state = 'idle';
this._components = new Map();
this._overrides = new Map();
this._context = {};
this._eventLog = [];
this._listeners = new Set();
this._endedTimer = null;
}
get state() { return this._state; }
get context() { return { ...this._context }; }
registerComponent(name, component) {
this._components.set(name, component);
this._overrides.set(name, OVERRIDE_MODES.AUTO);
}
onChange(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
_notify() {
for (const fn of this._listeners) {
try { fn(this._state, this._context); } catch (e) { console.error('[Manager] Listener error:', e); }
}
}
setOverride(componentName, mode) {
if (!this._components.has(componentName)) return;
if (!Object.values(OVERRIDE_MODES).includes(mode)) return;
this._overrides.set(componentName, mode);
this._applyOverride(componentName);
this._notify();
}
getOverride(componentName) {
return this._overrides.get(componentName) || OVERRIDE_MODES.AUTO;
}
_applyOverride(name) {
const component = this._components.get(name);
const mode = this._overrides.get(name);
if (!component) return;
if (mode === OVERRIDE_MODES.FORCE_SHOW) {
component.activate(this._context);
} else if (mode === OVERRIDE_MODES.FORCE_HIDE) {
component.deactivate();
}
// AUTO: no action here, state transitions handle it
}
transition(newState, contextUpdates = {}) {
if (!VALID_STATES.includes(newState)) {
console.error('[Manager] Invalid state:', newState);
return;
}
if (!VALID_TRANSITIONS[this._state]?.includes(newState)) {
console.warn(`[Manager] Invalid transition: ${this._state} → ${newState}`);
return;
}
const oldState = this._state;
const wasLobby = oldState === 'lobby';
const isLobby = newState === 'lobby';
Object.assign(this._context, contextUpdates);
this._state = newState;
if (this._endedTimer) {
clearTimeout(this._endedTimer);
this._endedTimer = null;
}
if (wasLobby && !isLobby) {
this._deactivateAllComponents();
} else if (wasLobby && isLobby) {
// lobby → lobby (new room code): full reset
this._deactivateAllComponents();
this._activateAllComponents();
} else if (!wasLobby && isLobby) {
this._activateAllComponents();
}
if (newState === 'ended') {
this._endedTimer = setTimeout(() => {
this.transition('idle');
}, 2000);
}
console.log(`[Manager] ${oldState} → ${newState}`);
this._notify();
}
updateComponents(contextUpdates = {}) {
Object.assign(this._context, contextUpdates);
for (const [name, component] of this._components) {
const mode = this._overrides.get(name);
if (mode === OVERRIDE_MODES.AUTO && this._state === 'lobby') {
component.update(this._context);
} else if (mode === OVERRIDE_MODES.FORCE_SHOW) {
component.update(this._context);
}
}
this._notify();
}
_activateAllComponents() {
for (const [name, component] of this._components) {
const mode = this._overrides.get(name);
if (mode === OVERRIDE_MODES.AUTO) {
component.activate(this._context);
} else if (mode === OVERRIDE_MODES.FORCE_HIDE) {
// Respect hide override even during lobby
}
}
}
_deactivateAllComponents() {
for (const [name, component] of this._components) {
const mode = this._overrides.get(name);
if (mode === OVERRIDE_MODES.AUTO) {
component.deactivate();
}
}
}
logEvent(eventType, data) {
const entry = { type: eventType, timestamp: new Date().toISOString(), data };
this._eventLog.push(entry);
if (this._eventLog.length > 50) this._eventLog.shift();
this._notify();
}
getEventLog() { return [...this._eventLog]; }
getComponentStatuses() {
const statuses = {};
for (const [name, component] of this._components) {
statuses[name] = {
status: component.getStatus(),
override: this._overrides.get(name),
};
}
return statuses;
}
handleEvent(eventType, data) {
this.logEvent(eventType, data);
switch (eventType) {
case 'game.added': {
const game = data.game;
const roomCode = game?.room_code;
if (!roomCode) break;
this.transition('lobby', {
roomCode,
gameTitle: game.title,
packName: game.pack_name,
maxPlayers: game.max_players,
minPlayers: game.min_players,
gameId: game.id,
sessionId: data.session?.id,
players: [],
playerCount: 0,
});
break;
}
case 'room.connected':
this.updateComponents({
maxPlayers: data.maxPlayers,
playerCount: data.playerCount,
players: data.players || [],
lobbyState: data.lobbyState,
gameState: data.gameState,
appTag: data.appTag,
});
if (this._state === 'idle' || this._state === 'ended') {
this.transition('lobby', {
roomCode: data.roomCode,
maxPlayers: data.maxPlayers,
playerCount: data.playerCount,
players: data.players || [],
});
}
break;
case 'lobby.player-joined':
this.updateComponents({
players: data.players || [],
playerCount: data.playerCount,
maxPlayers: data.maxPlayers,
lastJoinedPlayer: data.playerName,
});
break;
case 'lobby.updated':
this.updateComponents({
lobbyState: data.lobbyState,
playerCount: data.playerCount,
});
break;
case 'game.started':
this.transition('playing', {
playerCount: data.playerCount,
players: data.players || [],
});
break;
case 'game.ended':
this.transition('ended', {
playerCount: data.playerCount,
players: data.players || [],
});
break;
case 'room.disconnected':
this.transition('idle', {
disconnectReason: data.reason,
});
break;
case 'session.started':
this.updateComponents({
sessionId: data.session?.id,
});
break;
case 'session.ended':
this.transition('idle', {
sessionId: null,
roomCode: null,
players: [],
playerCount: 0,
});
break;
case 'player-count.updated':
this.updateComponents({
playerCount: data.playerCount,
});
break;
default:
break;
}
}
}
export { OVERRIDE_MODES };
```
**Step 2: Verify module loads**
Open the overlay HTML in a browser, open DevTools console, and run:
```javascript
import('./js/state-manager.js').then(m => { window._sm = m; console.log('loaded', m); });
```
Expected: Module loads without errors.
**Step 3: Commit**
```bash
git add js/state-manager.js
git commit -m "feat: add OverlayManager state machine module"
```
---
## Task 2: Create `js/websocket-client.js` — Extract WebSocket Logic
**Files:**
- Create: `js/websocket-client.js`
- Reference: `optimized-controls.html:996-1344` (current inline WebSocket code)
**Step 1: Create the WebSocketClient class**
Extract the auth, connection, reconnection, and heartbeat logic from `optimized-controls.html` lines 996-1344 into `js/websocket-client.js`:
```javascript
export class WebSocketClient {
constructor({ onStatusChange, onEvent, onSessionSubscribed }) {
this._ws = null;
this._jwtToken = null;
this._heartbeatInterval = null;
this._reconnectTimeout = null;
this._reconnectDelay = 1000;
this._maxReconnectDelay = 30000;
this._intentionalDisconnect = false;
this._apiUrl = '';
this._apiKey = '';
this._onStatusChange = onStatusChange || (() => {});
this._onEvent = onEvent || (() => {});
this._onSessionSubscribed = onSessionSubscribed || (() => {});
}
get isConnected() {
return this._ws && this._ws.readyState === WebSocket.OPEN;
}
async connect(apiUrl, apiKey) {
this._apiUrl = apiUrl.replace(/\/+$/, '');
this._apiKey = apiKey;
this._intentionalDisconnect = false;
if (!this._apiUrl || !this._apiKey) {
this._onStatusChange('error', 'API URL and Key are required');
return;
}
this._onStatusChange('connecting', 'Authenticating...');
try {
const response = await fetch(this._apiUrl + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: this._apiKey }),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
this._onStatusChange('error', 'Auth failed: ' + (errData.error || response.statusText));
return;
}
const data = await response.json();
this._jwtToken = data.token;
if (!this._jwtToken) {
this._onStatusChange('error', 'No token in auth response');
return;
}
this._onStatusChange('connecting', 'Connecting WebSocket...');
this._connectWebSocket();
} catch (err) {
console.error('[WSClient] Auth error:', err);
this._onStatusChange('error', 'Auth error: ' + err.message);
}
}
disconnect() {
this._intentionalDisconnect = true;
if (this._reconnectTimeout) {
clearTimeout(this._reconnectTimeout);
this._reconnectTimeout = null;
}
this._stopHeartbeat();
if (this._ws) {
this._ws.close();
this._ws = null;
}
this._jwtToken = null;
this._onStatusChange('disconnected', 'Disconnected');
}
_connectWebSocket() {
const wsUrl = this._apiUrl.replace(/^http/, 'ws') + '/api/sessions/live';
try {
this._ws = new WebSocket(wsUrl);
} catch (err) {
this._onStatusChange('error', 'WebSocket error: ' + err.message);
return;
}
this._ws.addEventListener('open', () => {
console.log('[WSClient] Connected, authenticating...');
this._onStatusChange('connecting', 'Authenticating via WS...');
this._reconnectDelay = 1000;
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('[WSClient] Failed to parse message:', event.data);
return;
}
this._handleMessage(message);
});
this._ws.addEventListener('close', (event) => {
console.log('[WSClient] Disconnected, code:', event.code);
this._stopHeartbeat();
if (!this._intentionalDisconnect) {
this._onStatusChange('connecting', 'Reconnecting in ' + Math.round(this._reconnectDelay / 1000) + 's...');
this._scheduleReconnect();
} else {
this._onStatusChange('disconnected', 'Disconnected');
}
});
this._ws.addEventListener('error', (err) => {
console.error('[WSClient] Error:', err);
});
}
_handleMessage(message) {
switch (message.type) {
case 'auth_success':
console.log('[WSClient] Authenticated');
this._onStatusChange('connected', 'Connected');
this._startHeartbeat();
this._fetchActiveSessionAndSubscribe();
break;
case 'auth_error':
console.error('[WSClient] Auth error:', message.message);
this._onStatusChange('error', 'WS auth failed: ' + message.message);
this._intentionalDisconnect = true;
if (this._ws) this._ws.close();
break;
case 'subscribed':
console.log('[WSClient] Subscribed to session:', message.sessionId);
this._onStatusChange('connected', 'Connected (session ' + message.sessionId + ')');
this._onSessionSubscribed(message.sessionId);
break;
case 'pong':
break;
case 'error':
console.error('[WSClient] Server error:', message.message);
break;
default:
// All domain events route to the manager
if (message.data !== undefined) {
this._onEvent(message.type, message.data);
} else {
console.log('[WSClient] Unhandled message type:', message.type);
}
break;
}
}
async _fetchActiveSessionAndSubscribe() {
if (!this._apiUrl || !this._jwtToken) return;
try {
const response = await fetch(this._apiUrl + '/api/sessions/active', {
headers: { 'Authorization': 'Bearer ' + this._jwtToken },
});
if (response.ok) {
const session = await response.json();
if (session && session.id) {
console.log('[WSClient] Found active session:', session.id);
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
}
} else {
console.log('[WSClient] No active session, waiting for session.started');
}
}
} catch (err) {
console.error('[WSClient] Error fetching active session:', err);
}
}
subscribeToSession(sessionId) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'subscribe', sessionId }));
}
}
_startHeartbeat() {
this._stopHeartbeat();
this._heartbeatInterval = setInterval(() => {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
}
_stopHeartbeat() {
if (this._heartbeatInterval) {
clearInterval(this._heartbeatInterval);
this._heartbeatInterval = null;
}
}
_scheduleReconnect() {
if (this._reconnectTimeout) clearTimeout(this._reconnectTimeout);
this._reconnectTimeout = setTimeout(() => {
if (this._apiUrl && this._jwtToken && !this._intentionalDisconnect) {
console.log('[WSClient] Attempting reconnect...');
this._onStatusChange('connecting', 'Reconnecting...');
this._connectWebSocket();
}
this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._maxReconnectDelay);
}, this._reconnectDelay);
}
}
```
The key difference from existing code: `session.started` is now handled as a domain event (forwarded via `_onEvent`) instead of being handled inline. The WebSocket client auto-subscribes to the session from the event data via the `_onEvent``handleEvent` flow in the manager. However, `session.started` needs special handling because it requires a `subscribe` call. Add this to `_handleMessage`:
After the `default` case forwards to `_onEvent`, the caller (bootstrap code) will also listen for `session.started` and call `subscribeToSession`. Alternatively, handle it directly:
In the `_handleMessage` method, add a case before `default`:
```javascript
case 'session.started':
console.log('[WSClient] Session started:', message.data?.session?.id);
if (message.data?.session?.id && this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({
type: 'subscribe',
sessionId: message.data.session.id,
}));
}
this._onEvent(message.type, message.data);
break;
```
**Step 2: Verify module loads**
Same browser console check:
```javascript
import('./js/websocket-client.js').then(m => { window._wsc = m; console.log('loaded', m); });
```
**Step 3: Commit**
```bash
git add js/websocket-client.js
git commit -m "feat: extract WebSocket client into ES module"
```
---
## Task 3: Create `js/room-code-display.js` — Extract Room Code Animation
**Files:**
- Create: `js/room-code-display.js`
- Reference: `optimized-controls.html:739-873` (applySettings + startAnimation), `optimized-controls.html:886-910` (hideDisplay)
**Step 1: Create the RoomCodeDisplay component**
Extract the animation logic into a component that implements the standard interface. The component reads settings from DOM inputs (same as current behavior) but lifecycle is managed externally.
```javascript
export class RoomCodeDisplay {
constructor() {
this._timers = [];
this._active = false;
this._elements = null;
this._inputs = null;
}
init(elements, inputs) {
this._elements = elements;
this._inputs = inputs;
}
activate(ctx) {
this.deactivate();
this._active = true;
if (ctx.roomCode) {
const midpoint = Math.ceil(ctx.roomCode.length / 2);
this._inputs.code1.value = ctx.roomCode.substring(0, midpoint).toUpperCase();
this._inputs.code2.value = ctx.roomCode.substring(midpoint).toUpperCase();
}
this._applySettings();
this._startAnimation();
}
deactivate() {
this._active = false;
this._clearTimers();
if (this._elements) {
const fadeTime = '0.3s';
for (const el of [this._elements.header, this._elements.footer, this._elements.codePart1, this._elements.codePart2]) {
el.style.transition = `opacity ${fadeTime} ease-out`;
el.style.opacity = 0;
}
}
}
update(ctx) {
if (!this._active) return;
// If room code changed mid-lobby, restart
if (ctx.roomCode) {
const midpoint = Math.ceil(ctx.roomCode.length / 2);
const first = ctx.roomCode.substring(0, midpoint).toUpperCase();
const second = ctx.roomCode.substring(midpoint).toUpperCase();
if (first !== this._inputs.code1.value || second !== this._inputs.code2.value) {
this.activate(ctx);
}
}
}
getStatus() {
return {
active: this._active,
roomCode: this._inputs ? (this._inputs.code1.value + this._inputs.code2.value) : '',
timersRunning: this._timers.length,
};
}
_clearTimers() {
this._timers.forEach(t => clearTimeout(t));
this._timers = [];
}
_applySettings() {
const { header, footer, codePart1, codePart2 } = this._elements;
const inp = this._inputs;
codePart1.textContent = inp.code1.value.toUpperCase();
codePart2.textContent = inp.code2.value.toUpperCase();
codePart1.style.color = inp.color1.value;
codePart2.style.color = inp.color2.value;
codePart1.style.transform = `translateY(${inp.offset1.value}px)`;
codePart2.style.transform = `translateY(${inp.offset2.value}px)`;
codePart1.style.fontSize = `${inp.size.value}px`;
codePart2.style.fontSize = `${inp.size.value}px`;
header.textContent = inp.headerText.value;
header.style.color = inp.headerColor.value;
header.style.fontSize = `${inp.headerSize.value}px`;
header.style.transform = `translateY(${inp.headerOffset.value}px)`;
footer.textContent = inp.footerText.value;
footer.style.color = inp.footerColor.value;
footer.style.fontSize = `${inp.footerSize.value}px`;
footer.style.transform = `translateY(${inp.footerOffset.value}px)`;
}
_startAnimation() {
this._clearTimers();
const { header, footer, codePart1, codePart2 } = this._elements;
const inp = this._inputs;
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
const cycleDuration = parseInt(inp.cycle.value) * 1000;
const headerAppearDelayMs = parseInt(inp.headerAppearDelay.value) * 1000;
const headerAppearDurationMs = parseInt(inp.headerAppearDuration.value) * 1000;
this._timers.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 = parseInt(inp.line1AppearDelay.value) * 1000;
const line1AppearDurationMs = parseInt(inp.line1AppearDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart1.style.transition = `opacity ${line1AppearDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 1;
}, line1AppearDelayMs));
const line1HideTimeMs = parseInt(inp.line1HideTime.value) * 1000;
const line1HideDurationMs = parseInt(inp.line1HideDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart1.style.transition = `opacity ${line1HideDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 0;
}, line1HideTimeMs));
const line2AppearDelayMs = parseInt(inp.line2AppearDelay.value) * 1000;
const line2AppearDurationMs = parseInt(inp.line2AppearDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart2.style.transition = `opacity ${line2AppearDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 1;
}, line2AppearDelayMs));
const line2HideTimeMs = parseInt(inp.line2HideTime.value) * 1000;
const line2HideDurationMs = parseInt(inp.line2HideDuration.value) * 1000;
this._timers.push(setTimeout(() => {
codePart2.style.transition = `opacity ${line2HideDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 0;
}, line2HideTimeMs));
const headerHideTimeMs = parseInt(inp.headerHideTime.value) * 1000;
const headerHideDurationMs = parseInt(inp.headerHideDuration.value) * 1000;
this._timers.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));
// Restart animation loop
this._timers.push(setTimeout(() => {
if (this._active) this._startAnimation();
}, cycleDuration));
}
}
```
**Step 2: Commit**
```bash
git add js/room-code-display.js
git commit -m "feat: extract room code display into ES module component"
```
---
## Task 4: Create `js/audio-controller.js` — Extract Audio Logic
**Files:**
- Create: `js/audio-controller.js`
- Reference: `optimized-controls.html:784-793` (play in startAnimation), `optimized-controls.html:857-866` (fade out), `optimized-controls.html:904-905` (stop in hideDisplay)
This is the core of the audio restart bug fix. The current code's audio state gets tangled because `hideDisplay()` pauses and resets the audio, but subsequent `startAnimation()` calls don't reload the source or handle the paused+reset state consistently (especially after the fade-out interval modifies `volume`).
```javascript
export class AudioController {
constructor() {
this._active = false;
this._audioEl = null;
this._inputs = null;
this._fadeInterval = null;
}
init(audioElement, inputs) {
this._audioEl = audioElement;
this._inputs = inputs;
}
activate(ctx) {
this.deactivate();
this._active = true;
if (!this._inputs.enabled.checked) return;
const audio = this._audioEl;
// Reload source to ensure clean state
audio.src = this._inputs.soundUrl.value;
audio.volume = parseFloat(this._inputs.volume.value);
audio.loop = true;
audio.currentTime = 0;
audio.load();
audio.play().catch(err => {
console.log('[Audio] Playback failed:', err.message);
});
}
deactivate() {
this._active = false;
this._stopFade();
if (this._audioEl) {
this._audioEl.pause();
this._audioEl.currentTime = 0;
// Restore volume to slider value for next activation
if (this._inputs) {
this._audioEl.volume = parseFloat(this._inputs.volume.value);
}
}
}
update(_ctx) {
// No in-state updates needed for audio
}
getStatus() {
const audio = this._audioEl;
return {
active: this._active,
enabled: this._inputs?.enabled.checked ?? false,
playing: audio ? !audio.paused : false,
volume: audio ? Math.round(audio.volume * 100) : 0,
src: audio?.src || '',
};
}
startFadeOut(durationMs) {
if (!this._audioEl || this._audioEl.paused) return;
this._stopFade();
const steps = 10;
const stepInterval = durationMs / steps;
const volumeStep = this._audioEl.volume / steps;
this._fadeInterval = setInterval(() => {
if (this._audioEl.volume > volumeStep) {
this._audioEl.volume -= volumeStep;
} else {
this._audioEl.volume = 0;
this._audioEl.pause();
this._stopFade();
}
}, stepInterval);
}
_stopFade() {
if (this._fadeInterval) {
clearInterval(this._fadeInterval);
this._fadeInterval = null;
}
}
}
```
The key fix: `activate()` always calls `deactivate()` first, which stops any in-progress fade, pauses audio, and resets state. Then it reloads the source (`audio.src = ...`, `audio.load()`), restores volume to the slider value, and plays from the beginning. This guarantees clean audio state on every lobby entry.
**Step 2: Commit**
```bash
git add js/audio-controller.js
git commit -m "feat: extract audio controller into ES module, fix restart bug"
```
---
## Task 5: Create `js/player-list.js` — New Player List Component
**Files:**
- Create: `js/player-list.js`
**Step 1: Create the PlayerList component**
```javascript
const DEFAULT_MAX_PLAYERS = 8;
export class PlayerList {
constructor() {
this._active = false;
this._container = null;
this._inputs = null;
this._slots = [];
this._players = [];
this._maxPlayers = DEFAULT_MAX_PLAYERS;
this._animationTimers = [];
}
init(container, inputs, animationTimingInputs) {
this._container = container;
this._inputs = inputs;
this._timingInputs = animationTimingInputs;
}
activate(ctx) {
this.deactivate();
this._active = true;
if (!this._inputs.enabled.checked) return;
this._maxPlayers = ctx.maxPlayers || DEFAULT_MAX_PLAYERS;
this._players = ctx.players || [];
this._buildSlots();
this._fillSlots(this._players);
this._animateIn();
}
deactivate() {
this._active = false;
this._clearTimers();
if (this._container) {
this._container.style.transition = 'opacity 0.3s ease-out';
this._container.style.opacity = 0;
setTimeout(() => {
if (!this._active) {
this._container.innerHTML = '';
this._slots = [];
this._players = [];
}
}, 300);
}
}
update(ctx) {
if (!this._active || !this._inputs.enabled.checked) return;
if (ctx.maxPlayers && ctx.maxPlayers !== this._maxPlayers) {
this._maxPlayers = ctx.maxPlayers;
this._buildSlots();
this._fillSlots(ctx.players || this._players);
return;
}
if (ctx.players) {
const newPlayers = ctx.players.filter(p => !this._players.includes(p));
this._players = ctx.players;
this._fillSlots(this._players, newPlayers);
}
}
getStatus() {
return {
active: this._active,
enabled: this._inputs?.enabled.checked ?? false,
playerCount: this._players.length,
maxPlayers: this._maxPlayers,
players: [...this._players],
};
}
_buildSlots() {
this._container.innerHTML = '';
this._slots = [];
for (let i = 0; i < this._maxPlayers; i++) {
const slot = document.createElement('div');
slot.className = 'player-slot';
slot.dataset.index = 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);
this._container.appendChild(slot);
this._slots.push({ element: slot, nameEl: name, filled: false });
}
}
_fillSlots(players, newlyJoined = []) {
for (let i = 0; i < this._slots.length; i++) {
const slot = this._slots[i];
if (i < players.length) {
const playerName = players[i];
const isNew = newlyJoined.includes(playerName);
slot.nameEl.textContent = playerName;
slot.nameEl.classList.remove('empty');
slot.nameEl.classList.add('filled');
slot.filled = true;
if (isNew) {
slot.nameEl.style.opacity = 0;
requestAnimationFrame(() => {
slot.nameEl.style.transition = 'opacity 0.4s ease-out';
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;
}
}
}
_animateIn() {
this._container.style.opacity = 0;
this._container.style.display = 'flex';
const headerAppearDelay = parseInt(this._timingInputs.headerAppearDelay.value) * 1000;
const headerAppearDuration = parseInt(this._timingInputs.headerAppearDuration.value) * 1000;
this._animationTimers.push(setTimeout(() => {
this._container.style.transition = `opacity ${headerAppearDuration / 1000}s ease-out`;
this._container.style.opacity = 1;
}, headerAppearDelay));
}
_clearTimers() {
this._animationTimers.forEach(t => clearTimeout(t));
this._animationTimers = [];
}
}
```
**Step 2: Commit**
```bash
git add js/player-list.js
git commit -m "feat: add player list component with slot-based display"
```
---
## Task 6: Create `js/controls.js` — Debug Dashboard & Control Bindings
**Files:**
- Create: `js/controls.js`
**Step 1: Create the Controls module**
This module handles the debug dashboard, manual overrides, and binds existing settings UI elements. It exports an `initControls` function rather than a class.
```javascript
import { OVERRIDE_MODES } from './state-manager.js';
const STATE_COLORS = {
idle: '#888',
lobby: '#4CAF50',
playing: '#f0ad4e',
ended: '#d9534f',
disconnected: '#d9534f',
};
export function initControls(manager, wsClient, components) {
const dashboardEl = document.getElementById('manager-dashboard');
if (!dashboardEl) return;
const stateEl = dashboardEl.querySelector('#manager-state');
const roomCodeEl = dashboardEl.querySelector('#manager-room-code');
const sessionIdEl = dashboardEl.querySelector('#manager-session-id');
const gameTitleEl = dashboardEl.querySelector('#manager-game-title');
const playerCountEl = dashboardEl.querySelector('#manager-player-count');
const eventLogEl = dashboardEl.querySelector('#manager-event-log');
// Override toggles
for (const name of Object.keys(components)) {
const select = dashboardEl.querySelector(`#override-${name}`);
if (select) {
select.addEventListener('change', () => {
manager.setOverride(name, select.value);
});
}
}
// Update dashboard on state change
manager.onChange((state, ctx) => {
if (stateEl) {
stateEl.textContent = state.toUpperCase();
stateEl.style.backgroundColor = STATE_COLORS[state] || '#888';
}
if (roomCodeEl) roomCodeEl.textContent = ctx.roomCode || '—';
if (sessionIdEl) sessionIdEl.textContent = ctx.sessionId || '—';
if (gameTitleEl) gameTitleEl.textContent = ctx.gameTitle ? `${ctx.gameTitle} (${ctx.packName || ''})` : '—';
if (playerCountEl) {
const count = ctx.playerCount ?? 0;
const max = ctx.maxPlayers ?? '?';
playerCountEl.textContent = `${count} / ${max}`;
}
// Update component status rows
const statuses = manager.getComponentStatuses();
for (const [name, info] of Object.entries(statuses)) {
const statusEl = dashboardEl.querySelector(`#status-${name}`);
if (statusEl) {
const s = info.status;
let text = s.active ? 'Active' : 'Inactive';
if (name === 'playerList' && s.active) {
text = `Active (${s.playerCount}/${s.maxPlayers})`;
}
if (name === 'audio' && s.active) {
text = s.playing ? 'Playing' : 'Active (muted)';
}
statusEl.textContent = text;
}
}
// Event log
if (eventLogEl) {
const log = manager.getEventLog();
const lastEvents = log.slice(-20);
eventLogEl.innerHTML = lastEvents.map(e => {
const time = new Date(e.timestamp).toLocaleTimeString();
return `<div class="event-log-entry"><span class="event-time">${time}</span> <span class="event-type">${e.type}</span></div>`;
}).join('');
eventLogEl.scrollTop = eventLogEl.scrollHeight;
}
});
// Connection buttons
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) {
connectBtn.addEventListener('click', () => {
const url = apiUrlInput.value.trim();
const key = apiKeyInput.value.trim();
if (url) localStorage.setItem('jackbox-api-url', url);
if (key) localStorage.setItem('jackbox-api-key', key);
wsClient.connect(url, key);
});
}
if (disconnectBtn) {
disconnectBtn.addEventListener('click', () => {
wsClient.disconnect();
manager.transition('idle');
});
}
// Settings change handlers
apiUrlInput?.addEventListener('change', () => {
const url = apiUrlInput.value.trim();
if (url) localStorage.setItem('jackbox-api-url', url);
});
apiKeyInput?.addEventListener('change', () => {
localStorage.setItem('jackbox-api-key', apiKeyInput.value.trim());
});
// Volume slider
const volumeSlider = document.getElementById('volume-slider');
const volumeValue = document.getElementById('volume-value');
const themeSound = document.getElementById('theme-sound');
if (volumeSlider && volumeValue) {
volumeSlider.addEventListener('input', () => {
volumeValue.textContent = Math.round(volumeSlider.value * 100) + '%';
if (themeSound) themeSound.volume = volumeSlider.value;
});
}
// Test sound button
const testSoundBtn = document.getElementById('test-sound-btn');
const soundUrlInput = document.getElementById('sound-url-input');
if (testSoundBtn && themeSound && soundUrlInput) {
testSoundBtn.addEventListener('click', () => {
themeSound.src = soundUrlInput.value;
themeSound.volume = volumeSlider?.value || 0.5;
themeSound.currentTime = 0;
themeSound.loop = false;
themeSound.play().catch(err => {
console.log('[Test] Playback failed:', err.message);
alert('Audio playback failed. Check URL or browser autoplay permissions.');
});
setTimeout(() => {
themeSound.pause();
themeSound.loop = true;
}, 5000);
});
}
// Manual Update/Preview buttons
const updateBtn = document.getElementById('update-btn');
const previewBtn = document.getElementById('preview-btn');
if (updateBtn) {
updateBtn.addEventListener('click', () => {
// Manually trigger lobby state with current input values
const code1 = document.getElementById('code1-input')?.value || '';
const code2 = document.getElementById('code2-input')?.value || '';
const roomCode = code1 + code2;
if (roomCode) {
manager.handleEvent('game.added', {
session: {},
game: { room_code: roomCode, title: 'Manual', max_players: 8 },
});
}
});
}
if (previewBtn) {
previewBtn.addEventListener('click', () => {
const code1 = document.getElementById('code1-input')?.value || '';
const code2 = document.getElementById('code2-input')?.value || '';
const roomCode = code1 + code2;
if (roomCode) {
manager.handleEvent('game.added', {
session: {},
game: { room_code: roomCode, title: 'Preview', max_players: 8 },
});
}
});
}
// Toggle display button
const toggleDisplayBtn = document.getElementById('toggle-display-btn');
if (toggleDisplayBtn) {
toggleDisplayBtn.addEventListener('click', () => {
if (manager.state === 'lobby') {
manager.transition('idle');
toggleDisplayBtn.textContent = 'Show Display';
} else {
const code1 = document.getElementById('code1-input')?.value || '';
const code2 = document.getElementById('code2-input')?.value || '';
const roomCode = code1 + code2;
if (roomCode) {
manager.transition('lobby', { roomCode });
toggleDisplayBtn.textContent = 'Hide Display';
}
}
});
}
// Show/hide controls panel
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';
}
});
}
// Collapsible sections
document.querySelectorAll('.section-header').forEach(hdr => {
hdr.addEventListener('click', () => {
const content = hdr.nextElementSibling;
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');
}
});
});
// Position toggle
const positionToggle = document.getElementById('position-toggle');
if (positionToggle && controls) {
positionToggle.addEventListener('change', () => {
controls.classList.toggle('bottom-position', positionToggle.checked);
});
}
// Inactivity timer
let inactivityTimer;
function resetInactivityTimer() {
clearTimeout(inactivityTimer);
inactivityTimer = 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();
}
export function initConnectionStatusHandler(wsClient) {
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 || 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;
}
};
}
```
**Step 2: Commit**
```bash
git add js/controls.js
git commit -m "feat: add controls module with debug dashboard and override support"
```
---
## Task 7: Update `optimized-controls.html` — Add Player List DOM, Dashboard DOM, CSS, Bootstrap
**Files:**
- Modify: `optimized-controls.html`
This is the largest task. The HTML file needs:
1. New CSS for player list and debug dashboard
2. New DOM elements for player list container
3. New DOM elements for debug dashboard section in controls
4. New player list settings section in controls
5. Replace the entire `<script>` block with a module bootstrap
6. Remove the old inline JS (lines 634-1345)
**Step 1: Add player list and dashboard CSS**
Add new CSS rules after the existing `.mode-label` rule (around line 360, before `</style>`). These style the player list, the debug dashboard, and override controls.
**Step 2: Add player list container to the DOM**
After the `<div id="footer">` element, add a `<div id="player-list-container">` that will be positioned to the left or right of the room code.
**Step 3: Add player list settings section to controls**
After the Audio Settings section (around line 572), add a new collapsible "Player List Settings" section with:
- Enable/disable checkbox (`#player-list-enabled`)
- Position select: left/right (`#player-list-position`)
- Font size input (`#player-list-font-size`)
- Text color input (`#player-list-text-color`)
- Empty slot color input (`#player-list-empty-color`)
- Vertical offset input (`#player-list-offset`)
**Step 4: Add Overlay Manager dashboard section to controls**
After the Player List Settings section, add a new collapsible "Overlay Manager" section with:
- State badge (`#manager-state`)
- Room code display (`#manager-room-code`)
- Session ID (`#manager-session-id`)
- Game title (`#manager-game-title`)
- Player count (`#manager-player-count`)
- Component status table with override selects for `roomCode`, `audio`, `playerList`
- Event log container (`#manager-event-log`)
**Step 5: Replace inline script with module bootstrap**
Replace the entire `<script>` block (lines 634-1345) with:
```html
<script type="module">
import { OverlayManager } from './js/state-manager.js';
import { WebSocketClient } from './js/websocket-client.js';
import { RoomCodeDisplay } from './js/room-code-display.js';
import { AudioController } from './js/audio-controller.js';
import { PlayerList } from './js/player-list.js';
import { initControls, initConnectionStatusHandler } from './js/controls.js';
const manager = new OverlayManager();
// Room Code Display component
const roomCodeDisplay = new RoomCodeDisplay();
roomCodeDisplay.init(
{
header: document.getElementById('header'),
footer: document.getElementById('footer'),
codePart1: document.getElementById('code-part1'),
codePart2: document.getElementById('code-part2'),
},
{
code1: document.getElementById('code1-input'),
code2: document.getElementById('code2-input'),
color1: document.getElementById('color1-input'),
color2: document.getElementById('color2-input'),
offset1: document.getElementById('offset1-input'),
offset2: document.getElementById('offset2-input'),
size: document.getElementById('size-input'),
cycle: document.getElementById('cycle-input'),
headerText: document.getElementById('header-text-input'),
headerColor: document.getElementById('header-color-input'),
headerSize: document.getElementById('header-size-input'),
headerOffset: document.getElementById('header-offset-input'),
footerText: document.getElementById('footer-text-input'),
footerColor: document.getElementById('footer-color-input'),
footerSize: document.getElementById('footer-size-input'),
footerOffset: document.getElementById('footer-offset-input'),
headerAppearDelay: document.getElementById('header-appear-delay'),
headerAppearDuration: document.getElementById('header-appear-duration'),
headerHideTime: document.getElementById('header-hide-time'),
headerHideDuration: document.getElementById('header-hide-duration'),
line1AppearDelay: document.getElementById('line1-appear-delay'),
line1AppearDuration: document.getElementById('line1-appear-duration'),
line1HideTime: document.getElementById('line1-hide-time'),
line1HideDuration: document.getElementById('line1-hide-duration'),
line2AppearDelay: document.getElementById('line2-appear-delay'),
line2AppearDuration: document.getElementById('line2-appear-duration'),
line2HideTime: document.getElementById('line2-hide-time'),
line2HideDuration: document.getElementById('line2-hide-duration'),
}
);
manager.registerComponent('roomCode', roomCodeDisplay);
// Audio Controller component
const audioController = new AudioController();
audioController.init(
document.getElementById('theme-sound'),
{
enabled: document.getElementById('sound-enabled'),
volume: document.getElementById('volume-slider'),
soundUrl: document.getElementById('sound-url-input'),
}
);
manager.registerComponent('audio', audioController);
// Player List component
const playerList = new PlayerList();
playerList.init(
document.getElementById('player-list-container'),
{
enabled: document.getElementById('player-list-enabled'),
position: document.getElementById('player-list-position'),
fontSize: document.getElementById('player-list-font-size'),
textColor: document.getElementById('player-list-text-color'),
emptyColor: document.getElementById('player-list-empty-color'),
offset: document.getElementById('player-list-offset'),
},
{
headerAppearDelay: document.getElementById('header-appear-delay'),
headerAppearDuration: document.getElementById('header-appear-duration'),
}
);
manager.registerComponent('playerList', playerList);
// WebSocket Client
const statusHandler = initConnectionStatusHandler();
const wsClient = new WebSocketClient({
onStatusChange: statusHandler,
onEvent: (type, data) => manager.handleEvent(type, data),
onSessionSubscribed: (sessionId) => {
console.log('[Bootstrap] Subscribed to session:', sessionId);
},
});
// Initialize controls, dashboard, and event bindings
initControls(manager, wsClient, {
roomCode: roomCodeDisplay,
audio: audioController,
playerList: playerList,
});
// Player list position reactivity
const plPositionSelect = document.getElementById('player-list-position');
const plContainer = document.getElementById('player-list-container');
if (plPositionSelect && plContainer) {
plPositionSelect.addEventListener('change', () => {
plContainer.classList.toggle('position-left', plPositionSelect.value === 'left');
plContainer.classList.toggle('position-right', plPositionSelect.value === 'right');
});
// Set initial position
plContainer.classList.add('position-' + (plPositionSelect.value || 'right'));
}
// Auto-connect from localStorage
const savedUrl = localStorage.getItem('jackbox-api-url');
const savedKey = localStorage.getItem('jackbox-api-key');
if (savedUrl) document.getElementById('api-url-input').value = savedUrl;
if (savedKey) document.getElementById('api-key-input').value = savedKey;
if (savedUrl && savedKey) {
setTimeout(() => wsClient.connect(savedUrl, savedKey), 500);
}
// Expose for debugging
window.__overlay = { manager, wsClient, roomCodeDisplay, audioController, playerList };
</script>
```
**Step 6: Verify in browser**
1. Open the HTML file in a browser
2. Open DevTools console — should see no import errors
3. Open controls panel — should see all existing settings plus new Player List and Overlay Manager sections
4. Type a code in First/Second Line inputs, click Update — room code animation should play with audio
5. Check `window.__overlay.manager.state` — should be `'lobby'`
6. Click Hide Display — should transition to `'idle'` and stop everything
**Step 7: Commit**
```bash
git add optimized-controls.html
git commit -m "feat: integrate module system, add player list + dashboard DOM"
```
---
## Task 8: End-to-End Verification & Polish
**Files:**
- Possibly modify: any of the above files for bug fixes
**Step 1: Test the full WebSocket flow**
If you have access to a running Game Picker API instance:
1. Connect via the overlay's Connection Settings
2. Verify `Connected` status
3. Add a game with a room code in the Game Picker
4. Verify: room code appears, audio plays, player list shows empty slots
5. If shard monitor is running, verify players populate the list as they join
6. When game starts: verify everything hides
7. Add another game: verify full reset and clean restart
If no API instance is available, test manually:
1. Open DevTools console
2. Run: `window.__overlay.manager.handleEvent('game.added', { session: { id: 1 }, game: { room_code: 'ABCD', title: 'Test Game', max_players: 6 } })`
3. Verify: room code displays "AB" / "CD", audio plays, 6 empty player slots appear
4. Run: `window.__overlay.manager.handleEvent('lobby.player-joined', { players: ['Alice'], playerCount: 1, maxPlayers: 6, playerName: 'Alice' })`
5. Verify: first slot fills with "Alice"
6. Run: `window.__overlay.manager.handleEvent('game.started', { playerCount: 1, players: ['Alice'] })`
7. Verify: everything hides
8. Run the `game.added` event again — verify audio restarts cleanly
**Step 2: Test overrides**
1. Set Room Code override to "Force Show"
2. Verify it stays visible even when state transitions to `idle`
3. Set Audio override to "Force Hide"
4. Trigger a lobby — verify room code and player list appear but audio does not play
5. Reset all overrides to "Auto"
**Step 3: Test edge cases**
- Rapid `game.added` events (new code while animation is running)
- `room.disconnected` during lobby
- `session.ended` during lobby
- Player list with 0 maxPlayers (should default to 8)
- Player list disabled in settings
**Step 4: Fix any issues found**
Address bugs discovered during testing.
**Step 5: Final commit**
```bash
git add -A
git commit -m "fix: address issues found during end-to-end testing"
```
---
## Summary of Changes
| File | Action | Purpose |
|------|--------|---------|
| `js/state-manager.js` | Create | OverlayManager state machine |
| `js/websocket-client.js` | Create | Extracted WebSocket auth/connect/events |
| `js/room-code-display.js` | Create | Extracted room code animation component |
| `js/audio-controller.js` | Create | Extracted audio lifecycle (fixes restart bug) |
| `js/player-list.js` | Create | New player slot list component |
| `js/controls.js` | Create | Settings bindings + debug dashboard |
| `optimized-controls.html` | Modify | Add player list/dashboard DOM + CSS, replace inline JS with module bootstrap |