1580 lines
52 KiB
Markdown
1580 lines
52 KiB
Markdown
|
|
# 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 |
|