Compare commits
10 Commits
875153ef63
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d66c2dba
|
||
|
|
fa7363bc78
|
||
|
|
a8b7df48a6
|
||
|
|
06e375ccdc
|
||
|
|
4c56b7f8f9
|
||
|
|
7a2f8419d5
|
||
|
|
d775f74035
|
||
|
|
228981bc2b
|
||
|
|
c4929e54c3
|
||
|
|
88a4ac9889
|
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"
|
||||||
|
```
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
* @property {HTMLInputElement} soundUrl
|
* @property {HTMLInputElement} soundUrl
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class AudioController {
|
class AudioController {
|
||||||
/** @type {HTMLAudioElement | null} */
|
/** @type {HTMLAudioElement | null} */
|
||||||
#audio = null;
|
#audio = null;
|
||||||
|
|
||||||
@@ -137,3 +137,6 @@ export class AudioController {
|
|||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.OBS = window.OBS || {};
|
||||||
|
window.OBS.AudioController = AudioController;
|
||||||
|
|||||||
18
js/controls.js
vendored
18
js/controls.js
vendored
@@ -2,8 +2,6 @@
|
|||||||
* Debug dashboard, manual overrides, and bindings for the controls panel.
|
* Debug dashboard, manual overrides, and bindings for the controls panel.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { OVERRIDE_MODES } from './state-manager.js';
|
|
||||||
|
|
||||||
const STATE_COLORS = Object.freeze({
|
const STATE_COLORS = Object.freeze({
|
||||||
idle: '#888',
|
idle: '#888',
|
||||||
lobby: '#4CAF50',
|
lobby: '#4CAF50',
|
||||||
@@ -20,7 +18,7 @@ const STORAGE_API_KEY = 'jackbox-api-key';
|
|||||||
* @param {import('./websocket-client.js').WebSocketClient} wsClient
|
* @param {import('./websocket-client.js').WebSocketClient} wsClient
|
||||||
* @param {{ roomCode?: unknown, audio?: unknown, playerList?: unknown }} components
|
* @param {{ roomCode?: unknown, audio?: unknown, playerList?: unknown }} components
|
||||||
*/
|
*/
|
||||||
export function initControls(manager, wsClient, components) {
|
function initControls(manager, wsClient, components) {
|
||||||
const stateEl = document.getElementById('manager-state');
|
const stateEl = document.getElementById('manager-state');
|
||||||
const roomCodeEl = document.getElementById('manager-room-code');
|
const roomCodeEl = document.getElementById('manager-room-code');
|
||||||
const sessionIdEl = document.getElementById('manager-session-id');
|
const sessionIdEl = document.getElementById('manager-session-id');
|
||||||
@@ -34,7 +32,7 @@ export function initControls(manager, wsClient, components) {
|
|||||||
const select = document.getElementById(`override-${name}`);
|
const select = document.getElementById(`override-${name}`);
|
||||||
if (!select) continue;
|
if (!select) continue;
|
||||||
if (select.options.length === 0) {
|
if (select.options.length === 0) {
|
||||||
for (const mode of Object.values(OVERRIDE_MODES)) {
|
for (const mode of Object.values(window.OBS.OVERRIDE_MODES)) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = mode;
|
opt.value = mode;
|
||||||
opt.textContent = mode.replace(/_/g, ' ');
|
opt.textContent = mode.replace(/_/g, ' ');
|
||||||
@@ -68,8 +66,10 @@ export function initControls(manager, wsClient, components) {
|
|||||||
const s = info?.status;
|
const s = info?.status;
|
||||||
if (!s || typeof s !== 'object') return '—';
|
if (!s || typeof s !== 'object') return '—';
|
||||||
if (name === 'roomCode') {
|
if (name === 'roomCode') {
|
||||||
const rc = /** @type {{ active?: boolean, roomCode?: string }} */ (s);
|
const rc = /** @type {{ active?: boolean, roomCode?: string, meterFill?: number }} */ (s);
|
||||||
return rc.active ? `Active (${rc.roomCode ?? ''})` : 'Inactive';
|
if (!rc.active) return 'Inactive';
|
||||||
|
const meter = rc.meterFill != null ? ` | Meter: ${rc.meterFill}%` : '';
|
||||||
|
return `Active (${rc.roomCode ?? ''})${meter}`;
|
||||||
}
|
}
|
||||||
if (name === 'audio') {
|
if (name === 'audio') {
|
||||||
const a = /** @type {{ active?: boolean, playing?: boolean }} */ (s);
|
const a = /** @type {{ active?: boolean, playing?: boolean }} */ (s);
|
||||||
@@ -333,7 +333,7 @@ export function initControls(manager, wsClient, components) {
|
|||||||
/**
|
/**
|
||||||
* @returns {(state: string, message?: string) => void}
|
* @returns {(state: string, message?: string) => void}
|
||||||
*/
|
*/
|
||||||
export function initConnectionStatusHandler() {
|
function initConnectionStatusHandler() {
|
||||||
const wsStatusDot = document.getElementById('ws-status-dot');
|
const wsStatusDot = document.getElementById('ws-status-dot');
|
||||||
const wsStatusText = document.getElementById('ws-status-text');
|
const wsStatusText = document.getElementById('ws-status-text');
|
||||||
const wsConnectBtn = document.getElementById('ws-connect-btn');
|
const wsConnectBtn = document.getElementById('ws-connect-btn');
|
||||||
@@ -358,3 +358,7 @@ export function initConnectionStatusHandler() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.OBS = window.OBS || {};
|
||||||
|
window.OBS.initControls = initControls;
|
||||||
|
window.OBS.initConnectionStatusHandler = initConnectionStatusHandler;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DEFAULT_MAX_PLAYERS = 8;
|
|||||||
* @property {HTMLInputElement} textColor
|
* @property {HTMLInputElement} textColor
|
||||||
* @property {HTMLInputElement} emptyColor
|
* @property {HTMLInputElement} emptyColor
|
||||||
* @property {HTMLInputElement} offset
|
* @property {HTMLInputElement} offset
|
||||||
|
* @property {HTMLInputElement} offsetX
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +39,7 @@ function displayName(p) {
|
|||||||
return String(p).trim();
|
return String(p).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlayerList {
|
class PlayerList {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._active = false;
|
this._active = false;
|
||||||
/** @type {HTMLElement | null} */
|
/** @type {HTMLElement | null} */
|
||||||
@@ -186,7 +187,7 @@ export class PlayerList {
|
|||||||
|
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'player-slot-name empty';
|
name.className = 'player-slot-name empty';
|
||||||
name.textContent = '────────';
|
name.textContent = '\u00A0';
|
||||||
|
|
||||||
slot.appendChild(number);
|
slot.appendChild(number);
|
||||||
slot.appendChild(name);
|
slot.appendChild(name);
|
||||||
@@ -214,15 +215,25 @@ export class PlayerList {
|
|||||||
const textColor = inputs.textColor.value;
|
const textColor = inputs.textColor.value;
|
||||||
const emptyColor = inputs.emptyColor.value;
|
const emptyColor = inputs.emptyColor.value;
|
||||||
const offsetY = `${parseInt(inputs.offset.value, 10) || 0}px`;
|
const offsetY = `${parseInt(inputs.offset.value, 10) || 0}px`;
|
||||||
|
const offsetX = parseInt(inputs.offsetX?.value, 10) || 0;
|
||||||
|
|
||||||
container.style.transform = `translateY(calc(-50% + ${offsetY}))`;
|
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) {
|
for (const slot of this._slots) {
|
||||||
slot.nameEl.style.fontSize = fontPx;
|
slot.nameEl.style.fontSize = fontPx;
|
||||||
|
slot.element.querySelector('.player-slot-number').style.fontSize = fontPx;
|
||||||
if (slot.nameEl.classList.contains('filled')) {
|
if (slot.nameEl.classList.contains('filled')) {
|
||||||
slot.nameEl.style.color = textColor;
|
slot.nameEl.style.color = textColor;
|
||||||
} else {
|
} else {
|
||||||
slot.nameEl.style.color = emptyColor;
|
slot.nameEl.style.borderBottomColor = emptyColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,14 +272,15 @@ export class PlayerList {
|
|||||||
slot.nameEl.style.opacity = '1';
|
slot.nameEl.style.opacity = '1';
|
||||||
}
|
}
|
||||||
} else if (slot.filled) {
|
} else if (slot.filled) {
|
||||||
slot.nameEl.textContent = '────────';
|
slot.nameEl.textContent = '\u00A0';
|
||||||
slot.nameEl.classList.remove('filled');
|
slot.nameEl.classList.remove('filled');
|
||||||
slot.nameEl.classList.add('empty');
|
slot.nameEl.classList.add('empty');
|
||||||
slot.filled = false;
|
slot.filled = false;
|
||||||
slot.nameEl.style.transition = '';
|
slot.nameEl.style.transition = '';
|
||||||
slot.nameEl.style.opacity = '1';
|
slot.nameEl.style.opacity = '1';
|
||||||
|
slot.nameEl.style.color = 'transparent';
|
||||||
if (inputs) {
|
if (inputs) {
|
||||||
slot.nameEl.style.color = inputs.emptyColor.value;
|
slot.nameEl.style.borderBottomColor = inputs.emptyColor.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,3 +315,6 @@ export class PlayerList {
|
|||||||
this._animationTimers = [];
|
this._animationTimers = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.OBS = window.OBS || {};
|
||||||
|
window.OBS.PlayerList = PlayerList;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
* @property {HTMLInputElement} line2HideDuration
|
* @property {HTMLInputElement} line2HideDuration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class RoomCodeDisplay {
|
class RoomCodeDisplay {
|
||||||
/** @type {RoomCodeDisplayElements | null} */
|
/** @type {RoomCodeDisplayElements | null} */
|
||||||
#elements = null;
|
#elements = null;
|
||||||
|
|
||||||
@@ -56,6 +56,16 @@ export class RoomCodeDisplay {
|
|||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
#active = false;
|
#active = false;
|
||||||
|
|
||||||
|
/** @type {number} */
|
||||||
|
#meterFill = 0;
|
||||||
|
|
||||||
|
/** @type {number} */
|
||||||
|
#meterTarget = 0;
|
||||||
|
|
||||||
|
/** @type {number | null} */
|
||||||
|
#meterRafId = null;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {RoomCodeDisplayElements} elements
|
* @param {RoomCodeDisplayElements} elements
|
||||||
* @param {RoomCodeDisplayInputs} inputs
|
* @param {RoomCodeDisplayInputs} inputs
|
||||||
@@ -89,6 +99,14 @@ export class RoomCodeDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#applySettings();
|
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();
|
this.#startAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +114,13 @@ export class RoomCodeDisplay {
|
|||||||
this.#active = false;
|
this.#active = false;
|
||||||
this.#clearAnimationTimers();
|
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 ?? {};
|
const { header, footer, codePart1, codePart2 } = this.#elements ?? {};
|
||||||
if (!header || !footer || !codePart1 || !codePart2) {
|
if (!header || !footer || !codePart1 || !codePart2) {
|
||||||
return;
|
return;
|
||||||
@@ -127,6 +152,14 @@ export class RoomCodeDisplay {
|
|||||||
|
|
||||||
if (next && next !== current) {
|
if (next && next !== current) {
|
||||||
this.activate(ctx);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +173,7 @@ export class RoomCodeDisplay {
|
|||||||
active: this.#active,
|
active: this.#active,
|
||||||
roomCode,
|
roomCode,
|
||||||
timersRunning: this.#animationTimers.length > 0,
|
timersRunning: this.#animationTimers.length > 0,
|
||||||
|
meterFill: Math.round(this.#meterFill * 100),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,9 +209,9 @@ export class RoomCodeDisplay {
|
|||||||
codePart2.style.fontSize = `${inputs.size.value}px`;
|
codePart2.style.fontSize = `${inputs.size.value}px`;
|
||||||
|
|
||||||
header.textContent = inputs.headerText.value;
|
header.textContent = inputs.headerText.value;
|
||||||
header.style.color = inputs.headerColor.value;
|
this.#applyMeterGradient();
|
||||||
header.style.fontSize = `${inputs.headerSize.value}px`;
|
header.style.fontSize = `${inputs.headerSize.value}px`;
|
||||||
header.style.transform = `translateY(${inputs.headerOffset.value}px)`;
|
header.style.transform = `translateX(-50%) translateY(${inputs.headerOffset.value}px)`;
|
||||||
|
|
||||||
footer.textContent = inputs.footerText.value;
|
footer.textContent = inputs.footerText.value;
|
||||||
footer.style.color = inputs.footerColor.value;
|
footer.style.color = inputs.footerColor.value;
|
||||||
@@ -279,4 +313,74 @@ export class RoomCodeDisplay {
|
|||||||
}, cycleDuration),
|
}, 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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Central overlay state machine: coordinates room/game lifecycle and registered UI components.
|
* Central overlay state machine: coordinates room/game lifecycle and registered UI components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const OVERRIDE_MODES = Object.freeze({
|
const OVERRIDE_MODES = Object.freeze({
|
||||||
AUTO: 'auto',
|
AUTO: 'auto',
|
||||||
FORCE_SHOW: 'force_show',
|
FORCE_SHOW: 'force_show',
|
||||||
FORCE_HIDE: 'force_hide',
|
FORCE_HIDE: 'force_hide',
|
||||||
@@ -34,7 +34,7 @@ function shallowClone(obj) {
|
|||||||
* @property {() => object} getStatus
|
* @property {() => object} getStatus
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class OverlayManager {
|
class OverlayManager {
|
||||||
/** @type {OverlayState} */
|
/** @type {OverlayState} */
|
||||||
#state = 'idle';
|
#state = 'idle';
|
||||||
|
|
||||||
@@ -217,6 +217,15 @@ export class OverlayManager {
|
|||||||
this.#transitionTo('idle');
|
this.#transitionTo('idle');
|
||||||
break;
|
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':
|
case 'player-count.updated':
|
||||||
this.#applyPlayerCountUpdated(d);
|
this.#applyPlayerCountUpdated(d);
|
||||||
this.#broadcastUpdate();
|
this.#broadcastUpdate();
|
||||||
@@ -286,6 +295,8 @@ export class OverlayManager {
|
|||||||
const code =
|
const code =
|
||||||
game.room_code ?? game.roomCode ?? game.code;
|
game.room_code ?? game.roomCode ?? game.code;
|
||||||
if (code != null) this.#context.roomCode = String(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 };
|
this.#context.game = { ...game };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,8 +317,8 @@ export class OverlayManager {
|
|||||||
if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers);
|
if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers);
|
||||||
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
|
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
|
||||||
if (Array.isArray(d.players)) this.#context.players = [...d.players];
|
if (Array.isArray(d.players)) this.#context.players = [...d.players];
|
||||||
if (d.player !== undefined) this.#context.lastJoinedPlayer = d.player;
|
const joined = d.playerName ?? d.player ?? d.lastJoinedPlayer;
|
||||||
if (d.lastJoinedPlayer !== undefined) this.#context.lastJoinedPlayer = d.lastJoinedPlayer;
|
if (joined !== undefined) this.#context.lastJoinedPlayer = joined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,6 +329,17 @@ export class OverlayManager {
|
|||||||
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
|
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
|
* @param {Record<string, unknown>} d
|
||||||
*/
|
*/
|
||||||
@@ -331,7 +353,7 @@ export class OverlayManager {
|
|||||||
*/
|
*/
|
||||||
#transitionTo(next) {
|
#transitionTo(next) {
|
||||||
const current = this.#state;
|
const current = this.#state;
|
||||||
if (!VALID_TRANSITIONS.get(current)?.has(next)) {
|
if (!VALID_TRANSITIONS[current]?.has(next)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,3 +414,7 @@ export class OverlayManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.OBS = window.OBS || {};
|
||||||
|
window.OBS.OVERRIDE_MODES = OVERRIDE_MODES;
|
||||||
|
window.OBS.OverlayManager = OverlayManager;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
|||||||
* @typedef {'connecting'|'connected'|'disconnected'|'error'} WsConnectionState
|
* @typedef {'connecting'|'connected'|'disconnected'|'error'} WsConnectionState
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class WebSocketClient {
|
class WebSocketClient {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
const {
|
const {
|
||||||
onStatusChange = () => {},
|
onStatusChange = () => {},
|
||||||
@@ -307,31 +307,125 @@ export class WebSocketClient {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
const session = await response.json();
|
|
||||||
if (session && session.id !== undefined && session.id !== null) {
|
|
||||||
console.log(
|
|
||||||
'[WebSocketClient] Found active session:',
|
|
||||||
session.id,
|
|
||||||
'— subscribing',
|
|
||||||
);
|
|
||||||
this.subscribeToSession(session.id);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
'[WebSocketClient] No active session found; waiting for session.started',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(
|
console.log(
|
||||||
'[WebSocketClient] Could not fetch active session:',
|
'[WebSocketClient] Could not fetch active session:',
|
||||||
response.status,
|
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) {
|
} catch (err) {
|
||||||
console.error('[WebSocketClient] Error fetching active session:', 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() {
|
_startHeartbeat() {
|
||||||
this._stopHeartbeat();
|
this._stopHeartbeat();
|
||||||
this._heartbeatInterval = setInterval(() => {
|
this._heartbeatInterval = setInterval(() => {
|
||||||
@@ -372,3 +466,6 @@ export class WebSocketClient {
|
|||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.OBS = window.OBS || {};
|
||||||
|
window.OBS.WebSocketClient = WebSocketClient;
|
||||||
|
|||||||
@@ -29,16 +29,30 @@
|
|||||||
|
|
||||||
#header {
|
#header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: fit-content;
|
||||||
text-align: center;
|
left: 50%;
|
||||||
transform: translateY(-220px);
|
transform: translateX(-50%) translateY(-220px);
|
||||||
color: #f35dcb;
|
color: #f35dcb;
|
||||||
font-size: 80px;
|
font-size: 80px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: 3px 3px 8px rgba(0, 0, 0, 0.8);
|
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.5s ease;
|
transition: opacity 0.5s ease;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
#footer {
|
#footer {
|
||||||
@@ -317,14 +331,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#player-list-container.player-list-position-left {
|
#player-list-container.player-list-position-left {
|
||||||
right: 55%;
|
left: 24px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-list-container.player-list-position-right {
|
#player-list-container.player-list-position-right {
|
||||||
left: 55%;
|
right: 24px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -353,12 +367,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-slot-name.empty {
|
.player-slot-name.empty {
|
||||||
color: rgba(255,255,255,0.25);
|
color: transparent;
|
||||||
font-style: italic;
|
font-style: normal;
|
||||||
|
border-bottom: 2px dashed rgba(255,255,255,0.2);
|
||||||
|
min-width: 120px;
|
||||||
|
line-height: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-slot-name.filled {
|
.player-slot-name.filled {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
border-bottom: none;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Debug dashboard */
|
/* Debug dashboard */
|
||||||
@@ -666,8 +685,8 @@
|
|||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
<label>Position:</label>
|
<label>Position:</label>
|
||||||
<select id="player-list-position">
|
<select id="player-list-position">
|
||||||
<option value="left">Left of Code</option>
|
<option value="left">Left Side</option>
|
||||||
<option value="right">Right of Code</option>
|
<option value="right">Right Side</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
@@ -686,6 +705,10 @@
|
|||||||
<label>Vertical Offset:</label>
|
<label>Vertical Offset:</label>
|
||||||
<input type="number" id="player-list-offset" value="0" step="10">
|
<input type="number" id="player-list-offset" value="0" step="10">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<label>Horizontal Offset:</label>
|
||||||
|
<input type="number" id="player-list-offset-x" value="0" step="10">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -783,17 +806,19 @@
|
|||||||
<button id="toggle-display-btn">Hide Display</button>
|
<button id="toggle-display-btn">Hide Display</button>
|
||||||
<button id="show-controls-btn">Show Controls</button>
|
<button id="show-controls-btn">Show Controls</button>
|
||||||
|
|
||||||
<script type="module">
|
<script src="js/state-manager.js"></script>
|
||||||
import { OverlayManager } from './js/state-manager.js';
|
<script src="js/websocket-client.js"></script>
|
||||||
import { WebSocketClient } from './js/websocket-client.js';
|
<script src="js/room-code-display.js"></script>
|
||||||
import { RoomCodeDisplay } from './js/room-code-display.js';
|
<script src="js/audio-controller.js"></script>
|
||||||
import { AudioController } from './js/audio-controller.js';
|
<script src="js/player-list.js"></script>
|
||||||
import { PlayerList } from './js/player-list.js';
|
<script src="js/controls.js"></script>
|
||||||
import { initControls, initConnectionStatusHandler } from './js/controls.js';
|
<script>
|
||||||
|
(function () {
|
||||||
|
var O = window.OBS;
|
||||||
|
|
||||||
const manager = new OverlayManager();
|
var manager = new O.OverlayManager();
|
||||||
|
|
||||||
const roomCodeDisplay = new RoomCodeDisplay();
|
var roomCodeDisplay = new O.RoomCodeDisplay();
|
||||||
roomCodeDisplay.init(
|
roomCodeDisplay.init(
|
||||||
{
|
{
|
||||||
header: document.getElementById('header'),
|
header: document.getElementById('header'),
|
||||||
@@ -834,7 +859,7 @@
|
|||||||
);
|
);
|
||||||
manager.registerComponent('roomCode', roomCodeDisplay);
|
manager.registerComponent('roomCode', roomCodeDisplay);
|
||||||
|
|
||||||
const audioController = new AudioController();
|
var audioController = new O.AudioController();
|
||||||
audioController.init(
|
audioController.init(
|
||||||
document.getElementById('theme-sound'),
|
document.getElementById('theme-sound'),
|
||||||
{
|
{
|
||||||
@@ -845,7 +870,7 @@
|
|||||||
);
|
);
|
||||||
manager.registerComponent('audio', audioController);
|
manager.registerComponent('audio', audioController);
|
||||||
|
|
||||||
const playerList = new PlayerList();
|
var playerList = new O.PlayerList();
|
||||||
playerList.init(
|
playerList.init(
|
||||||
document.getElementById('player-list-container'),
|
document.getElementById('player-list-container'),
|
||||||
{
|
{
|
||||||
@@ -855,6 +880,7 @@
|
|||||||
textColor: document.getElementById('player-list-text-color'),
|
textColor: document.getElementById('player-list-text-color'),
|
||||||
emptyColor: document.getElementById('player-list-empty-color'),
|
emptyColor: document.getElementById('player-list-empty-color'),
|
||||||
offset: document.getElementById('player-list-offset'),
|
offset: document.getElementById('player-list-offset'),
|
||||||
|
offsetX: document.getElementById('player-list-offset-x'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerAppearDelay: document.getElementById('header-appear-delay'),
|
headerAppearDelay: document.getElementById('header-appear-delay'),
|
||||||
@@ -863,33 +889,34 @@
|
|||||||
);
|
);
|
||||||
manager.registerComponent('playerList', playerList);
|
manager.registerComponent('playerList', playerList);
|
||||||
|
|
||||||
const statusHandler = initConnectionStatusHandler();
|
var statusHandler = O.initConnectionStatusHandler();
|
||||||
const wsClient = new WebSocketClient({
|
var wsClient = new O.WebSocketClient({
|
||||||
onStatusChange: statusHandler,
|
onStatusChange: statusHandler,
|
||||||
onEvent: (type, data) => manager.handleEvent(type, data),
|
onEvent: function (type, data) { manager.handleEvent(type, data); },
|
||||||
onSessionSubscribed: (sessionId) => {
|
onSessionSubscribed: function (sessionId) {
|
||||||
console.log('[Overlay] Subscribed to session:', sessionId);
|
console.log('[Overlay] Subscribed to session:', sessionId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
initControls(manager, wsClient, {
|
O.initControls(manager, wsClient, {
|
||||||
roomCode: roomCodeDisplay,
|
roomCode: roomCodeDisplay,
|
||||||
audio: audioController,
|
audio: audioController,
|
||||||
playerList: playerList,
|
playerList: playerList,
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedUrl = localStorage.getItem('jackbox-api-url');
|
var savedUrl = localStorage.getItem('jackbox-api-url');
|
||||||
const savedKey = localStorage.getItem('jackbox-api-key');
|
var savedKey = localStorage.getItem('jackbox-api-key');
|
||||||
const apiUrlInput = document.getElementById('api-url-input');
|
var apiUrlInput = document.getElementById('api-url-input');
|
||||||
const apiKeyInput = document.getElementById('api-key-input');
|
var apiKeyInput = document.getElementById('api-key-input');
|
||||||
if (savedUrl && apiUrlInput) apiUrlInput.value = savedUrl;
|
if (savedUrl && apiUrlInput) apiUrlInput.value = savedUrl;
|
||||||
if (savedKey && apiKeyInput) apiKeyInput.value = savedKey;
|
if (savedKey && apiKeyInput) apiKeyInput.value = savedKey;
|
||||||
|
|
||||||
if (savedUrl && savedKey) {
|
if (savedUrl && savedKey) {
|
||||||
setTimeout(() => wsClient.connect(savedUrl, savedKey), 500);
|
setTimeout(function () { wsClient.connect(savedUrl, savedKey); }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__overlay = { manager, wsClient, roomCodeDisplay, audioController, playerList };
|
window.__overlay = { manager: manager, wsClient: wsClient, roomCodeDisplay: roomCodeDisplay, audioController: audioController, playerList: playerList };
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user