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
421 lines
12 KiB
JavaScript
421 lines
12 KiB
JavaScript
/**
|
|
* 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;
|