Compare commits
20 Commits
f3cbf10937
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d66c2dba
|
||
|
|
fa7363bc78
|
||
|
|
a8b7df48a6
|
||
|
|
06e375ccdc
|
||
|
|
4c56b7f8f9
|
||
|
|
7a2f8419d5
|
||
|
|
d775f74035
|
||
|
|
228981bc2b
|
||
|
|
c4929e54c3
|
||
|
|
88a4ac9889
|
||
|
|
875153ef63
|
||
|
|
19c94d294f
|
||
|
|
f754b227b3
|
||
|
|
cddfe9125d
|
||
|
|
f0db0e8642
|
||
|
|
6b78928269
|
||
|
|
1ed647208e
|
||
|
|
284830a24b
|
||
|
|
41773d0fef
|
||
|
|
c049cddb6d
|
83
docs/plans/2026-03-20-header-meter-design.md
Normal file
83
docs/plans/2026-03-20-header-meter-design.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Header Text Player Meter — Design
|
||||
|
||||
## Summary
|
||||
|
||||
Add a gradient fill meter to the "ROOM CODE:" header text that visually represents lobby fill (playerCount / maxPlayers). The fill sweeps left-to-right from the configured header color (pink) to white. A pulse/glow fires when the lobby is full.
|
||||
|
||||
Also: revert the player list checkbox to unchecked by default.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Smooth CSS gradient across the header text, not per-character or per-word.
|
||||
- Fill percentage = `playerCount / maxPlayers`, clamped to `[0, 1]`.
|
||||
- 0 players → 100% pink. All players → 100% white.
|
||||
- Gradient edge animates smoothly (~400ms ease) when player count changes.
|
||||
- Brief pulse/glow animation when fill reaches 100%.
|
||||
- Existing header settings (text, color, size, offset) continue to work. The configured header color becomes the "unfilled" color; white is always the "filled" color.
|
||||
- Player list disabled by default.
|
||||
|
||||
## Approach
|
||||
|
||||
CSS `background-clip: text` with a dynamic `linear-gradient`.
|
||||
|
||||
### CSS Changes (`optimized-controls.html`)
|
||||
|
||||
Replace the static `#header` color with gradient-compatible styles:
|
||||
|
||||
```css
|
||||
#header {
|
||||
/* existing position, font, letter-spacing, opacity, transition stay */
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
/* drop-shadow replaces text-shadow (incompatible with transparent text) */
|
||||
filter: drop-shadow(3px 3px 8px rgba(0, 0, 0, 0.8));
|
||||
text-shadow: none;
|
||||
}
|
||||
```
|
||||
|
||||
New keyframes for the full-lobby pulse:
|
||||
|
||||
```css
|
||||
@keyframes meter-full-pulse {
|
||||
0% { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); transform: scale(1) translateY(var(--header-offset)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(255,255,255,0.6)) drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); transform: scale(1.05) translateY(var(--header-offset)); }
|
||||
100% { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); transform: scale(1) translateY(var(--header-offset)); }
|
||||
}
|
||||
|
||||
#header.meter-full-pulse {
|
||||
animation: meter-full-pulse 0.6s ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
### JS Changes (`js/room-code-display.js`)
|
||||
|
||||
1. **Track meter state** — new private fields: `#meterFill` (current 0–1), `#meterTarget` (target 0–1), `#meterRafId`.
|
||||
2. **`#applySettings()`** — replace `header.style.color = headerColor` with `header.style.background = linear-gradient(...)` using current `#meterFill` and the configured header color.
|
||||
3. **`update(ctx)`** — compute new target from `ctx.playerCount / ctx.maxPlayers`. If different from current target, call `#animateMeterTo(newTarget)`.
|
||||
4. **`#animateMeterTo(target)`** — `requestAnimationFrame` loop that interpolates `#meterFill` toward target over ~400ms with ease-out. Each frame calls `#applyMeterGradient()`.
|
||||
5. **`#applyMeterGradient()`** — sets `header.style.background` to `linear-gradient(to right, #fff 0%, #fff ${pct}%, ${headerColor} ${pct}%, ${headerColor} 100%)` plus re-applies `background-clip` properties.
|
||||
6. **`#triggerFullPulse()`** — adds `meter-full-pulse` class, removes after `animationend` event.
|
||||
7. **`deactivate()`** — cancels RAF, resets `#meterFill` to 0.
|
||||
|
||||
### HTML Change (`optimized-controls.html`)
|
||||
|
||||
Remove `checked` from `<input type="checkbox" id="player-list-enabled" checked>`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
WebSocket event (lobby.player-joined / player-count-updated)
|
||||
→ OverlayManager updates context.playerCount
|
||||
→ OverlayManager calls RoomCodeDisplay.update(ctx)
|
||||
→ RoomCodeDisplay computes target = playerCount / maxPlayers
|
||||
→ #animateMeterTo(target) runs RAF interpolation
|
||||
→ When target reaches 1.0, #triggerFullPulse() fires
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- `maxPlayers` is 0 or missing → fill stays at 0%.
|
||||
- `playerCount > maxPlayers` → clamp to 100%.
|
||||
- Rapid successive joins → each new target interrupts the current animation, smoothly redirecting.
|
||||
- Lobby reset (new game) → `deactivate()` resets fill to 0; next `activate()` starts fresh.
|
||||
388
docs/plans/2026-03-20-header-meter-implementation.md
Normal file
388
docs/plans/2026-03-20-header-meter-implementation.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Header Text Player Meter — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a gradient fill meter to the "ROOM CODE:" header that visually indicates lobby fill (playerCount / maxPlayers), animating left-to-right from the header color (pink) to white, with a pulse effect at 100%.
|
||||
|
||||
**Architecture:** The existing `RoomCodeDisplay` component gains meter state tracking and a `requestAnimationFrame` interpolation loop. CSS `background-clip: text` with a dynamic `linear-gradient` replaces the flat `header.style.color`. The `OverlayManager` already broadcasts `playerCount` and `maxPlayers` in context — no state management changes needed.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES modules), CSS `background-clip: text`, `requestAnimationFrame`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Revert Player List Default to Unchecked
|
||||
|
||||
**Files:**
|
||||
- Modify: `optimized-controls.html:669` (the `player-list-enabled` checkbox)
|
||||
|
||||
**Step 1: Remove the `checked` attribute**
|
||||
|
||||
In `optimized-controls.html`, change:
|
||||
|
||||
```html
|
||||
<input type="checkbox" id="player-list-enabled" checked>
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```html
|
||||
<input type="checkbox" id="player-list-enabled">
|
||||
```
|
||||
|
||||
**Step 2: Verify in browser**
|
||||
|
||||
Open `http://localhost:8080/optimized-controls.html`, expand Player List Settings, confirm the checkbox is unchecked by default.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add optimized-controls.html
|
||||
git commit -m "fix: disable player list by default"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update `#header` CSS for Gradient Text Support
|
||||
|
||||
**Files:**
|
||||
- Modify: `optimized-controls.html` — `<style>` block, `#header` rule (lines ~30-42)
|
||||
|
||||
**Step 1: Replace `#header` CSS**
|
||||
|
||||
Find the existing `#header` rule:
|
||||
|
||||
```css
|
||||
#header {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
transform: translateY(-220px);
|
||||
color: #f35dcb;
|
||||
font-size: 80px;
|
||||
font-weight: bold;
|
||||
text-shadow: 3px 3px 8px rgba(0, 0, 0, 0.8);
|
||||
letter-spacing: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```css
|
||||
#header {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
transform: translateY(-220px);
|
||||
color: #f35dcb;
|
||||
font-size: 80px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
/* Meter gradient support */
|
||||
background: linear-gradient(to right, #ffffff 0%, #ffffff 0%, #f35dcb 0%, #f35dcb 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(3px 3px 8px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
```
|
||||
|
||||
Key changes: removed `text-shadow` (incompatible with transparent text), added `background` gradient, `background-clip: text`, `-webkit-text-fill-color: transparent`, and `filter: drop-shadow()` to replace the shadow.
|
||||
|
||||
**Step 2: Add pulse keyframes and class**
|
||||
|
||||
After the `#header` rule, add:
|
||||
|
||||
```css
|
||||
@keyframes meter-full-pulse {
|
||||
0% { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(255,255,255,0.6)) drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); }
|
||||
100% { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); }
|
||||
}
|
||||
|
||||
#header.meter-full-pulse {
|
||||
animation: meter-full-pulse 0.6s ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify in browser**
|
||||
|
||||
Load the page. The header text should still appear pink during the animation cycle but now rendered via gradient clip instead of flat color. The shadow should look the same (via `drop-shadow`).
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add optimized-controls.html
|
||||
git commit -m "feat: add CSS gradient-clip and pulse keyframes for header meter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add Meter State and Gradient Logic to `RoomCodeDisplay`
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/room-code-display.js`
|
||||
|
||||
**Step 1: Add private meter fields**
|
||||
|
||||
After the existing private fields (`#elements`, `#inputs`, `#animationTimers`, `#active`), add:
|
||||
|
||||
```javascript
|
||||
/** @type {number} Current fill 0-1 */
|
||||
#meterFill = 0;
|
||||
|
||||
/** @type {number} Target fill 0-1 */
|
||||
#meterTarget = 0;
|
||||
|
||||
/** @type {number | null} */
|
||||
#meterRafId = null;
|
||||
|
||||
/** @type {boolean} Whether the pulse has fired for the current 100% state */
|
||||
#meterPulseFired = false;
|
||||
```
|
||||
|
||||
**Step 2: Add `#applyMeterGradient()` method**
|
||||
|
||||
This reads the current `#meterFill` and the configured header color, then sets the gradient on the header element.
|
||||
|
||||
```javascript
|
||||
#applyMeterGradient() {
|
||||
const header = this.#elements?.header;
|
||||
const inputs = this.#inputs;
|
||||
if (!header || !inputs) return;
|
||||
|
||||
const pct = Math.round(this.#meterFill * 100);
|
||||
const baseColor = inputs.headerColor.value || '#f35dcb';
|
||||
|
||||
header.style.background =
|
||||
`linear-gradient(to right, #ffffff 0%, #ffffff ${pct}%, ${baseColor} ${pct}%, ${baseColor} 100%)`;
|
||||
header.style.webkitBackgroundClip = 'text';
|
||||
header.style.backgroundClip = 'text';
|
||||
header.style.webkitTextFillColor = 'transparent';
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add `#animateMeterTo(target)` method**
|
||||
|
||||
Smooth `requestAnimationFrame` interpolation from current fill to target over ~400ms.
|
||||
|
||||
```javascript
|
||||
#animateMeterTo(target) {
|
||||
const clamped = Math.max(0, Math.min(1, target));
|
||||
this.#meterTarget = clamped;
|
||||
|
||||
if (this.#meterRafId != null) {
|
||||
cancelAnimationFrame(this.#meterRafId);
|
||||
this.#meterRafId = null;
|
||||
}
|
||||
|
||||
const start = this.#meterFill;
|
||||
const delta = clamped - start;
|
||||
if (Math.abs(delta) < 0.001) {
|
||||
this.#meterFill = clamped;
|
||||
this.#applyMeterGradient();
|
||||
this.#checkFullPulse();
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = 400;
|
||||
const startTime = performance.now();
|
||||
|
||||
const step = (now) => {
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
|
||||
this.#meterFill = start + delta * eased;
|
||||
this.#applyMeterGradient();
|
||||
|
||||
if (t < 1) {
|
||||
this.#meterRafId = requestAnimationFrame(step);
|
||||
} else {
|
||||
this.#meterRafId = null;
|
||||
this.#meterFill = clamped;
|
||||
this.#applyMeterGradient();
|
||||
this.#checkFullPulse();
|
||||
}
|
||||
};
|
||||
|
||||
this.#meterRafId = requestAnimationFrame(step);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Add `#checkFullPulse()` method**
|
||||
|
||||
```javascript
|
||||
#checkFullPulse() {
|
||||
const header = this.#elements?.header;
|
||||
if (!header) return;
|
||||
|
||||
if (this.#meterFill >= 1 && !this.#meterPulseFired) {
|
||||
this.#meterPulseFired = true;
|
||||
header.classList.add('meter-full-pulse');
|
||||
header.addEventListener('animationend', () => {
|
||||
header.classList.remove('meter-full-pulse');
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
if (this.#meterFill < 1) {
|
||||
this.#meterPulseFired = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Modify `#applySettings()` — replace color line with gradient**
|
||||
|
||||
In the existing `#applySettings()` method, find:
|
||||
|
||||
```javascript
|
||||
header.style.color = inputs.headerColor.value;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```javascript
|
||||
this.#applyMeterGradient();
|
||||
```
|
||||
|
||||
This ensures that whenever settings are applied, the gradient is re-rendered with the correct header color and current fill level.
|
||||
|
||||
**Step 6: Modify `update(ctx)` — add meter update logic**
|
||||
|
||||
The existing `update()` only checks roomCode changes. After the existing roomCode check, add meter logic:
|
||||
|
||||
```javascript
|
||||
// After the existing roomCode change check:
|
||||
const count = Number(ctx?.playerCount ?? 0);
|
||||
const max = Number(ctx?.maxPlayers ?? 0);
|
||||
const newTarget = max > 0 ? count / max : 0;
|
||||
|
||||
if (Math.abs(newTarget - this.#meterTarget) > 0.001) {
|
||||
this.#animateMeterTo(newTarget);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 7: Modify `deactivate()` — cancel RAF and reset meter**
|
||||
|
||||
Add before the existing opacity fade-out code:
|
||||
|
||||
```javascript
|
||||
if (this.#meterRafId != null) {
|
||||
cancelAnimationFrame(this.#meterRafId);
|
||||
this.#meterRafId = null;
|
||||
}
|
||||
this.#meterFill = 0;
|
||||
this.#meterTarget = 0;
|
||||
this.#meterPulseFired = false;
|
||||
```
|
||||
|
||||
**Step 8: Modify `activate(ctx)` — seed initial meter from context**
|
||||
|
||||
After the existing `this.#applySettings()` call, add:
|
||||
|
||||
```javascript
|
||||
const count = Number(ctx?.playerCount ?? 0);
|
||||
const max = Number(ctx?.maxPlayers ?? 0);
|
||||
const initialFill = max > 0 ? count / max : 0;
|
||||
this.#meterFill = Math.max(0, Math.min(1, initialFill));
|
||||
this.#meterTarget = this.#meterFill;
|
||||
this.#meterPulseFired = false;
|
||||
this.#applyMeterGradient();
|
||||
```
|
||||
|
||||
**Step 9: Update `getStatus()` — include meter info**
|
||||
|
||||
Add `meterFill` to the returned object:
|
||||
|
||||
```javascript
|
||||
getStatus() {
|
||||
// ... existing code ...
|
||||
return {
|
||||
active: this.#active,
|
||||
roomCode,
|
||||
timersRunning: this.#animationTimers.length > 0,
|
||||
meterFill: Math.round(this.#meterFill * 100),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Step 10: Verify in browser**
|
||||
|
||||
1. Open the overlay, connect to API.
|
||||
2. Trigger a game (or use Update/Preview button).
|
||||
3. The header should appear 100% pink (0 players).
|
||||
4. As players join, the gradient should animate left-to-right toward white.
|
||||
5. At full capacity, a brief pulse/glow should fire.
|
||||
|
||||
**Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add js/room-code-display.js
|
||||
git commit -m "feat: add animated gradient meter to header text based on player count"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update Dashboard Status for Meter
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/controls.js` — `formatStatusRow` function
|
||||
|
||||
**Step 1: Include meter fill in roomCode status display**
|
||||
|
||||
In `formatStatusRow`, find the `roomCode` case:
|
||||
|
||||
```javascript
|
||||
if (name === 'roomCode') {
|
||||
const rc = /** @type {{ active?: boolean, roomCode?: string }} */ (s);
|
||||
return rc.active ? `Active (${rc.roomCode ?? ''})` : 'Inactive';
|
||||
}
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```javascript
|
||||
if (name === 'roomCode') {
|
||||
const rc = /** @type {{ active?: boolean, roomCode?: string, meterFill?: number }} */ (s);
|
||||
if (!rc.active) return 'Inactive';
|
||||
const meter = rc.meterFill != null ? ` | Meter: ${rc.meterFill}%` : '';
|
||||
return `Active (${rc.roomCode ?? ''})${meter}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add js/controls.js
|
||||
git commit -m "feat: show meter fill percentage in dashboard status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: End-to-End Verification
|
||||
|
||||
**Step 1: Full flow test**
|
||||
|
||||
1. Load `http://localhost:8080/optimized-controls.html`.
|
||||
2. Confirm player list checkbox is unchecked by default.
|
||||
3. Connect to the API WebSocket.
|
||||
4. Trigger a game via the Game Picker (or use Preview/Update button with a room code).
|
||||
5. Observe: header appears 100% pink.
|
||||
6. As players join, gradient fills left-to-right, animating smoothly.
|
||||
7. When lobby is full, text is 100% white and pulse fires.
|
||||
8. When game starts/ends, header deactivates, meter resets.
|
||||
9. New lobby → header starts fresh at 0% fill.
|
||||
|
||||
**Step 2: Edge case test**
|
||||
|
||||
- Use Update/Preview with `max_players: 2`. Join 1 player (50%), then 2nd (100% + pulse).
|
||||
- Disconnect/reconnect mid-lobby — meter should reset and re-seed from context.
|
||||
|
||||
**Step 3: Final commit (if any polish needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "polish: header meter edge cases and cleanup"
|
||||
```
|
||||
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.
|
||||
142
js/audio-controller.js
Normal file
142
js/audio-controller.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
window.OBS = window.OBS || {};
|
||||
window.OBS.AudioController = AudioController;
|
||||
364
js/controls.js
vendored
Normal file
364
js/controls.js
vendored
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Debug dashboard, manual overrides, and bindings for the controls panel.
|
||||
*/
|
||||
|
||||
const STATE_COLORS = Object.freeze({
|
||||
idle: '#888',
|
||||
lobby: '#4CAF50',
|
||||
playing: '#f0ad4e',
|
||||
ended: '#d9534f',
|
||||
disconnected: '#d9534f',
|
||||
});
|
||||
|
||||
const STORAGE_API_URL = 'jackbox-api-url';
|
||||
const STORAGE_API_KEY = 'jackbox-api-key';
|
||||
|
||||
/**
|
||||
* @param {import('./state-manager.js').OverlayManager} manager
|
||||
* @param {import('./websocket-client.js').WebSocketClient} wsClient
|
||||
* @param {{ roomCode?: unknown, audio?: unknown, playerList?: unknown }} components
|
||||
*/
|
||||
function initControls(manager, wsClient, components) {
|
||||
const stateEl = document.getElementById('manager-state');
|
||||
const roomCodeEl = document.getElementById('manager-room-code');
|
||||
const sessionIdEl = document.getElementById('manager-session-id');
|
||||
const gameTitleEl = document.getElementById('manager-game-title');
|
||||
const playerCountEl = document.getElementById('manager-player-count');
|
||||
const eventLogEl = document.getElementById('manager-event-log');
|
||||
|
||||
const componentNames = Object.keys(components);
|
||||
|
||||
for (const name of componentNames) {
|
||||
const select = document.getElementById(`override-${name}`);
|
||||
if (!select) continue;
|
||||
if (select.options.length === 0) {
|
||||
for (const mode of Object.values(window.OBS.OVERRIDE_MODES)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mode;
|
||||
opt.textContent = mode.replace(/_/g, ' ');
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
select.value = manager.getOverride(name);
|
||||
select.addEventListener('change', () => {
|
||||
manager.setOverride(name, select.value);
|
||||
});
|
||||
}
|
||||
|
||||
function formatGameTitleLine(ctx) {
|
||||
const g = ctx.game;
|
||||
if (!g || typeof g !== 'object') return '—';
|
||||
const rec = /** @type {Record<string, unknown>} */ (g);
|
||||
const title =
|
||||
(typeof rec.title === 'string' && rec.title) ||
|
||||
(typeof rec.name === 'string' && rec.name) ||
|
||||
'';
|
||||
const pack =
|
||||
(typeof rec.pack_name === 'string' && rec.pack_name) ||
|
||||
(typeof rec.packName === 'string' && rec.packName) ||
|
||||
(typeof rec.pack === 'string' && rec.pack) ||
|
||||
'';
|
||||
if (!title) return '—';
|
||||
return pack ? `${title} (${pack})` : title;
|
||||
}
|
||||
|
||||
function formatStatusRow(name, info) {
|
||||
const s = info?.status;
|
||||
if (!s || typeof s !== 'object') return '—';
|
||||
if (name === 'roomCode') {
|
||||
const rc = /** @type {{ active?: boolean, roomCode?: string, meterFill?: number }} */ (s);
|
||||
if (!rc.active) return 'Inactive';
|
||||
const meter = rc.meterFill != null ? ` | Meter: ${rc.meterFill}%` : '';
|
||||
return `Active (${rc.roomCode ?? ''})${meter}`;
|
||||
}
|
||||
if (name === 'audio') {
|
||||
const a = /** @type {{ active?: boolean, playing?: boolean }} */ (s);
|
||||
if (!a.active) return 'Inactive';
|
||||
return a.playing ? 'Playing' : 'Active (muted)';
|
||||
}
|
||||
if (name === 'playerList') {
|
||||
const pl = /** @type {{ active?: boolean, playerCount?: number, maxPlayers?: number }} */ (
|
||||
s
|
||||
);
|
||||
if (!pl.active) return 'Inactive';
|
||||
const n = pl.playerCount ?? 0;
|
||||
const m = pl.maxPlayers ?? '?';
|
||||
return `Active (${n}/${m})`;
|
||||
}
|
||||
return /** @type {{ active?: boolean }} */ (s).active ? 'Active' : 'Inactive';
|
||||
}
|
||||
|
||||
function updateDashboard() {
|
||||
const state = manager.getState();
|
||||
const ctx = manager.getContext();
|
||||
|
||||
if (stateEl) {
|
||||
stateEl.textContent = state.toUpperCase();
|
||||
stateEl.style.backgroundColor = STATE_COLORS[state] ?? '#888';
|
||||
}
|
||||
if (roomCodeEl) {
|
||||
roomCodeEl.textContent = ctx.roomCode != null ? String(ctx.roomCode) : '—';
|
||||
}
|
||||
if (sessionIdEl) {
|
||||
sessionIdEl.textContent = ctx.sessionId != null ? String(ctx.sessionId) : '—';
|
||||
}
|
||||
if (gameTitleEl) {
|
||||
gameTitleEl.textContent = formatGameTitleLine(ctx);
|
||||
}
|
||||
if (playerCountEl) {
|
||||
const n = ctx.playerCount ?? 0;
|
||||
const m = ctx.maxPlayers != null ? String(ctx.maxPlayers) : '?';
|
||||
playerCountEl.textContent = `${n}/${m}`;
|
||||
}
|
||||
|
||||
const statuses = manager.getComponentStatuses();
|
||||
for (const name of componentNames) {
|
||||
const row = document.getElementById(`status-${name}`);
|
||||
if (!row) continue;
|
||||
const info = statuses[name];
|
||||
row.textContent = formatStatusRow(name, info);
|
||||
}
|
||||
|
||||
for (const name of componentNames) {
|
||||
const sel = document.getElementById(`override-${name}`);
|
||||
if (!sel) continue;
|
||||
const want = manager.getOverride(name);
|
||||
if (sel.value !== want) sel.value = want;
|
||||
}
|
||||
|
||||
if (eventLogEl) {
|
||||
const log = manager.getEventLog();
|
||||
const last = log.slice(-20);
|
||||
eventLogEl.replaceChildren();
|
||||
for (const e of last) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'event-log-entry';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'event-time';
|
||||
time.textContent = new Date(e.at).toLocaleTimeString();
|
||||
const typ = document.createElement('span');
|
||||
typ.className = 'event-type';
|
||||
typ.textContent = e.type;
|
||||
row.appendChild(time);
|
||||
row.appendChild(document.createTextNode(' '));
|
||||
row.appendChild(typ);
|
||||
eventLogEl.appendChild(row);
|
||||
}
|
||||
eventLogEl.scrollTop = eventLogEl.scrollHeight;
|
||||
}
|
||||
|
||||
syncToggleDisplayLabel();
|
||||
}
|
||||
|
||||
const toggleDisplayBtn = document.getElementById('toggle-display-btn');
|
||||
|
||||
function syncToggleDisplayLabel() {
|
||||
if (!toggleDisplayBtn) return;
|
||||
const s = manager.getState();
|
||||
toggleDisplayBtn.textContent = s === 'lobby' ? 'Hide Display' : 'Show Display';
|
||||
}
|
||||
|
||||
manager.onChange(() => {
|
||||
updateDashboard();
|
||||
});
|
||||
|
||||
updateDashboard();
|
||||
|
||||
const connectBtn = document.getElementById('ws-connect-btn');
|
||||
const disconnectBtn = document.getElementById('ws-disconnect-btn');
|
||||
const apiUrlInput = document.getElementById('api-url-input');
|
||||
const apiKeyInput = document.getElementById('api-key-input');
|
||||
|
||||
if (connectBtn && apiUrlInput && apiKeyInput) {
|
||||
connectBtn.addEventListener('click', () => {
|
||||
const url = apiUrlInput.value.trim();
|
||||
const key = apiKeyInput.value.trim();
|
||||
if (url) localStorage.setItem(STORAGE_API_URL, url);
|
||||
if (key) localStorage.setItem(STORAGE_API_KEY, key);
|
||||
void wsClient.connect(url, key);
|
||||
});
|
||||
}
|
||||
|
||||
if (disconnectBtn) {
|
||||
disconnectBtn.addEventListener('click', () => {
|
||||
wsClient.disconnect();
|
||||
manager.handleEvent('session.ended', { session: {} });
|
||||
});
|
||||
}
|
||||
|
||||
apiUrlInput?.addEventListener('change', () => {
|
||||
const url = apiUrlInput.value.trim();
|
||||
if (url) localStorage.setItem(STORAGE_API_URL, url);
|
||||
});
|
||||
apiKeyInput?.addEventListener('change', () => {
|
||||
localStorage.setItem(STORAGE_API_KEY, apiKeyInput.value.trim());
|
||||
});
|
||||
|
||||
const volumeSlider = document.getElementById('volume-slider');
|
||||
const volumeValue = document.getElementById('volume-value');
|
||||
const themeSound = /** @type {HTMLAudioElement | null} */ (
|
||||
document.getElementById('theme-sound')
|
||||
);
|
||||
|
||||
if (volumeSlider && volumeValue) {
|
||||
volumeSlider.addEventListener('input', () => {
|
||||
const v = Number(volumeSlider.value);
|
||||
volumeValue.textContent = `${Math.round(v * 100)}%`;
|
||||
if (themeSound) themeSound.volume = v;
|
||||
});
|
||||
}
|
||||
|
||||
const testSoundBtn = document.getElementById('test-sound-btn');
|
||||
const soundUrlInput = document.getElementById('sound-url-input');
|
||||
if (testSoundBtn && themeSound && soundUrlInput && volumeSlider) {
|
||||
testSoundBtn.addEventListener('click', () => {
|
||||
themeSound.src = soundUrlInput.value;
|
||||
themeSound.volume = Number(volumeSlider.value);
|
||||
themeSound.currentTime = 0;
|
||||
themeSound.loop = false;
|
||||
themeSound.play().catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log('[Test] Playback failed:', msg);
|
||||
window.alert(
|
||||
'Audio playback failed. Check the URL or browser autoplay permissions.',
|
||||
);
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
themeSound.pause();
|
||||
themeSound.loop = true;
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function synthesizeGameAddedFromInputs() {
|
||||
const code1 = document.getElementById('code1-input')?.value ?? '';
|
||||
const code2 = document.getElementById('code2-input')?.value ?? '';
|
||||
const roomCode = `${code1}${code2}`.trim();
|
||||
if (!roomCode) return;
|
||||
manager.handleEvent('game.added', {
|
||||
session: {},
|
||||
game: {
|
||||
room_code: roomCode,
|
||||
title: 'Manual',
|
||||
max_players: 8,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updateBtn = document.getElementById('update-btn');
|
||||
const previewBtn = document.getElementById('preview-btn');
|
||||
updateBtn?.addEventListener('click', () => {
|
||||
synthesizeGameAddedFromInputs();
|
||||
});
|
||||
previewBtn?.addEventListener('click', () => {
|
||||
synthesizeGameAddedFromInputs();
|
||||
});
|
||||
|
||||
if (toggleDisplayBtn) {
|
||||
toggleDisplayBtn.addEventListener('click', () => {
|
||||
const s = manager.getState();
|
||||
if (s === 'lobby') {
|
||||
manager.handleEvent('session.ended', { session: {} });
|
||||
} else if (s === 'idle') {
|
||||
const code1 = document.getElementById('code1-input')?.value ?? '';
|
||||
const code2 = document.getElementById('code2-input')?.value ?? '';
|
||||
const roomCode = `${code1}${code2}`.trim();
|
||||
if (roomCode) {
|
||||
manager.handleEvent('game.added', {
|
||||
session: {},
|
||||
game: {
|
||||
room_code: roomCode,
|
||||
title: 'Display',
|
||||
max_players: 8,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
syncToggleDisplayLabel();
|
||||
});
|
||||
}
|
||||
|
||||
const showControlsBtn = document.getElementById('show-controls-btn');
|
||||
const controls = document.getElementById('controls');
|
||||
if (showControlsBtn && controls) {
|
||||
showControlsBtn.addEventListener('click', () => {
|
||||
if (controls.style.display === 'block') {
|
||||
controls.style.display = 'none';
|
||||
showControlsBtn.textContent = 'Show Controls';
|
||||
} else {
|
||||
controls.style.display = 'block';
|
||||
showControlsBtn.textContent = 'Hide Controls';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.section-header').forEach((hdr) => {
|
||||
hdr.addEventListener('click', () => {
|
||||
const content = hdr.nextElementSibling;
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const indicator = hdr.querySelector('.toggle-indicator');
|
||||
if (content.style.display === 'block') {
|
||||
content.style.display = 'none';
|
||||
indicator?.classList.remove('active');
|
||||
hdr.classList.remove('active');
|
||||
} else {
|
||||
content.style.display = 'block';
|
||||
indicator?.classList.add('active');
|
||||
hdr.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const positionToggle = document.getElementById('position-toggle');
|
||||
if (positionToggle && controls) {
|
||||
positionToggle.addEventListener('change', () => {
|
||||
controls.classList.toggle('bottom-position', positionToggle.checked);
|
||||
});
|
||||
}
|
||||
|
||||
let inactivityTimer = 0;
|
||||
function resetInactivityTimer() {
|
||||
window.clearTimeout(inactivityTimer);
|
||||
inactivityTimer = window.setTimeout(() => {
|
||||
if (controls) controls.style.display = 'none';
|
||||
if (showControlsBtn) showControlsBtn.textContent = 'Show Controls';
|
||||
}, 20000);
|
||||
}
|
||||
document.addEventListener('mousemove', resetInactivityTimer);
|
||||
document.addEventListener('keypress', resetInactivityTimer);
|
||||
document.addEventListener('click', resetInactivityTimer);
|
||||
resetInactivityTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {(state: string, message?: string) => void}
|
||||
*/
|
||||
function initConnectionStatusHandler() {
|
||||
const wsStatusDot = document.getElementById('ws-status-dot');
|
||||
const wsStatusText = document.getElementById('ws-status-text');
|
||||
const wsConnectBtn = document.getElementById('ws-connect-btn');
|
||||
const wsDisconnectRow = document.getElementById('ws-disconnect-row');
|
||||
const apiUrlInput = document.getElementById('api-url-input');
|
||||
const apiKeyInput = document.getElementById('api-key-input');
|
||||
|
||||
return (state, message) => {
|
||||
if (wsStatusDot) wsStatusDot.className = `status-dot ${state}`;
|
||||
if (wsStatusText) wsStatusText.textContent = message ?? String(state);
|
||||
|
||||
if (state === 'connected') {
|
||||
if (wsConnectBtn) wsConnectBtn.style.display = 'none';
|
||||
if (wsDisconnectRow) wsDisconnectRow.style.display = 'flex';
|
||||
if (apiUrlInput) apiUrlInput.disabled = true;
|
||||
if (apiKeyInput) apiKeyInput.disabled = true;
|
||||
} else {
|
||||
if (wsConnectBtn) wsConnectBtn.style.display = 'block';
|
||||
if (wsDisconnectRow) wsDisconnectRow.style.display = 'none';
|
||||
if (apiUrlInput) apiUrlInput.disabled = false;
|
||||
if (apiKeyInput) apiKeyInput.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.OBS = window.OBS || {};
|
||||
window.OBS.initControls = initControls;
|
||||
window.OBS.initConnectionStatusHandler = initConnectionStatusHandler;
|
||||
320
js/player-list.js
Normal file
320
js/player-list.js
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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
|
||||
* @property {HTMLInputElement} offsetX
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
|
||||
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 = '\u00A0';
|
||||
|
||||
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`;
|
||||
const offsetX = parseInt(inputs.offsetX?.value, 10) || 0;
|
||||
|
||||
container.style.transform = `translateY(calc(-50% + ${offsetY}))`;
|
||||
|
||||
if (pos === 'right') {
|
||||
container.style.right = `${24 + offsetX}px`;
|
||||
container.style.left = '';
|
||||
} else {
|
||||
container.style.left = `${24 + offsetX}px`;
|
||||
container.style.right = '';
|
||||
}
|
||||
|
||||
for (const slot of this._slots) {
|
||||
slot.nameEl.style.fontSize = fontPx;
|
||||
slot.element.querySelector('.player-slot-number').style.fontSize = fontPx;
|
||||
if (slot.nameEl.classList.contains('filled')) {
|
||||
slot.nameEl.style.color = textColor;
|
||||
} else {
|
||||
slot.nameEl.style.borderBottomColor = 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 = '\u00A0';
|
||||
slot.nameEl.classList.remove('filled');
|
||||
slot.nameEl.classList.add('empty');
|
||||
slot.filled = false;
|
||||
slot.nameEl.style.transition = '';
|
||||
slot.nameEl.style.opacity = '1';
|
||||
slot.nameEl.style.color = 'transparent';
|
||||
if (inputs) {
|
||||
slot.nameEl.style.borderBottomColor = 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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
window.OBS = window.OBS || {};
|
||||
window.OBS.PlayerList = PlayerList;
|
||||
386
js/room-code-display.js
Normal file
386
js/room-code-display.js
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
class RoomCodeDisplay {
|
||||
/** @type {RoomCodeDisplayElements | null} */
|
||||
#elements = null;
|
||||
|
||||
/** @type {RoomCodeDisplayInputs | null} */
|
||||
#inputs = null;
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>[]} */
|
||||
#animationTimers = [];
|
||||
|
||||
/** @type {boolean} */
|
||||
#active = false;
|
||||
|
||||
/** @type {number} */
|
||||
#meterFill = 0;
|
||||
|
||||
/** @type {number} */
|
||||
#meterTarget = 0;
|
||||
|
||||
/** @type {number | null} */
|
||||
#meterRafId = null;
|
||||
|
||||
|
||||
/**
|
||||
* @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();
|
||||
|
||||
const count = Number(ctx?.playerCount ?? 0);
|
||||
const max = Number(ctx?.maxPlayers ?? 0);
|
||||
const initialFill = max > 0 ? count / max : 0;
|
||||
this.#meterFill = Math.max(0, Math.min(1, initialFill));
|
||||
this.#meterTarget = this.#meterFill;
|
||||
this.#applyMeterGradient();
|
||||
|
||||
this.#startAnimation();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.#active = false;
|
||||
this.#clearAnimationTimers();
|
||||
|
||||
if (this.#meterRafId != null) {
|
||||
cancelAnimationFrame(this.#meterRafId);
|
||||
this.#meterRafId = null;
|
||||
}
|
||||
this.#meterFill = 0;
|
||||
this.#meterTarget = 0;
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Number(ctx?.playerCount ?? 0);
|
||||
const max = Number(ctx?.maxPlayers ?? 0);
|
||||
const newTarget = max > 0 ? count / max : 0;
|
||||
if (Math.abs(newTarget - this.#meterTarget) > 0.001) {
|
||||
this.#animateMeterTo(newTarget);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
meterFill: Math.round(this.#meterFill * 100),
|
||||
};
|
||||
}
|
||||
|
||||
#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;
|
||||
this.#applyMeterGradient();
|
||||
header.style.fontSize = `${inputs.headerSize.value}px`;
|
||||
header.style.transform = `translateX(-50%) 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),
|
||||
);
|
||||
}
|
||||
|
||||
#applyMeterGradient() {
|
||||
const header = this.#elements?.header;
|
||||
const inputs = this.#inputs;
|
||||
if (!header || !inputs) return;
|
||||
|
||||
const pct = Math.round(this.#meterFill * 100);
|
||||
const baseColor = inputs.headerColor.value || '#f35dcb';
|
||||
|
||||
header.style.background =
|
||||
`linear-gradient(to right, #ffffff 0%, #ffffff ${pct}%, ${baseColor} ${pct}%, ${baseColor} 100%)`;
|
||||
header.style.webkitBackgroundClip = 'text';
|
||||
header.style.backgroundClip = 'text';
|
||||
header.style.webkitTextFillColor = 'transparent';
|
||||
}
|
||||
|
||||
#animateMeterTo(target) {
|
||||
const clamped = Math.max(0, Math.min(1, target));
|
||||
this.#meterTarget = clamped;
|
||||
|
||||
if (this.#meterRafId != null) {
|
||||
cancelAnimationFrame(this.#meterRafId);
|
||||
this.#meterRafId = null;
|
||||
}
|
||||
|
||||
const start = this.#meterFill;
|
||||
const delta = clamped - start;
|
||||
if (Math.abs(delta) < 0.001) {
|
||||
this.#meterFill = clamped;
|
||||
this.#applyMeterGradient();
|
||||
this.#checkFullPulse();
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = 400;
|
||||
const startTime = performance.now();
|
||||
|
||||
const step = (now) => {
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
this.#meterFill = start + delta * eased;
|
||||
this.#applyMeterGradient();
|
||||
|
||||
if (t < 1) {
|
||||
this.#meterRafId = requestAnimationFrame(step);
|
||||
} else {
|
||||
this.#meterRafId = null;
|
||||
this.#meterFill = clamped;
|
||||
this.#applyMeterGradient();
|
||||
this.#checkFullPulse();
|
||||
}
|
||||
};
|
||||
|
||||
this.#meterRafId = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
#checkFullPulse() {
|
||||
const header = this.#elements?.header;
|
||||
if (!header) return;
|
||||
|
||||
if (this.#meterFill >= 1) {
|
||||
header.classList.add('meter-full-pulse');
|
||||
} else {
|
||||
header.classList.remove('meter-full-pulse');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.OBS = window.OBS || {};
|
||||
window.OBS.RoomCodeDisplay = RoomCodeDisplay;
|
||||
420
js/state-manager.js
Normal file
420
js/state-manager.js
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Central overlay state machine: coordinates room/game lifecycle and registered UI components.
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
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 'game.status':
|
||||
this.#applyGameStatus(d);
|
||||
if (this.#state === 'idle' && d.gameState === 'Lobby') {
|
||||
this.#transitionTo('lobby');
|
||||
} else {
|
||||
this.#broadcastUpdate();
|
||||
}
|
||||
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);
|
||||
const mp = game.max_players ?? game.maxPlayers;
|
||||
if (mp != null) this.#context.maxPlayers = Number(mp);
|
||||
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];
|
||||
const joined = d.playerName ?? d.player ?? d.lastJoinedPlayer;
|
||||
if (joined !== undefined) this.#context.lastJoinedPlayer = joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
#applyGameStatus(d) {
|
||||
if (d.roomCode != null) this.#context.roomCode = String(d.roomCode);
|
||||
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.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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[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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.OBS = window.OBS || {};
|
||||
window.OBS.OVERRIDE_MODES = OVERRIDE_MODES;
|
||||
window.OBS.OverlayManager = OverlayManager;
|
||||
471
js/websocket-client.js
Normal file
471
js/websocket-client.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
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) {
|
||||
console.log(
|
||||
'[WebSocketClient] Could not fetch active session:',
|
||||
response.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await response.json();
|
||||
if (!session || session.id === undefined || session.id === null) {
|
||||
console.log(
|
||||
'[WebSocketClient] No active session found; waiting for session.started',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = session.id;
|
||||
console.log(
|
||||
'[WebSocketClient] Found active session:',
|
||||
sessionId,
|
||||
'— subscribing',
|
||||
);
|
||||
this.subscribeToSession(sessionId);
|
||||
|
||||
await this._restoreCurrentGame(sessionId);
|
||||
} catch (err) {
|
||||
console.error('[WebSocketClient] Error fetching active session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After subscribing, fetch the session's games to find the currently playing
|
||||
* game, then hit status-live for full shard state (players, lobby, etc.).
|
||||
* @param {string | number} sessionId
|
||||
*/
|
||||
async _restoreCurrentGame(sessionId) {
|
||||
const apiUrl = this._apiUrl;
|
||||
const token = this._jwtToken;
|
||||
if (!apiUrl || !token) return;
|
||||
|
||||
try {
|
||||
const gamesRes = await fetch(`${apiUrl}/api/sessions/${sessionId}/games`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!gamesRes.ok) {
|
||||
console.log('[WebSocketClient] Could not fetch session games:', gamesRes.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const games = await gamesRes.json();
|
||||
if (!Array.isArray(games) || games.length === 0) return;
|
||||
|
||||
const playing = games.find((g) => g.status === 'playing');
|
||||
if (!playing) return;
|
||||
|
||||
console.log(
|
||||
'[WebSocketClient] Restoring in-progress game:',
|
||||
playing.title,
|
||||
'(room:', playing.room_code, ')',
|
||||
);
|
||||
|
||||
this._onEvent('game.added', {
|
||||
session: { id: sessionId },
|
||||
game: {
|
||||
id: playing.game_id,
|
||||
title: playing.title,
|
||||
pack_name: playing.pack_name,
|
||||
min_players: playing.min_players,
|
||||
max_players: playing.max_players,
|
||||
room_code: playing.room_code,
|
||||
manually_added: playing.manually_added,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionGameId = playing.id;
|
||||
const statusRes = await fetch(
|
||||
`${apiUrl}/api/sessions/${sessionId}/games/${sessionGameId}/status-live`,
|
||||
);
|
||||
|
||||
if (statusRes.ok) {
|
||||
const live = await statusRes.json();
|
||||
console.log(
|
||||
'[WebSocketClient] Restored live status — players:',
|
||||
live.playerCount,
|
||||
'state:', live.gameState,
|
||||
);
|
||||
|
||||
this._onEvent('game.status', {
|
||||
sessionId,
|
||||
gameId: live.gameId ?? playing.game_id,
|
||||
roomCode: live.roomCode ?? playing.room_code,
|
||||
appTag: live.appTag,
|
||||
maxPlayers: live.maxPlayers ?? playing.max_players,
|
||||
playerCount: live.playerCount ?? playing.player_count,
|
||||
players: Array.isArray(live.players) ? live.players : [],
|
||||
lobbyState: live.lobbyState,
|
||||
gameState: live.gameState,
|
||||
gameStarted: live.gameStarted,
|
||||
gameFinished: live.gameFinished,
|
||||
monitoring: live.monitoring,
|
||||
});
|
||||
} else if (playing.player_count != null && playing.max_players != null) {
|
||||
this._onEvent('room.connected', {
|
||||
sessionId,
|
||||
gameId: playing.game_id,
|
||||
roomCode: playing.room_code,
|
||||
maxPlayers: playing.max_players,
|
||||
playerCount: playing.player_count,
|
||||
players: [],
|
||||
lobbyState: 'Unknown',
|
||||
gameState: 'Unknown',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocketClient] Error restoring current game:', 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);
|
||||
}
|
||||
}
|
||||
|
||||
window.OBS = window.OBS || {};
|
||||
window.OBS.WebSocketClient = WebSocketClient;
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user