Files
OBS-overlay/js/player-list.js
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

321 lines
9.1 KiB
JavaScript

/**
* 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;