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
321 lines
9.1 KiB
JavaScript
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;
|