/** * 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[]} */ this._animationTimers = []; /** @type {ReturnType | 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;