Compare commits

..

3 Commits

Author SHA1 Message Date
cottongin
18d66c2dba fix: meter gradient now scales correctly across resolutions
The header element was width:100% so the gradient filled relative to
the viewport, not the visible text. With centered text, low fill
percentages fell entirely outside the text bounds — making the first
player (12.5%) invisible and causing shifts at different resolutions.
Changed to width:fit-content with translateX(-50%) centering so the
gradient maps 1:1 to the text content.

Made-with: Cursor
2026-03-20 23:06:45 -04:00
cottongin
fa7363bc78 fix: convert ES modules to classic scripts for file:// compatibility
Browsers block ES module imports over the file:// protocol due to CORS.
Users opening the overlay by double-clicking the HTML file saw all JS
fail to load. Replace import/export with a window.OBS global namespace
and classic <script> tags so the overlay works without a local server.

Made-with: Cursor
2026-03-20 22:20:12 -04:00
cottongin
a8b7df48a6 fix: restore lobby state on refresh, handle game.status heartbeat
- Extract maxPlayers from game object in #applyGameAdded so the meter
  works immediately when a game is added
- Read playerName field in lobby.player-joined (matches API payload)
- Handle game.status 20s heartbeat to keep overlay in sync
- Restore in-progress game on page refresh using status-live endpoint
  for full shard state including player names

Made-with: Cursor
2026-03-20 17:55:07 -04:00
7 changed files with 191 additions and 54 deletions

View File

@@ -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;

12
js/controls.js vendored
View File

@@ -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, ' ');
@@ -335,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');
@@ -360,3 +358,7 @@ export function initConnectionStatusHandler() {
} }
}; };
} }
window.OBS = window.OBS || {};
window.OBS.initControls = initControls;
window.OBS.initConnectionStatusHandler = initConnectionStatusHandler;

View File

@@ -39,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} */
@@ -315,3 +315,6 @@ export class PlayerList {
this._animationTimers = []; this._animationTimers = [];
} }
} }
window.OBS = window.OBS || {};
window.OBS.PlayerList = PlayerList;

View File

@@ -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;
@@ -211,7 +211,7 @@ export class RoomCodeDisplay {
header.textContent = inputs.headerText.value; header.textContent = inputs.headerText.value;
this.#applyMeterGradient(); 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;
@@ -381,3 +381,6 @@ export class RoomCodeDisplay {
} }
} }
} }
window.OBS = window.OBS || {};
window.OBS.RoomCodeDisplay = RoomCodeDisplay;

View File

@@ -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
*/ */
@@ -392,3 +414,7 @@ export class OverlayManager {
} }
} }
} }
window.OBS = window.OBS || {};
window.OBS.OVERRIDE_MODES = OVERRIDE_MODES;
window.OBS.OverlayManager = OverlayManager;

View File

@@ -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;

View File

@@ -29,9 +29,9 @@
#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;
@@ -806,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'),
@@ -857,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'),
{ {
@@ -868,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'),
{ {
@@ -887,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>