Compare commits
10 Commits
f3cbf10937
...
875153ef63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
875153ef63
|
||
|
|
19c94d294f
|
||
|
|
f754b227b3
|
||
|
|
cddfe9125d
|
||
|
|
f0db0e8642
|
||
|
|
6b78928269
|
||
|
|
1ed647208e
|
||
|
|
284830a24b
|
||
|
|
41773d0fef
|
||
|
|
c049cddb6d
|
1579
docs/plans/2026-03-20-overlay-manager-implementation.md
Normal file
1579
docs/plans/2026-03-20-overlay-manager-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
154
docs/plans/2026-03-20-overlay-manager-player-list-design.md
Normal file
154
docs/plans/2026-03-20-overlay-manager-player-list-design.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Overlay Manager & Player List Design
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The OBS overlay (`optimized-controls.html`) has two issues after the upstream Game Picker API migrated from Puppeteer to an ecast shard monitor:
|
||||
|
||||
1. **Audio doesn't restart on new room codes.** The overlay updates the room code text when `game.added` fires, but audio playback fails to reinitialize because `hideDisplay()` (triggered by `game.started` or `audience.joined`) leaves the audio element in a state that `startAnimation()` doesn't fully recover from. There is no centralized lifecycle management — show/hide logic is scattered across event handlers.
|
||||
|
||||
2. **No player list.** The new shard-based API pushes real-time player names and join events (`room.connected`, `lobby.player-joined`), but the overlay doesn't consume them. Players joining the lobby should be visible to viewers.
|
||||
|
||||
Additionally, several new WebSocket events (`room.connected`, `lobby.player-joined`, `lobby.updated`, `game.ended`, `room.disconnected`) are not handled, and the removed `audience.joined` event is still referenced.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Event-driven state machine** over reactive flags or per-component lifecycle management. A central `OverlayManager` coordinates all components through explicit state transitions, preventing the ad-hoc state bugs that cause the current audio issue.
|
||||
- **ES module split** (no build step) over single-file or bundled architecture. Keeps OBS browser source simplicity while improving maintainability.
|
||||
- **WebSocket-only player data** — no REST polling fallback for player lists.
|
||||
- **Overlay visible only during lobby state** — room code, audio, and player list all share the same lifecycle.
|
||||
|
||||
## Architecture
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
idle → lobby → playing → ended → idle
|
||||
↑ |
|
||||
└─────────────────────────┘
|
||||
(new game.added)
|
||||
|
||||
Any state → disconnected → idle (on reconnect, no active lobby)
|
||||
→ lobby (on reconnect, active lobby)
|
||||
```
|
||||
|
||||
| State | Entry Trigger | Visible Components |
|
||||
|-------|--------------|-------------------|
|
||||
| `idle` | Initial load, `session.ended`, `game.ended`, `room.disconnected` | None |
|
||||
| `lobby` | `game.added`, `room.connected` (lobby state) | Room code + audio + player list |
|
||||
| `playing` | `game.started` | None |
|
||||
| `ended` | `game.ended` | None (transitions to `idle` after brief delay) |
|
||||
| `disconnected` | WebSocket close/error | None (reconnect logic runs) |
|
||||
|
||||
Key transition: `game.added` while already in `lobby` triggers a **full reset** — deactivate all components, update context, reactivate. This fixes the audio restart bug by design.
|
||||
|
||||
### Component Interface
|
||||
|
||||
Every component implements:
|
||||
|
||||
- `activate(context)` — enter active state with room/game/player context
|
||||
- `deactivate()` — fully clean up (stop audio, clear timers, hide elements)
|
||||
- `update(context)` — handle in-state updates (new player joined, etc.)
|
||||
- `getStatus()` — return current status for the debug panel
|
||||
|
||||
### File Layout
|
||||
|
||||
```
|
||||
OBS-stuff/
|
||||
├── optimized-controls.html # DOM + CSS + bootstrap script
|
||||
├── js/
|
||||
│ ├── state-manager.js # OverlayManager: state machine, component registry
|
||||
│ ├── websocket-client.js # Auth, connect, reconnect, event routing
|
||||
│ ├── room-code-display.js # Room code animation component
|
||||
│ ├── audio-controller.js # Audio playback lifecycle
|
||||
│ ├── player-list.js # Player slot list component
|
||||
│ └── controls.js # Settings panel + debug dashboard
|
||||
```
|
||||
|
||||
Loaded via `<script type="module">` — no build step.
|
||||
|
||||
## Player List
|
||||
|
||||
### Visual Design
|
||||
|
||||
Vertical numbered roster positioned to the left or right of the room code (configurable):
|
||||
|
||||
```
|
||||
1. xXSlayerXx ← filled (bright text)
|
||||
2. CoolPlayer42 ← filled
|
||||
3. ──────────── ← empty (dim placeholder)
|
||||
4. ────────────
|
||||
5. ────────────
|
||||
```
|
||||
|
||||
Slot count equals `maxPlayers` from the shard's `room.connected` event (fallback: game catalog `max_players` from `game.added`, default: 8).
|
||||
|
||||
### Configuration
|
||||
|
||||
- Enable/disable toggle
|
||||
- Position: left or right of room code
|
||||
- Font size, text color, empty slot color
|
||||
- Vertical offset
|
||||
|
||||
### Behavior
|
||||
|
||||
1. `activate(ctx)`: Create `maxPlayers` empty slots. Fade in using the same timing curve as the room code animation.
|
||||
2. `lobby.player-joined`: Fill next empty slot with player name (subtle fade-in on the name text). Uses the `players[]` array from the event to diff against displayed slots.
|
||||
3. `room.connected` (with existing players — e.g., reconnect): Bulk-fill all known players.
|
||||
4. `deactivate()`: Fade out all slots, clear the list.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- More players than `maxPlayers` (audience members): ignored, only player slots shown.
|
||||
- Player names unavailable: fallback to "Player 1", "Player 2", etc.
|
||||
- No `maxPlayers` data yet: default to 8 slots, update when data arrives.
|
||||
|
||||
## Debug Dashboard & Manual Overrides
|
||||
|
||||
New collapsible "Overlay Manager" section in the controls panel.
|
||||
|
||||
### State Display
|
||||
|
||||
- Current state badge (color-coded): `IDLE` / `LOBBY` / `PLAYING` / `ENDED` / `DISCONNECTED`
|
||||
- Room code, session ID, game title + pack
|
||||
- Player count: `3 / 8`
|
||||
|
||||
### Component Override Table
|
||||
|
||||
| Component | State | Override |
|
||||
|-----------|-------|----------|
|
||||
| Room Code | Active (cycling) | [Auto] / [Force Show] / [Force Hide] |
|
||||
| Audio | Active (playing) | [Auto] / [Force Show] / [Force Hide] |
|
||||
| Player List | Active (3/8) | [Auto] / [Force Show] / [Force Hide] |
|
||||
|
||||
- **Auto** (default): follows state machine
|
||||
- **Force Show**: always visible, uses last known context
|
||||
- **Force Hide**: always hidden regardless of state
|
||||
- Overrides are session-only (not persisted to localStorage)
|
||||
|
||||
### Event Log
|
||||
|
||||
Scrollable log of last ~20 WebSocket events with timestamps.
|
||||
|
||||
## WebSocket Event Mapping
|
||||
|
||||
| Event | New Behavior |
|
||||
|-------|-------------|
|
||||
| `game.added` | Transition to `lobby` with room code + game metadata |
|
||||
| `room.connected` | Enrich lobby context: `maxPlayers`, initial `players[]` |
|
||||
| `lobby.player-joined` | Update player list with new player |
|
||||
| `lobby.updated` | Update lobby context |
|
||||
| `game.started` | Transition to `playing` |
|
||||
| `game.ended` | Transition to `ended` → `idle` |
|
||||
| `room.disconnected` | Transition to `idle` (with reason logging) |
|
||||
| `session.started` | Subscribe to session, set session context |
|
||||
| `session.ended` | Transition to `idle`, clear all context |
|
||||
| `player-count.updated` | Update player count (manual REST override, less common now) |
|
||||
| `vote.received` | Pass-through logging |
|
||||
| `audience.joined` | **Removed** — no longer exists in the new API |
|
||||
|
||||
Auth flow unchanged: `POST /api/auth/login` with `{ key }`, JWT response, Bearer header.
|
||||
|
||||
Reconnection preserves current exponential backoff. On reconnect: re-auth, fetch active session, re-subscribe. If lobby is active, `room.connected` fires with current state.
|
||||
139
js/audio-controller.js
Normal file
139
js/audio-controller.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Theme audio for the room code lobby: reload, volume, loop, and fade-out.
|
||||
* Implements the overlay component contract: init, activate, deactivate, update, getStatus.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} AudioControllerInputs
|
||||
* @property {HTMLInputElement} enabled
|
||||
* @property {HTMLInputElement} volume
|
||||
* @property {HTMLInputElement} soundUrl
|
||||
*/
|
||||
|
||||
export class AudioController {
|
||||
/** @type {HTMLAudioElement | null} */
|
||||
#audio = null;
|
||||
|
||||
/** @type {AudioControllerInputs | null} */
|
||||
#inputs = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
#active = false;
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
#fadeInterval = null;
|
||||
|
||||
/**
|
||||
* @param {HTMLAudioElement} audioElement
|
||||
* @param {AudioControllerInputs} inputs
|
||||
*/
|
||||
init(audioElement, inputs) {
|
||||
this.#audio = audioElement;
|
||||
this.#inputs = inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [_ctx]
|
||||
*/
|
||||
activate(_ctx) {
|
||||
this.deactivate();
|
||||
|
||||
this.#active = true;
|
||||
|
||||
const inputs = this.#inputs;
|
||||
const audio = this.#audio;
|
||||
if (!inputs || !audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inputs.enabled.checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
audio.src = inputs.soundUrl.value;
|
||||
audio.load();
|
||||
audio.volume = parseFloat(inputs.volume.value);
|
||||
audio.loop = true;
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch((error) => {
|
||||
console.log('Audio playback failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.#active = false;
|
||||
|
||||
if (this.#fadeInterval != null) {
|
||||
clearInterval(this.#fadeInterval);
|
||||
this.#fadeInterval = null;
|
||||
}
|
||||
|
||||
const audio = this.#audio;
|
||||
const inputs = this.#inputs;
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
|
||||
if (inputs) {
|
||||
audio.volume = parseFloat(inputs.volume.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [_ctx]
|
||||
*/
|
||||
update(_ctx) {
|
||||
// No in-state audio updates.
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
const audio = this.#audio;
|
||||
const inputs = this.#inputs;
|
||||
|
||||
return {
|
||||
active: this.#active,
|
||||
enabled: inputs?.enabled.checked ?? false,
|
||||
playing: Boolean(audio && !audio.paused),
|
||||
volume: audio?.volume ?? 0,
|
||||
src: audio?.currentSrc ?? audio?.src ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gradually reduce volume to zero, then pause. No-op if audio is already paused.
|
||||
* @param {number} durationMs
|
||||
*/
|
||||
startFadeOut(durationMs) {
|
||||
const audio = this.#audio;
|
||||
if (!audio || audio.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#fadeInterval != null) {
|
||||
clearInterval(this.#fadeInterval);
|
||||
this.#fadeInterval = null;
|
||||
}
|
||||
|
||||
const startVolume = audio.volume;
|
||||
const stepCount = 10;
|
||||
const intervalMs = Math.max(1, durationMs / stepCount);
|
||||
let step = 0;
|
||||
|
||||
this.#fadeInterval = setInterval(() => {
|
||||
step += 1;
|
||||
audio.volume = Math.max(0, startVolume * (1 - step / stepCount));
|
||||
|
||||
if (step >= stepCount) {
|
||||
audio.volume = 0;
|
||||
audio.pause();
|
||||
if (this.#fadeInterval != null) {
|
||||
clearInterval(this.#fadeInterval);
|
||||
this.#fadeInterval = null;
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
}
|
||||
360
js/controls.js
vendored
Normal file
360
js/controls.js
vendored
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Debug dashboard, manual overrides, and bindings for the controls panel.
|
||||
*/
|
||||
|
||||
import { OVERRIDE_MODES } from './state-manager.js';
|
||||
|
||||
const STATE_COLORS = Object.freeze({
|
||||
idle: '#888',
|
||||
lobby: '#4CAF50',
|
||||
playing: '#f0ad4e',
|
||||
ended: '#d9534f',
|
||||
disconnected: '#d9534f',
|
||||
});
|
||||
|
||||
const STORAGE_API_URL = 'jackbox-api-url';
|
||||
const STORAGE_API_KEY = 'jackbox-api-key';
|
||||
|
||||
/**
|
||||
* @param {import('./state-manager.js').OverlayManager} manager
|
||||
* @param {import('./websocket-client.js').WebSocketClient} wsClient
|
||||
* @param {{ roomCode?: unknown, audio?: unknown, playerList?: unknown }} components
|
||||
*/
|
||||
export function initControls(manager, wsClient, components) {
|
||||
const stateEl = document.getElementById('manager-state');
|
||||
const roomCodeEl = document.getElementById('manager-room-code');
|
||||
const sessionIdEl = document.getElementById('manager-session-id');
|
||||
const gameTitleEl = document.getElementById('manager-game-title');
|
||||
const playerCountEl = document.getElementById('manager-player-count');
|
||||
const eventLogEl = document.getElementById('manager-event-log');
|
||||
|
||||
const componentNames = Object.keys(components);
|
||||
|
||||
for (const name of componentNames) {
|
||||
const select = document.getElementById(`override-${name}`);
|
||||
if (!select) continue;
|
||||
if (select.options.length === 0) {
|
||||
for (const mode of Object.values(OVERRIDE_MODES)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mode;
|
||||
opt.textContent = mode.replace(/_/g, ' ');
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
select.value = manager.getOverride(name);
|
||||
select.addEventListener('change', () => {
|
||||
manager.setOverride(name, select.value);
|
||||
});
|
||||
}
|
||||
|
||||
function formatGameTitleLine(ctx) {
|
||||
const g = ctx.game;
|
||||
if (!g || typeof g !== 'object') return '—';
|
||||
const rec = /** @type {Record<string, unknown>} */ (g);
|
||||
const title =
|
||||
(typeof rec.title === 'string' && rec.title) ||
|
||||
(typeof rec.name === 'string' && rec.name) ||
|
||||
'';
|
||||
const pack =
|
||||
(typeof rec.pack_name === 'string' && rec.pack_name) ||
|
||||
(typeof rec.packName === 'string' && rec.packName) ||
|
||||
(typeof rec.pack === 'string' && rec.pack) ||
|
||||
'';
|
||||
if (!title) return '—';
|
||||
return pack ? `${title} (${pack})` : title;
|
||||
}
|
||||
|
||||
function formatStatusRow(name, info) {
|
||||
const s = info?.status;
|
||||
if (!s || typeof s !== 'object') return '—';
|
||||
if (name === 'roomCode') {
|
||||
const rc = /** @type {{ active?: boolean, roomCode?: string }} */ (s);
|
||||
return rc.active ? `Active (${rc.roomCode ?? ''})` : 'Inactive';
|
||||
}
|
||||
if (name === 'audio') {
|
||||
const a = /** @type {{ active?: boolean, playing?: boolean }} */ (s);
|
||||
if (!a.active) return 'Inactive';
|
||||
return a.playing ? 'Playing' : 'Active (muted)';
|
||||
}
|
||||
if (name === 'playerList') {
|
||||
const pl = /** @type {{ active?: boolean, playerCount?: number, maxPlayers?: number }} */ (
|
||||
s
|
||||
);
|
||||
if (!pl.active) return 'Inactive';
|
||||
const n = pl.playerCount ?? 0;
|
||||
const m = pl.maxPlayers ?? '?';
|
||||
return `Active (${n}/${m})`;
|
||||
}
|
||||
return /** @type {{ active?: boolean }} */ (s).active ? 'Active' : 'Inactive';
|
||||
}
|
||||
|
||||
function updateDashboard() {
|
||||
const state = manager.getState();
|
||||
const ctx = manager.getContext();
|
||||
|
||||
if (stateEl) {
|
||||
stateEl.textContent = state.toUpperCase();
|
||||
stateEl.style.backgroundColor = STATE_COLORS[state] ?? '#888';
|
||||
}
|
||||
if (roomCodeEl) {
|
||||
roomCodeEl.textContent = ctx.roomCode != null ? String(ctx.roomCode) : '—';
|
||||
}
|
||||
if (sessionIdEl) {
|
||||
sessionIdEl.textContent = ctx.sessionId != null ? String(ctx.sessionId) : '—';
|
||||
}
|
||||
if (gameTitleEl) {
|
||||
gameTitleEl.textContent = formatGameTitleLine(ctx);
|
||||
}
|
||||
if (playerCountEl) {
|
||||
const n = ctx.playerCount ?? 0;
|
||||
const m = ctx.maxPlayers != null ? String(ctx.maxPlayers) : '?';
|
||||
playerCountEl.textContent = `${n}/${m}`;
|
||||
}
|
||||
|
||||
const statuses = manager.getComponentStatuses();
|
||||
for (const name of componentNames) {
|
||||
const row = document.getElementById(`status-${name}`);
|
||||
if (!row) continue;
|
||||
const info = statuses[name];
|
||||
row.textContent = formatStatusRow(name, info);
|
||||
}
|
||||
|
||||
for (const name of componentNames) {
|
||||
const sel = document.getElementById(`override-${name}`);
|
||||
if (!sel) continue;
|
||||
const want = manager.getOverride(name);
|
||||
if (sel.value !== want) sel.value = want;
|
||||
}
|
||||
|
||||
if (eventLogEl) {
|
||||
const log = manager.getEventLog();
|
||||
const last = log.slice(-20);
|
||||
eventLogEl.replaceChildren();
|
||||
for (const e of last) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'event-log-entry';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'event-time';
|
||||
time.textContent = new Date(e.at).toLocaleTimeString();
|
||||
const typ = document.createElement('span');
|
||||
typ.className = 'event-type';
|
||||
typ.textContent = e.type;
|
||||
row.appendChild(time);
|
||||
row.appendChild(document.createTextNode(' '));
|
||||
row.appendChild(typ);
|
||||
eventLogEl.appendChild(row);
|
||||
}
|
||||
eventLogEl.scrollTop = eventLogEl.scrollHeight;
|
||||
}
|
||||
|
||||
syncToggleDisplayLabel();
|
||||
}
|
||||
|
||||
const toggleDisplayBtn = document.getElementById('toggle-display-btn');
|
||||
|
||||
function syncToggleDisplayLabel() {
|
||||
if (!toggleDisplayBtn) return;
|
||||
const s = manager.getState();
|
||||
toggleDisplayBtn.textContent = s === 'lobby' ? 'Hide Display' : 'Show Display';
|
||||
}
|
||||
|
||||
manager.onChange(() => {
|
||||
updateDashboard();
|
||||
});
|
||||
|
||||
updateDashboard();
|
||||
|
||||
const connectBtn = document.getElementById('ws-connect-btn');
|
||||
const disconnectBtn = document.getElementById('ws-disconnect-btn');
|
||||
const apiUrlInput = document.getElementById('api-url-input');
|
||||
const apiKeyInput = document.getElementById('api-key-input');
|
||||
|
||||
if (connectBtn && apiUrlInput && apiKeyInput) {
|
||||
connectBtn.addEventListener('click', () => {
|
||||
const url = apiUrlInput.value.trim();
|
||||
const key = apiKeyInput.value.trim();
|
||||
if (url) localStorage.setItem(STORAGE_API_URL, url);
|
||||
if (key) localStorage.setItem(STORAGE_API_KEY, key);
|
||||
void wsClient.connect(url, key);
|
||||
});
|
||||
}
|
||||
|
||||
if (disconnectBtn) {
|
||||
disconnectBtn.addEventListener('click', () => {
|
||||
wsClient.disconnect();
|
||||
manager.handleEvent('session.ended', { session: {} });
|
||||
});
|
||||
}
|
||||
|
||||
apiUrlInput?.addEventListener('change', () => {
|
||||
const url = apiUrlInput.value.trim();
|
||||
if (url) localStorage.setItem(STORAGE_API_URL, url);
|
||||
});
|
||||
apiKeyInput?.addEventListener('change', () => {
|
||||
localStorage.setItem(STORAGE_API_KEY, apiKeyInput.value.trim());
|
||||
});
|
||||
|
||||
const volumeSlider = document.getElementById('volume-slider');
|
||||
const volumeValue = document.getElementById('volume-value');
|
||||
const themeSound = /** @type {HTMLAudioElement | null} */ (
|
||||
document.getElementById('theme-sound')
|
||||
);
|
||||
|
||||
if (volumeSlider && volumeValue) {
|
||||
volumeSlider.addEventListener('input', () => {
|
||||
const v = Number(volumeSlider.value);
|
||||
volumeValue.textContent = `${Math.round(v * 100)}%`;
|
||||
if (themeSound) themeSound.volume = v;
|
||||
});
|
||||
}
|
||||
|
||||
const testSoundBtn = document.getElementById('test-sound-btn');
|
||||
const soundUrlInput = document.getElementById('sound-url-input');
|
||||
if (testSoundBtn && themeSound && soundUrlInput && volumeSlider) {
|
||||
testSoundBtn.addEventListener('click', () => {
|
||||
themeSound.src = soundUrlInput.value;
|
||||
themeSound.volume = Number(volumeSlider.value);
|
||||
themeSound.currentTime = 0;
|
||||
themeSound.loop = false;
|
||||
themeSound.play().catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log('[Test] Playback failed:', msg);
|
||||
window.alert(
|
||||
'Audio playback failed. Check the URL or browser autoplay permissions.',
|
||||
);
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
themeSound.pause();
|
||||
themeSound.loop = true;
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function synthesizeGameAddedFromInputs() {
|
||||
const code1 = document.getElementById('code1-input')?.value ?? '';
|
||||
const code2 = document.getElementById('code2-input')?.value ?? '';
|
||||
const roomCode = `${code1}${code2}`.trim();
|
||||
if (!roomCode) return;
|
||||
manager.handleEvent('game.added', {
|
||||
session: {},
|
||||
game: {
|
||||
room_code: roomCode,
|
||||
title: 'Manual',
|
||||
max_players: 8,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updateBtn = document.getElementById('update-btn');
|
||||
const previewBtn = document.getElementById('preview-btn');
|
||||
updateBtn?.addEventListener('click', () => {
|
||||
synthesizeGameAddedFromInputs();
|
||||
});
|
||||
previewBtn?.addEventListener('click', () => {
|
||||
synthesizeGameAddedFromInputs();
|
||||
});
|
||||
|
||||
if (toggleDisplayBtn) {
|
||||
toggleDisplayBtn.addEventListener('click', () => {
|
||||
const s = manager.getState();
|
||||
if (s === 'lobby') {
|
||||
manager.handleEvent('session.ended', { session: {} });
|
||||
} else if (s === 'idle') {
|
||||
const code1 = document.getElementById('code1-input')?.value ?? '';
|
||||
const code2 = document.getElementById('code2-input')?.value ?? '';
|
||||
const roomCode = `${code1}${code2}`.trim();
|
||||
if (roomCode) {
|
||||
manager.handleEvent('game.added', {
|
||||
session: {},
|
||||
game: {
|
||||
room_code: roomCode,
|
||||
title: 'Display',
|
||||
max_players: 8,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
syncToggleDisplayLabel();
|
||||
});
|
||||
}
|
||||
|
||||
const showControlsBtn = document.getElementById('show-controls-btn');
|
||||
const controls = document.getElementById('controls');
|
||||
if (showControlsBtn && controls) {
|
||||
showControlsBtn.addEventListener('click', () => {
|
||||
if (controls.style.display === 'block') {
|
||||
controls.style.display = 'none';
|
||||
showControlsBtn.textContent = 'Show Controls';
|
||||
} else {
|
||||
controls.style.display = 'block';
|
||||
showControlsBtn.textContent = 'Hide Controls';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.section-header').forEach((hdr) => {
|
||||
hdr.addEventListener('click', () => {
|
||||
const content = hdr.nextElementSibling;
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const indicator = hdr.querySelector('.toggle-indicator');
|
||||
if (content.style.display === 'block') {
|
||||
content.style.display = 'none';
|
||||
indicator?.classList.remove('active');
|
||||
hdr.classList.remove('active');
|
||||
} else {
|
||||
content.style.display = 'block';
|
||||
indicator?.classList.add('active');
|
||||
hdr.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const positionToggle = document.getElementById('position-toggle');
|
||||
if (positionToggle && controls) {
|
||||
positionToggle.addEventListener('change', () => {
|
||||
controls.classList.toggle('bottom-position', positionToggle.checked);
|
||||
});
|
||||
}
|
||||
|
||||
let inactivityTimer = 0;
|
||||
function resetInactivityTimer() {
|
||||
window.clearTimeout(inactivityTimer);
|
||||
inactivityTimer = window.setTimeout(() => {
|
||||
if (controls) controls.style.display = 'none';
|
||||
if (showControlsBtn) showControlsBtn.textContent = 'Show Controls';
|
||||
}, 20000);
|
||||
}
|
||||
document.addEventListener('mousemove', resetInactivityTimer);
|
||||
document.addEventListener('keypress', resetInactivityTimer);
|
||||
document.addEventListener('click', resetInactivityTimer);
|
||||
resetInactivityTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {(state: string, message?: string) => void}
|
||||
*/
|
||||
export function initConnectionStatusHandler() {
|
||||
const wsStatusDot = document.getElementById('ws-status-dot');
|
||||
const wsStatusText = document.getElementById('ws-status-text');
|
||||
const wsConnectBtn = document.getElementById('ws-connect-btn');
|
||||
const wsDisconnectRow = document.getElementById('ws-disconnect-row');
|
||||
const apiUrlInput = document.getElementById('api-url-input');
|
||||
const apiKeyInput = document.getElementById('api-key-input');
|
||||
|
||||
return (state, message) => {
|
||||
if (wsStatusDot) wsStatusDot.className = `status-dot ${state}`;
|
||||
if (wsStatusText) wsStatusText.textContent = message ?? String(state);
|
||||
|
||||
if (state === 'connected') {
|
||||
if (wsConnectBtn) wsConnectBtn.style.display = 'none';
|
||||
if (wsDisconnectRow) wsDisconnectRow.style.display = 'flex';
|
||||
if (apiUrlInput) apiUrlInput.disabled = true;
|
||||
if (apiKeyInput) apiKeyInput.disabled = true;
|
||||
} else {
|
||||
if (wsConnectBtn) wsConnectBtn.style.display = 'block';
|
||||
if (wsDisconnectRow) wsDisconnectRow.style.display = 'none';
|
||||
if (apiUrlInput) apiUrlInput.disabled = false;
|
||||
if (apiKeyInput) apiKeyInput.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
305
js/player-list.js
Normal file
305
js/player-list.js
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Numbered player slot roster beside the room code. Implements the overlay component contract:
|
||||
* init, activate, deactivate, update, getStatus.
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_PLAYERS = 8;
|
||||
|
||||
/**
|
||||
* @typedef {object} PlayerListInputs
|
||||
* @property {HTMLInputElement} enabled
|
||||
* @property {HTMLSelectElement | HTMLInputElement} position
|
||||
* @property {HTMLInputElement} fontSize
|
||||
* @property {HTMLInputElement} textColor
|
||||
* @property {HTMLInputElement} emptyColor
|
||||
* @property {HTMLInputElement} offset
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PlayerListTimingInputs
|
||||
* @property {HTMLInputElement} headerAppearDelay
|
||||
* @property {HTMLInputElement} headerAppearDuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} p
|
||||
* @returns {string}
|
||||
*/
|
||||
function displayName(p) {
|
||||
if (p == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof p === 'string') {
|
||||
return p.trim();
|
||||
}
|
||||
if (typeof p === 'object' && p !== null && 'name' in p) {
|
||||
return String(/** @type {{ name?: unknown }} */ (p).name ?? '').trim();
|
||||
}
|
||||
return String(p).trim();
|
||||
}
|
||||
|
||||
export class PlayerList {
|
||||
constructor() {
|
||||
this._active = false;
|
||||
/** @type {HTMLElement | null} */
|
||||
this._container = null;
|
||||
/** @type {PlayerListInputs | null} */
|
||||
this._inputs = null;
|
||||
/** @type {PlayerListTimingInputs | null} */
|
||||
this._timingInputs = null;
|
||||
/** @type {{ element: HTMLDivElement, nameEl: HTMLSpanElement, filled: boolean }[]} */
|
||||
this._slots = [];
|
||||
/** @type {unknown[]} */
|
||||
this._players = [];
|
||||
this._maxPlayers = DEFAULT_MAX_PLAYERS;
|
||||
/** @type {ReturnType<typeof setTimeout>[]} */
|
||||
this._animationTimers = [];
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
this._deactivateFadeTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {PlayerListInputs} inputs
|
||||
* @param {PlayerListTimingInputs} animationTimingInputs
|
||||
*/
|
||||
init(container, inputs, animationTimingInputs) {
|
||||
this._container = container;
|
||||
this._inputs = inputs;
|
||||
this._timingInputs = animationTimingInputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ maxPlayers?: number, players?: unknown[], [key: string]: unknown }} ctx
|
||||
*/
|
||||
activate(ctx) {
|
||||
this.deactivate();
|
||||
|
||||
this._active = true;
|
||||
|
||||
const inputs = this._inputs;
|
||||
if (!inputs || !inputs.enabled.checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMax = ctx.maxPlayers != null ? Number(ctx.maxPlayers) : DEFAULT_MAX_PLAYERS;
|
||||
this._maxPlayers =
|
||||
Number.isFinite(rawMax) && rawMax > 0 ? Math.floor(rawMax) : DEFAULT_MAX_PLAYERS;
|
||||
|
||||
this._players = Array.isArray(ctx.players) ? [...ctx.players] : [];
|
||||
|
||||
this._buildSlots();
|
||||
this._applySettings();
|
||||
this._fillSlots(this._players.map(displayName));
|
||||
this._animateIn();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._active = false;
|
||||
this._clearTimers();
|
||||
|
||||
if (this._deactivateFadeTimer != null) {
|
||||
clearTimeout(this._deactivateFadeTimer);
|
||||
this._deactivateFadeTimer = null;
|
||||
}
|
||||
|
||||
const container = this._container;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.transition = 'opacity 0.3s ease-out';
|
||||
container.style.opacity = '0';
|
||||
|
||||
this._deactivateFadeTimer = setTimeout(() => {
|
||||
this._deactivateFadeTimer = null;
|
||||
if (!this._active && this._container) {
|
||||
this._container.innerHTML = '';
|
||||
this._slots = [];
|
||||
this._players = [];
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ maxPlayers?: number, players?: unknown[], [key: string]: unknown }} ctx
|
||||
*/
|
||||
update(ctx) {
|
||||
const inputs = this._inputs;
|
||||
if (!this._active || !inputs?.enabled.checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.maxPlayers != null) {
|
||||
const rawMax = Number(ctx.maxPlayers);
|
||||
const nextMax =
|
||||
Number.isFinite(rawMax) && rawMax > 0 ? Math.floor(rawMax) : DEFAULT_MAX_PLAYERS;
|
||||
if (nextMax !== this._maxPlayers) {
|
||||
this._maxPlayers = nextMax;
|
||||
this._buildSlots();
|
||||
this._applySettings();
|
||||
const names = (ctx.players != null ? ctx.players : this._players).map(displayName);
|
||||
this._players = ctx.players != null ? [...ctx.players] : [...this._players];
|
||||
this._fillSlots(names);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.players != null && Array.isArray(ctx.players)) {
|
||||
const prevNames = this._players.map(displayName);
|
||||
const nextNames = ctx.players.map(displayName);
|
||||
const prevSet = new Set(prevNames.filter(Boolean));
|
||||
const newlyJoined = nextNames.filter((n) => n && !prevSet.has(n));
|
||||
|
||||
this._players = [...ctx.players];
|
||||
this._fillSlots(nextNames, newlyJoined);
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
active: this._active,
|
||||
enabled: this._inputs?.enabled.checked ?? false,
|
||||
playerCount: this._players.length,
|
||||
maxPlayers: this._maxPlayers,
|
||||
players: this._players.map((p) => displayName(p)),
|
||||
};
|
||||
}
|
||||
|
||||
_buildSlots() {
|
||||
const container = this._container;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
this._slots = [];
|
||||
|
||||
for (let i = 0; i < this._maxPlayers; i++) {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = 'player-slot';
|
||||
slot.dataset.index = String(i);
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.className = 'player-slot-number';
|
||||
number.textContent = `${i + 1}.`;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'player-slot-name empty';
|
||||
name.textContent = '────────';
|
||||
|
||||
slot.appendChild(number);
|
||||
slot.appendChild(name);
|
||||
container.appendChild(slot);
|
||||
this._slots.push({ element: slot, nameEl: name, filled: false });
|
||||
}
|
||||
}
|
||||
|
||||
_applySettings() {
|
||||
const container = this._container;
|
||||
const inputs = this._inputs;
|
||||
if (!container || !inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.classList.remove('player-list-position-left', 'player-list-position-right');
|
||||
const pos = String(inputs.position.value || 'left').toLowerCase();
|
||||
if (pos === 'right') {
|
||||
container.classList.add('player-list-position-right');
|
||||
} else {
|
||||
container.classList.add('player-list-position-left');
|
||||
}
|
||||
|
||||
const fontPx = `${parseInt(inputs.fontSize.value, 10) || 14}px`;
|
||||
const textColor = inputs.textColor.value;
|
||||
const emptyColor = inputs.emptyColor.value;
|
||||
const offsetY = `${parseInt(inputs.offset.value, 10) || 0}px`;
|
||||
|
||||
container.style.transform = `translateY(calc(-50% + ${offsetY}))`;
|
||||
|
||||
for (const slot of this._slots) {
|
||||
slot.nameEl.style.fontSize = fontPx;
|
||||
if (slot.nameEl.classList.contains('filled')) {
|
||||
slot.nameEl.style.color = textColor;
|
||||
} else {
|
||||
slot.nameEl.style.color = emptyColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} players
|
||||
* @param {string[]} [newlyJoined]
|
||||
*/
|
||||
_fillSlots(players, newlyJoined = []) {
|
||||
const newSet = new Set(newlyJoined.filter(Boolean));
|
||||
const inputs = this._inputs;
|
||||
|
||||
for (let i = 0; i < this._slots.length; i++) {
|
||||
const slot = this._slots[i];
|
||||
const name = players[i];
|
||||
const hasPlayer = name != null && String(name).length > 0;
|
||||
|
||||
if (hasPlayer) {
|
||||
const label = String(name);
|
||||
slot.nameEl.textContent = label;
|
||||
slot.nameEl.classList.remove('empty');
|
||||
slot.nameEl.classList.add('filled');
|
||||
slot.filled = true;
|
||||
if (inputs) {
|
||||
slot.nameEl.style.color = inputs.textColor.value;
|
||||
}
|
||||
|
||||
if (newSet.has(label)) {
|
||||
slot.nameEl.style.opacity = '0';
|
||||
requestAnimationFrame(() => {
|
||||
slot.nameEl.style.transition = 'opacity 0.4s ease-out';
|
||||
slot.nameEl.style.opacity = '1';
|
||||
});
|
||||
} else {
|
||||
slot.nameEl.style.transition = '';
|
||||
slot.nameEl.style.opacity = '1';
|
||||
}
|
||||
} else if (slot.filled) {
|
||||
slot.nameEl.textContent = '────────';
|
||||
slot.nameEl.classList.remove('filled');
|
||||
slot.nameEl.classList.add('empty');
|
||||
slot.filled = false;
|
||||
slot.nameEl.style.transition = '';
|
||||
slot.nameEl.style.opacity = '1';
|
||||
if (inputs) {
|
||||
slot.nameEl.style.color = inputs.emptyColor.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_animateIn() {
|
||||
const container = this._container;
|
||||
const timing = this._timingInputs;
|
||||
if (!container || !timing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearTimers();
|
||||
|
||||
container.style.opacity = '0';
|
||||
container.style.display = 'flex';
|
||||
|
||||
const sec = (input) => parseInt(input.value, 10) * 1000;
|
||||
const headerAppearDelayMs = sec(timing.headerAppearDelay);
|
||||
const headerAppearDurationMs = sec(timing.headerAppearDuration);
|
||||
|
||||
this._animationTimers.push(
|
||||
setTimeout(() => {
|
||||
container.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
|
||||
container.style.opacity = '1';
|
||||
}, headerAppearDelayMs),
|
||||
);
|
||||
}
|
||||
|
||||
_clearTimers() {
|
||||
this._animationTimers.forEach((id) => clearTimeout(id));
|
||||
this._animationTimers = [];
|
||||
}
|
||||
}
|
||||
282
js/room-code-display.js
Normal file
282
js/room-code-display.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Room code lobby display: staggered opacity animation for header, footer, and two code lines.
|
||||
* Implements the overlay component contract: activate, deactivate, update, getStatus.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} RoomCodeDisplayElements
|
||||
* @property {HTMLElement} header
|
||||
* @property {HTMLElement} footer
|
||||
* @property {HTMLElement} codePart1
|
||||
* @property {HTMLElement} codePart2
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} RoomCodeDisplayInputs
|
||||
* @property {HTMLInputElement} code1
|
||||
* @property {HTMLInputElement} code2
|
||||
* @property {HTMLInputElement} color1
|
||||
* @property {HTMLInputElement} color2
|
||||
* @property {HTMLInputElement} offset1
|
||||
* @property {HTMLInputElement} offset2
|
||||
* @property {HTMLInputElement} size
|
||||
* @property {HTMLInputElement} cycle
|
||||
* @property {HTMLInputElement} headerText
|
||||
* @property {HTMLInputElement} headerColor
|
||||
* @property {HTMLInputElement} headerSize
|
||||
* @property {HTMLInputElement} headerOffset
|
||||
* @property {HTMLInputElement} footerText
|
||||
* @property {HTMLInputElement} footerColor
|
||||
* @property {HTMLInputElement} footerSize
|
||||
* @property {HTMLInputElement} footerOffset
|
||||
* @property {HTMLInputElement} headerAppearDelay
|
||||
* @property {HTMLInputElement} headerAppearDuration
|
||||
* @property {HTMLInputElement} headerHideTime
|
||||
* @property {HTMLInputElement} headerHideDuration
|
||||
* @property {HTMLInputElement} line1AppearDelay
|
||||
* @property {HTMLInputElement} line1AppearDuration
|
||||
* @property {HTMLInputElement} line1HideTime
|
||||
* @property {HTMLInputElement} line1HideDuration
|
||||
* @property {HTMLInputElement} line2AppearDelay
|
||||
* @property {HTMLInputElement} line2AppearDuration
|
||||
* @property {HTMLInputElement} line2HideTime
|
||||
* @property {HTMLInputElement} line2HideDuration
|
||||
*/
|
||||
|
||||
export class RoomCodeDisplay {
|
||||
/** @type {RoomCodeDisplayElements | null} */
|
||||
#elements = null;
|
||||
|
||||
/** @type {RoomCodeDisplayInputs | null} */
|
||||
#inputs = null;
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>[]} */
|
||||
#animationTimers = [];
|
||||
|
||||
/** @type {boolean} */
|
||||
#active = false;
|
||||
|
||||
/**
|
||||
* @param {RoomCodeDisplayElements} elements
|
||||
* @param {RoomCodeDisplayInputs} inputs
|
||||
*/
|
||||
init(elements, inputs) {
|
||||
this.#elements = elements;
|
||||
this.#inputs = inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ roomCode?: string, [key: string]: unknown }} ctx
|
||||
*/
|
||||
activate(ctx) {
|
||||
this.deactivate();
|
||||
|
||||
const inputs = this.#inputs;
|
||||
if (!inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#active = true;
|
||||
|
||||
if (ctx?.roomCode != null && String(ctx.roomCode).trim().length > 0) {
|
||||
const raw = String(ctx.roomCode).trim().toUpperCase();
|
||||
const mid = Math.floor(raw.length / 2);
|
||||
inputs.code1.value = raw.slice(0, mid);
|
||||
inputs.code2.value = raw.slice(mid);
|
||||
} else {
|
||||
inputs.code1.value = '';
|
||||
inputs.code2.value = '';
|
||||
}
|
||||
|
||||
this.#applySettings();
|
||||
this.#startAnimation();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.#active = false;
|
||||
this.#clearAnimationTimers();
|
||||
|
||||
const { header, footer, codePart1, codePart2 } = this.#elements ?? {};
|
||||
if (!header || !footer || !codePart1 || !codePart2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fadeTime = '0.3s';
|
||||
header.style.transition = `opacity ${fadeTime} ease-out`;
|
||||
footer.style.transition = `opacity ${fadeTime} ease-out`;
|
||||
codePart1.style.transition = `opacity ${fadeTime} ease-out`;
|
||||
codePart2.style.transition = `opacity ${fadeTime} ease-out`;
|
||||
|
||||
header.style.opacity = 0;
|
||||
footer.style.opacity = 0;
|
||||
codePart1.style.opacity = 0;
|
||||
codePart2.style.opacity = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ roomCode?: string, [key: string]: unknown }} ctx
|
||||
*/
|
||||
update(ctx) {
|
||||
if (!this.#active || !this.#inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = ctx?.roomCode != null ? String(ctx.roomCode).trim().toUpperCase() : '';
|
||||
const current =
|
||||
this.#inputs.code1.value.toUpperCase() + this.#inputs.code2.value.toUpperCase();
|
||||
|
||||
if (next && next !== current) {
|
||||
this.activate(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
const inputs = this.#inputs;
|
||||
const roomCode = inputs
|
||||
? (inputs.code1.value + inputs.code2.value).toUpperCase()
|
||||
: '';
|
||||
|
||||
return {
|
||||
active: this.#active,
|
||||
roomCode,
|
||||
timersRunning: this.#animationTimers.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
#clearAnimationTimers() {
|
||||
this.#animationTimers.forEach((id) => clearTimeout(id));
|
||||
this.#animationTimers = [];
|
||||
}
|
||||
|
||||
#applySettings() {
|
||||
const el = this.#elements;
|
||||
const inputs = this.#inputs;
|
||||
if (!el || !inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
header,
|
||||
footer,
|
||||
codePart1,
|
||||
codePart2,
|
||||
} = el;
|
||||
|
||||
codePart1.textContent = inputs.code1.value.toUpperCase();
|
||||
codePart2.textContent = inputs.code2.value.toUpperCase();
|
||||
|
||||
codePart1.style.color = inputs.color1.value;
|
||||
codePart2.style.color = inputs.color2.value;
|
||||
|
||||
codePart1.style.transform = `translateY(${inputs.offset1.value}px)`;
|
||||
codePart2.style.transform = `translateY(${inputs.offset2.value}px)`;
|
||||
|
||||
codePart1.style.fontSize = `${inputs.size.value}px`;
|
||||
codePart2.style.fontSize = `${inputs.size.value}px`;
|
||||
|
||||
header.textContent = inputs.headerText.value;
|
||||
header.style.color = inputs.headerColor.value;
|
||||
header.style.fontSize = `${inputs.headerSize.value}px`;
|
||||
header.style.transform = `translateY(${inputs.headerOffset.value}px)`;
|
||||
|
||||
footer.textContent = inputs.footerText.value;
|
||||
footer.style.color = inputs.footerColor.value;
|
||||
footer.style.fontSize = `${inputs.footerSize.value}px`;
|
||||
footer.style.transform = `translateY(${inputs.footerOffset.value}px)`;
|
||||
}
|
||||
|
||||
#startAnimation() {
|
||||
const el = this.#elements;
|
||||
const inputs = this.#inputs;
|
||||
if (!el || !inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { header, footer, codePart1, codePart2 } = el;
|
||||
|
||||
this.#clearAnimationTimers();
|
||||
|
||||
header.style.opacity = 0;
|
||||
footer.style.opacity = 0;
|
||||
codePart1.style.opacity = 0;
|
||||
codePart2.style.opacity = 0;
|
||||
|
||||
const sec = (input) => parseInt(input.value, 10) * 1000;
|
||||
|
||||
const cycleDuration = sec(inputs.cycle);
|
||||
|
||||
const headerAppearDelayMs = sec(inputs.headerAppearDelay);
|
||||
const headerAppearDurationMs = sec(inputs.headerAppearDuration);
|
||||
|
||||
this.#animationTimers.push(
|
||||
setTimeout(() => {
|
||||
header.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
|
||||
header.style.opacity = 1;
|
||||
|
||||
footer.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
|
||||
footer.style.opacity = 1;
|
||||
}, headerAppearDelayMs),
|
||||
);
|
||||
|
||||
const line1AppearDelayMs = sec(inputs.line1AppearDelay);
|
||||
const line1AppearDurationMs = sec(inputs.line1AppearDuration);
|
||||
|
||||
this.#animationTimers.push(
|
||||
setTimeout(() => {
|
||||
codePart1.style.transition = `opacity ${line1AppearDurationMs / 1000}s ease-out`;
|
||||
codePart1.style.opacity = 1;
|
||||
}, line1AppearDelayMs),
|
||||
);
|
||||
|
||||
const line1HideTimeMs = sec(inputs.line1HideTime);
|
||||
const line1HideDurationMs = sec(inputs.line1HideDuration);
|
||||
|
||||
this.#animationTimers.push(
|
||||
setTimeout(() => {
|
||||
codePart1.style.transition = `opacity ${line1HideDurationMs / 1000}s ease-out`;
|
||||
codePart1.style.opacity = 0;
|
||||
}, line1HideTimeMs),
|
||||
);
|
||||
|
||||
const line2AppearDelayMs = sec(inputs.line2AppearDelay);
|
||||
const line2AppearDurationMs = sec(inputs.line2AppearDuration);
|
||||
|
||||
this.#animationTimers.push(
|
||||
setTimeout(() => {
|
||||
codePart2.style.transition = `opacity ${line2AppearDurationMs / 1000}s ease-out`;
|
||||
codePart2.style.opacity = 1;
|
||||
}, line2AppearDelayMs),
|
||||
);
|
||||
|
||||
const line2HideTimeMs = sec(inputs.line2HideTime);
|
||||
const line2HideDurationMs = sec(inputs.line2HideDuration);
|
||||
|
||||
this.#animationTimers.push(
|
||||
setTimeout(() => {
|
||||
codePart2.style.transition = `opacity ${line2HideDurationMs / 1000}s ease-out`;
|
||||
codePart2.style.opacity = 0;
|
||||
}, line2HideTimeMs),
|
||||
);
|
||||
|
||||
const headerHideTimeMs = sec(inputs.headerHideTime);
|
||||
const headerHideDurationMs = sec(inputs.headerHideDuration);
|
||||
|
||||
this.#animationTimers.push(
|
||||
setTimeout(() => {
|
||||
header.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
|
||||
header.style.opacity = 0;
|
||||
|
||||
footer.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
|
||||
footer.style.opacity = 0;
|
||||
}, headerHideTimeMs),
|
||||
);
|
||||
|
||||
this.#animationTimers.push(
|
||||
setTimeout(() => {
|
||||
if (this.#active) {
|
||||
this.#startAnimation();
|
||||
}
|
||||
}, cycleDuration),
|
||||
);
|
||||
}
|
||||
}
|
||||
394
js/state-manager.js
Normal file
394
js/state-manager.js
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Central overlay state machine: coordinates room/game lifecycle and registered UI components.
|
||||
*/
|
||||
|
||||
export const OVERRIDE_MODES = Object.freeze({
|
||||
AUTO: 'auto',
|
||||
FORCE_SHOW: 'force_show',
|
||||
FORCE_HIDE: 'force_hide',
|
||||
});
|
||||
|
||||
const OVERRIDE_VALUES = new Set(Object.values(OVERRIDE_MODES));
|
||||
|
||||
/** @typedef {'idle'|'lobby'|'playing'|'ended'|'disconnected'} OverlayState */
|
||||
|
||||
const VALID_TRANSITIONS = Object.freeze({
|
||||
idle: new Set(['lobby', 'playing', 'ended', 'disconnected']),
|
||||
lobby: new Set(['lobby', 'playing', 'ended', 'idle', 'disconnected']),
|
||||
playing: new Set(['ended', 'lobby', 'idle', 'disconnected']),
|
||||
ended: new Set(['idle', 'lobby', 'disconnected']),
|
||||
disconnected: new Set(['idle', 'lobby']),
|
||||
});
|
||||
|
||||
const EVENT_LOG_MAX = 50;
|
||||
|
||||
function shallowClone(obj) {
|
||||
return { ...obj };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} OverlayComponent
|
||||
* @property {(context: object) => void} activate
|
||||
* @property {() => void} deactivate
|
||||
* @property {(context: object) => void} update
|
||||
* @property {() => object} getStatus
|
||||
*/
|
||||
|
||||
export class OverlayManager {
|
||||
/** @type {OverlayState} */
|
||||
#state = 'idle';
|
||||
|
||||
/** @type {Map<string, OverlayComponent>} */
|
||||
#components = new Map();
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
#overrides = new Map();
|
||||
|
||||
/** @type {{ roomCode?: string, game?: object, maxPlayers?: number, players?: unknown[], lobbyState?: unknown, playerCount?: number, sessionId?: string, lastJoinedPlayer?: unknown, [key: string]: unknown }} */
|
||||
#context = {};
|
||||
|
||||
/** @type {Array<{ type: string, data: unknown, at: number }>} */
|
||||
#eventLog = [];
|
||||
|
||||
/** @type {Set<(info: { state: OverlayState, context: object }) => void>} */
|
||||
#listeners = new Set();
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
#endedToIdleTimer = null;
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {OverlayComponent} component
|
||||
*/
|
||||
registerComponent(name, component) {
|
||||
this.#components.set(name, component);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(info: { state: OverlayState, context: object }) => void} listener
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onChange(listener) {
|
||||
this.#listeners.add(listener);
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @param {unknown} [data]
|
||||
*/
|
||||
logEvent(type, data) {
|
||||
this.#eventLog.push({ type, data, at: Date.now() });
|
||||
if (this.#eventLog.length > EVENT_LOG_MAX) {
|
||||
this.#eventLog.splice(0, this.#eventLog.length - EVENT_LOG_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
getEventLog() {
|
||||
return this.#eventLog.map((e) => ({ ...e, data: e.data }));
|
||||
}
|
||||
|
||||
/** @returns {OverlayState} */
|
||||
getState() {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
getContext() {
|
||||
return shallowClone(this.#context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} mode
|
||||
*/
|
||||
setOverride(name, mode) {
|
||||
if (!OVERRIDE_VALUES.has(mode)) {
|
||||
throw new Error(`Invalid override mode: ${mode}`);
|
||||
}
|
||||
this.#overrides.set(name, mode);
|
||||
this.#applyOverride(name);
|
||||
this.#notify();
|
||||
}
|
||||
|
||||
#applyOverride(name) {
|
||||
const component = this.#components.get(name);
|
||||
if (!component) return;
|
||||
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
|
||||
const ctx = this.getContext();
|
||||
|
||||
if (mode === OVERRIDE_MODES.FORCE_SHOW) {
|
||||
if (typeof component.activate === 'function') {
|
||||
try { component.activate(ctx); } catch (_) { /* ignore */ }
|
||||
}
|
||||
} else if (mode === OVERRIDE_MODES.FORCE_HIDE) {
|
||||
if (typeof component.deactivate === 'function') {
|
||||
try { component.deactivate(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
} else if (mode === OVERRIDE_MODES.AUTO) {
|
||||
if (this.#state === 'lobby') {
|
||||
if (typeof component.activate === 'function') {
|
||||
try { component.activate(ctx); } catch (_) { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
if (typeof component.deactivate === 'function') {
|
||||
try { component.deactivate(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
getOverride(name) {
|
||||
return this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
|
||||
}
|
||||
|
||||
getComponentStatuses() {
|
||||
const out = {};
|
||||
for (const [name, component] of this.#components) {
|
||||
out[name] = {
|
||||
status: typeof component.getStatus === 'function' ? component.getStatus() : null,
|
||||
override: this.getOverride(name),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventType
|
||||
* @param {unknown} [data]
|
||||
*/
|
||||
handleEvent(eventType, data) {
|
||||
this.logEvent(eventType, data);
|
||||
const d = data && typeof data === 'object' ? /** @type {Record<string, unknown>} */ (data) : {};
|
||||
|
||||
switch (eventType) {
|
||||
case 'game.added':
|
||||
this.#applyGameAdded(d);
|
||||
this.#transitionTo('lobby');
|
||||
break;
|
||||
|
||||
case 'room.connected':
|
||||
this.#applyRoomConnected(d);
|
||||
if (this.#state === 'idle' || this.#state === 'ended') {
|
||||
this.#transitionTo('lobby');
|
||||
} else {
|
||||
this.#broadcastUpdate();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lobby.player-joined':
|
||||
this.#applyLobbyPlayerJoined(d);
|
||||
this.#broadcastUpdate();
|
||||
break;
|
||||
|
||||
case 'lobby.updated':
|
||||
this.#applyLobbyUpdated(d);
|
||||
this.#broadcastUpdate();
|
||||
break;
|
||||
|
||||
case 'game.started':
|
||||
this.#transitionTo('playing');
|
||||
break;
|
||||
|
||||
case 'game.ended':
|
||||
this.#transitionTo('ended');
|
||||
this.#scheduleEndedToIdle();
|
||||
break;
|
||||
|
||||
case 'room.disconnected':
|
||||
this.#context.roomCode = null;
|
||||
this.#context.players = [];
|
||||
this.#context.playerCount = 0;
|
||||
this.#context.lobbyState = null;
|
||||
this.#transitionTo('idle');
|
||||
break;
|
||||
|
||||
case 'session.started':
|
||||
if (d.sessionId != null) this.#context.sessionId = /** @type {string} */ (d.sessionId);
|
||||
this.#notify();
|
||||
break;
|
||||
|
||||
case 'session.ended':
|
||||
this.#clearEndedToIdleTimer();
|
||||
this.#clearContext();
|
||||
this.#transitionTo('idle');
|
||||
break;
|
||||
|
||||
case 'player-count.updated':
|
||||
this.#applyPlayerCountUpdated(d);
|
||||
this.#broadcastUpdate();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- internal ---
|
||||
|
||||
#notify() {
|
||||
const snapshot = { state: this.#state, context: this.getContext() };
|
||||
for (const fn of this.#listeners) {
|
||||
try {
|
||||
fn(snapshot);
|
||||
} catch (_) {
|
||||
/* ignore listener errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#broadcastUpdate() {
|
||||
if (this.#state !== 'lobby') {
|
||||
this.#notify();
|
||||
return;
|
||||
}
|
||||
const ctx = this.getContext();
|
||||
for (const [name, component] of this.#components) {
|
||||
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
|
||||
if (mode === OVERRIDE_MODES.FORCE_HIDE) continue;
|
||||
if (typeof component.update === 'function') {
|
||||
try { component.update(ctx); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
this.#notify();
|
||||
}
|
||||
|
||||
#clearContext() {
|
||||
this.#context = {};
|
||||
}
|
||||
|
||||
#clearEndedToIdleTimer() {
|
||||
if (this.#endedToIdleTimer != null) {
|
||||
clearTimeout(this.#endedToIdleTimer);
|
||||
this.#endedToIdleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleEndedToIdle() {
|
||||
this.#clearEndedToIdleTimer();
|
||||
this.#endedToIdleTimer = setTimeout(() => {
|
||||
this.#endedToIdleTimer = null;
|
||||
if (this.#state === 'ended') {
|
||||
this.#transitionTo('idle');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} d
|
||||
*/
|
||||
#applyGameAdded(d) {
|
||||
const game = /** @type {Record<string, unknown> | undefined} */ (d.game);
|
||||
if (!game || typeof game !== 'object') return;
|
||||
const code =
|
||||
game.room_code ?? game.roomCode ?? game.code;
|
||||
if (code != null) this.#context.roomCode = String(code);
|
||||
this.#context.game = { ...game };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} d
|
||||
*/
|
||||
#applyRoomConnected(d) {
|
||||
if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers);
|
||||
if (Array.isArray(d.players)) this.#context.players = [...d.players];
|
||||
if (d.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState;
|
||||
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} d
|
||||
*/
|
||||
#applyLobbyPlayerJoined(d) {
|
||||
if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers);
|
||||
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
|
||||
if (Array.isArray(d.players)) this.#context.players = [...d.players];
|
||||
if (d.player !== undefined) this.#context.lastJoinedPlayer = d.player;
|
||||
if (d.lastJoinedPlayer !== undefined) this.#context.lastJoinedPlayer = d.lastJoinedPlayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} d
|
||||
*/
|
||||
#applyLobbyUpdated(d) {
|
||||
if (d.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState;
|
||||
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} d
|
||||
*/
|
||||
#applyPlayerCountUpdated(d) {
|
||||
const n = d.playerCount ?? d.count;
|
||||
if (n != null) this.#context.playerCount = Number(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OverlayState} next
|
||||
*/
|
||||
#transitionTo(next) {
|
||||
const current = this.#state;
|
||||
if (!VALID_TRANSITIONS.get(current)?.has(next)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current === 'lobby' && next === 'lobby') {
|
||||
this.#fullLobbyReset();
|
||||
this.#notify();
|
||||
return;
|
||||
}
|
||||
|
||||
const leavingLobby = current === 'lobby' && next !== 'lobby';
|
||||
const enteringLobby = next === 'lobby' && current !== 'lobby';
|
||||
|
||||
if (leavingLobby) {
|
||||
for (const [name, component] of this.#components) {
|
||||
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
|
||||
if (mode !== OVERRIDE_MODES.AUTO) continue;
|
||||
if (typeof component.deactivate === 'function') {
|
||||
try { component.deactivate(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#state = next;
|
||||
|
||||
if (enteringLobby) {
|
||||
const ctx = this.getContext();
|
||||
for (const [name, component] of this.#components) {
|
||||
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
|
||||
if (mode !== OVERRIDE_MODES.AUTO) continue;
|
||||
if (typeof component.activate === 'function') {
|
||||
try { component.activate(ctx); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (next !== 'ended') {
|
||||
this.#clearEndedToIdleTimer();
|
||||
}
|
||||
|
||||
this.#notify();
|
||||
}
|
||||
|
||||
#fullLobbyReset() {
|
||||
for (const [name, component] of this.#components) {
|
||||
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
|
||||
if (mode !== OVERRIDE_MODES.AUTO) continue;
|
||||
if (typeof component.deactivate === 'function') {
|
||||
try { component.deactivate(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
const ctx = this.getContext();
|
||||
for (const [name, component] of this.#components) {
|
||||
const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO;
|
||||
if (mode !== OVERRIDE_MODES.AUTO) continue;
|
||||
if (typeof component.activate === 'function') {
|
||||
try { component.activate(ctx); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
374
js/websocket-client.js
Normal file
374
js/websocket-client.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* WebSocket client for Jackbox Game Picker API: JWT auth, live session stream, reconnect, heartbeat.
|
||||
*/
|
||||
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||
|
||||
/**
|
||||
* @typedef {'connecting'|'connected'|'disconnected'|'error'} WsConnectionState
|
||||
*/
|
||||
|
||||
export class WebSocketClient {
|
||||
constructor(options = {}) {
|
||||
const {
|
||||
onStatusChange = () => {},
|
||||
onEvent = () => {},
|
||||
onSessionSubscribed = () => {},
|
||||
} = options;
|
||||
|
||||
/** @type {(state: WsConnectionState, message?: string) => void} */
|
||||
this._onStatusChange = onStatusChange;
|
||||
/** @type {(eventType: string, data: unknown) => void} */
|
||||
this._onEvent = onEvent;
|
||||
/** @type {(sessionId: string | number) => void} */
|
||||
this._onSessionSubscribed = onSessionSubscribed;
|
||||
|
||||
/** @type {WebSocket | null} */
|
||||
this._ws = null;
|
||||
/** @type {string | null} */
|
||||
this._jwtToken = null;
|
||||
/** @type {string | null} */
|
||||
this._apiUrl = null;
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
this._heartbeatInterval = null;
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
this._reconnectTimeout = null;
|
||||
this._reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this._intentionalDisconnect = false;
|
||||
/** @type {boolean} */
|
||||
this._authenticated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} apiUrl
|
||||
* @param {string} apiKey
|
||||
*/
|
||||
async connect(apiUrl, apiKey) {
|
||||
const base = this._normalizeApiUrl(apiUrl);
|
||||
const key = String(apiKey).trim();
|
||||
|
||||
if (!base) {
|
||||
this._onStatusChange('error', 'API URL is required');
|
||||
return;
|
||||
}
|
||||
if (!key) {
|
||||
this._onStatusChange('error', 'API Key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
this._intentionalDisconnect = false;
|
||||
this._apiUrl = base;
|
||||
this._onStatusChange('connecting', 'Authenticating...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
const msg =
|
||||
typeof errData.error === 'string'
|
||||
? errData.error
|
||||
: response.statusText || String(response.status);
|
||||
this._onStatusChange('error', `Auth failed: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const token = data.token;
|
||||
|
||||
if (!token) {
|
||||
this._onStatusChange('error', 'No token in auth response');
|
||||
return;
|
||||
}
|
||||
|
||||
this._jwtToken = token;
|
||||
this._onStatusChange('connecting', 'Connecting WebSocket...');
|
||||
this._connectWebSocket(base);
|
||||
} catch (err) {
|
||||
console.error('[WebSocketClient] Auth error:', err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this._onStatusChange('error', `Auth error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._intentionalDisconnect = true;
|
||||
if (this._reconnectTimeout) {
|
||||
clearTimeout(this._reconnectTimeout);
|
||||
this._reconnectTimeout = null;
|
||||
}
|
||||
this._stopHeartbeat();
|
||||
this._authenticated = false;
|
||||
this._jwtToken = null;
|
||||
if (this._ws) {
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
this._onStatusChange('disconnected', 'Disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number} sessionId
|
||||
*/
|
||||
subscribeToSession(sessionId) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[WebSocketClient] subscribeToSession: socket not open');
|
||||
return;
|
||||
}
|
||||
this._ws.send(
|
||||
JSON.stringify({ type: 'subscribe', sessionId }),
|
||||
);
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return (
|
||||
this._authenticated &&
|
||||
this._ws !== null &&
|
||||
this._ws.readyState === WebSocket.OPEN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} apiUrl
|
||||
*/
|
||||
_normalizeApiUrl(apiUrl) {
|
||||
return String(apiUrl).trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} apiUrl
|
||||
*/
|
||||
_connectWebSocket(apiUrl) {
|
||||
const wsUrl = `${apiUrl.replace(/^http/, 'ws')}/api/sessions/live`;
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(wsUrl);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this._onStatusChange('error', `WebSocket error: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.addEventListener('open', () => {
|
||||
this._authenticated = false;
|
||||
this._onStatusChange('connecting', 'Authenticating via WebSocket...');
|
||||
this._reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
|
||||
if (!this._jwtToken) {
|
||||
this._onStatusChange('error', 'Missing JWT for WebSocket auth');
|
||||
return;
|
||||
}
|
||||
this._ws.send(JSON.stringify({ type: 'auth', token: this._jwtToken }));
|
||||
});
|
||||
|
||||
this._ws.addEventListener('message', (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (e) {
|
||||
console.error('[WebSocketClient] Failed to parse message:', event.data);
|
||||
return;
|
||||
}
|
||||
this._handleMessage(message);
|
||||
});
|
||||
|
||||
this._ws.addEventListener('close', (event) => {
|
||||
console.log('[WebSocketClient] Disconnected, code:', event.code);
|
||||
this._stopHeartbeat();
|
||||
this._authenticated = false;
|
||||
this._ws = null;
|
||||
|
||||
if (!this._intentionalDisconnect) {
|
||||
const secs = Math.round(this._reconnectDelay / 1000);
|
||||
this._onStatusChange(
|
||||
'connecting',
|
||||
`Reconnecting in ${secs}s...`,
|
||||
);
|
||||
this._scheduleReconnect();
|
||||
} else {
|
||||
this._onStatusChange('disconnected', 'Disconnected');
|
||||
}
|
||||
});
|
||||
|
||||
this._ws.addEventListener('error', (err) => {
|
||||
console.error('[WebSocketClient] Socket error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} message
|
||||
*/
|
||||
_handleMessage(message) {
|
||||
const type = message.type;
|
||||
|
||||
switch (type) {
|
||||
case 'auth_success': {
|
||||
this._startHeartbeat();
|
||||
void this._fetchActiveSessionAndSubscribe();
|
||||
this._authenticated = true;
|
||||
this._onStatusChange('connected', 'Connected');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'auth_error': {
|
||||
const msg =
|
||||
typeof message.message === 'string'
|
||||
? message.message
|
||||
: 'WebSocket authentication failed';
|
||||
console.error('[WebSocketClient] Auth error:', msg);
|
||||
this._onStatusChange('error', `WS auth failed: ${msg}`);
|
||||
this._intentionalDisconnect = true;
|
||||
if (this._ws) {
|
||||
this._ws.close();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'subscribed': {
|
||||
const sessionId = message.sessionId;
|
||||
const label =
|
||||
sessionId !== undefined && sessionId !== null
|
||||
? `Connected (session ${sessionId})`
|
||||
: 'Connected (subscribed)';
|
||||
this._onStatusChange('connected', label);
|
||||
if (sessionId !== undefined && sessionId !== null) {
|
||||
this._onSessionSubscribed(sessionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'error': {
|
||||
const serverMsg =
|
||||
typeof message.message === 'string'
|
||||
? message.message
|
||||
: JSON.stringify(message);
|
||||
console.error('[WebSocketClient] Server error:', serverMsg);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session.started': {
|
||||
const data = message.data;
|
||||
/** @type {{ session?: { id?: string | number } } | undefined} */
|
||||
let payload;
|
||||
if (data && typeof data === 'object' && data !== null) {
|
||||
payload = /** @type {{ session?: { id?: string | number } }} */ (
|
||||
data
|
||||
);
|
||||
}
|
||||
const id = payload?.session?.id;
|
||||
|
||||
if (
|
||||
id !== undefined &&
|
||||
id !== null &&
|
||||
this._ws &&
|
||||
this._ws.readyState === WebSocket.OPEN
|
||||
) {
|
||||
this._ws.send(
|
||||
JSON.stringify({ type: 'subscribe', sessionId: id }),
|
||||
);
|
||||
}
|
||||
if (data !== undefined) {
|
||||
this._onEvent('session.started', data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(message, 'data') &&
|
||||
message.data !== undefined
|
||||
) {
|
||||
if (typeof type === 'string') {
|
||||
this._onEvent(type, message.data);
|
||||
}
|
||||
} else {
|
||||
console.log('[WebSocketClient] Unhandled message type:', type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchActiveSessionAndSubscribe() {
|
||||
const apiUrl = this._apiUrl;
|
||||
const token = this._jwtToken;
|
||||
if (!apiUrl || !token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/sessions/active`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const session = await response.json();
|
||||
if (session && session.id !== undefined && session.id !== null) {
|
||||
console.log(
|
||||
'[WebSocketClient] Found active session:',
|
||||
session.id,
|
||||
'— subscribing',
|
||||
);
|
||||
this.subscribeToSession(session.id);
|
||||
} else {
|
||||
console.log(
|
||||
'[WebSocketClient] No active session found; waiting for session.started',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'[WebSocketClient] Could not fetch active session:',
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocketClient] Error fetching active session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat();
|
||||
this._heartbeatInterval = setInterval(() => {
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this._heartbeatInterval) {
|
||||
clearInterval(this._heartbeatInterval);
|
||||
this._heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this._reconnectTimeout) {
|
||||
clearTimeout(this._reconnectTimeout);
|
||||
}
|
||||
|
||||
const delay = this._reconnectDelay;
|
||||
this._reconnectTimeout = setTimeout(() => {
|
||||
this._reconnectTimeout = null;
|
||||
const apiUrl = this._apiUrl;
|
||||
const token = this._jwtToken;
|
||||
|
||||
if (apiUrl && token && !this._intentionalDisconnect) {
|
||||
console.log('[WebSocketClient] Attempting reconnect...');
|
||||
this._onStatusChange('connecting', 'Reconnecting...');
|
||||
this._connectWebSocket(apiUrl);
|
||||
}
|
||||
|
||||
this._reconnectDelay = Math.min(
|
||||
this._reconnectDelay * 2,
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user