/** * Room code lobby display: staggered opacity animation for header, footer, and two code lines. * Implements the overlay component contract: activate, deactivate, update, getStatus. */ /** * @typedef {object} RoomCodeDisplayElements * @property {HTMLElement} header * @property {HTMLElement} footer * @property {HTMLElement} codePart1 * @property {HTMLElement} codePart2 */ /** * @typedef {object} RoomCodeDisplayInputs * @property {HTMLInputElement} code1 * @property {HTMLInputElement} code2 * @property {HTMLInputElement} color1 * @property {HTMLInputElement} color2 * @property {HTMLInputElement} offset1 * @property {HTMLInputElement} offset2 * @property {HTMLInputElement} size * @property {HTMLInputElement} cycle * @property {HTMLInputElement} headerText * @property {HTMLInputElement} headerColor * @property {HTMLInputElement} headerSize * @property {HTMLInputElement} headerOffset * @property {HTMLInputElement} footerText * @property {HTMLInputElement} footerColor * @property {HTMLInputElement} footerSize * @property {HTMLInputElement} footerOffset * @property {HTMLInputElement} headerAppearDelay * @property {HTMLInputElement} headerAppearDuration * @property {HTMLInputElement} headerHideTime * @property {HTMLInputElement} headerHideDuration * @property {HTMLInputElement} line1AppearDelay * @property {HTMLInputElement} line1AppearDuration * @property {HTMLInputElement} line1HideTime * @property {HTMLInputElement} line1HideDuration * @property {HTMLInputElement} line2AppearDelay * @property {HTMLInputElement} line2AppearDuration * @property {HTMLInputElement} line2HideTime * @property {HTMLInputElement} line2HideDuration */ class RoomCodeDisplay { /** @type {RoomCodeDisplayElements | null} */ #elements = null; /** @type {RoomCodeDisplayInputs | null} */ #inputs = null; /** @type {ReturnType[]} */ #animationTimers = []; /** @type {boolean} */ #active = false; /** @type {number} */ #meterFill = 0; /** @type {number} */ #meterTarget = 0; /** @type {number | null} */ #meterRafId = null; /** * @param {RoomCodeDisplayElements} elements * @param {RoomCodeDisplayInputs} inputs */ init(elements, inputs) { this.#elements = elements; this.#inputs = inputs; } /** * @param {{ roomCode?: string, [key: string]: unknown }} ctx */ activate(ctx) { this.deactivate(); const inputs = this.#inputs; if (!inputs) { return; } this.#active = true; if (ctx?.roomCode != null && String(ctx.roomCode).trim().length > 0) { const raw = String(ctx.roomCode).trim().toUpperCase(); const mid = Math.floor(raw.length / 2); inputs.code1.value = raw.slice(0, mid); inputs.code2.value = raw.slice(mid); } else { inputs.code1.value = ''; inputs.code2.value = ''; } this.#applySettings(); const count = Number(ctx?.playerCount ?? 0); const max = Number(ctx?.maxPlayers ?? 0); const initialFill = max > 0 ? count / max : 0; this.#meterFill = Math.max(0, Math.min(1, initialFill)); this.#meterTarget = this.#meterFill; this.#applyMeterGradient(); this.#startAnimation(); } deactivate() { this.#active = false; this.#clearAnimationTimers(); if (this.#meterRafId != null) { cancelAnimationFrame(this.#meterRafId); this.#meterRafId = null; } this.#meterFill = 0; this.#meterTarget = 0; const { header, footer, codePart1, codePart2 } = this.#elements ?? {}; if (!header || !footer || !codePart1 || !codePart2) { return; } const fadeTime = '0.3s'; header.style.transition = `opacity ${fadeTime} ease-out`; footer.style.transition = `opacity ${fadeTime} ease-out`; codePart1.style.transition = `opacity ${fadeTime} ease-out`; codePart2.style.transition = `opacity ${fadeTime} ease-out`; header.style.opacity = 0; footer.style.opacity = 0; codePart1.style.opacity = 0; codePart2.style.opacity = 0; } /** * @param {{ roomCode?: string, [key: string]: unknown }} ctx */ update(ctx) { if (!this.#active || !this.#inputs) { return; } const next = ctx?.roomCode != null ? String(ctx.roomCode).trim().toUpperCase() : ''; const current = this.#inputs.code1.value.toUpperCase() + this.#inputs.code2.value.toUpperCase(); if (next && next !== current) { this.activate(ctx); return; } const count = Number(ctx?.playerCount ?? 0); const max = Number(ctx?.maxPlayers ?? 0); const newTarget = max > 0 ? count / max : 0; if (Math.abs(newTarget - this.#meterTarget) > 0.001) { this.#animateMeterTo(newTarget); } } getStatus() { const inputs = this.#inputs; const roomCode = inputs ? (inputs.code1.value + inputs.code2.value).toUpperCase() : ''; return { active: this.#active, roomCode, timersRunning: this.#animationTimers.length > 0, meterFill: Math.round(this.#meterFill * 100), }; } #clearAnimationTimers() { this.#animationTimers.forEach((id) => clearTimeout(id)); this.#animationTimers = []; } #applySettings() { const el = this.#elements; const inputs = this.#inputs; if (!el || !inputs) { return; } const { header, footer, codePart1, codePart2, } = el; codePart1.textContent = inputs.code1.value.toUpperCase(); codePart2.textContent = inputs.code2.value.toUpperCase(); codePart1.style.color = inputs.color1.value; codePart2.style.color = inputs.color2.value; codePart1.style.transform = `translateY(${inputs.offset1.value}px)`; codePart2.style.transform = `translateY(${inputs.offset2.value}px)`; codePart1.style.fontSize = `${inputs.size.value}px`; codePart2.style.fontSize = `${inputs.size.value}px`; header.textContent = inputs.headerText.value; this.#applyMeterGradient(); header.style.fontSize = `${inputs.headerSize.value}px`; header.style.transform = `translateX(-50%) translateY(${inputs.headerOffset.value}px)`; footer.textContent = inputs.footerText.value; footer.style.color = inputs.footerColor.value; footer.style.fontSize = `${inputs.footerSize.value}px`; footer.style.transform = `translateY(${inputs.footerOffset.value}px)`; } #startAnimation() { const el = this.#elements; const inputs = this.#inputs; if (!el || !inputs) { return; } const { header, footer, codePart1, codePart2 } = el; this.#clearAnimationTimers(); header.style.opacity = 0; footer.style.opacity = 0; codePart1.style.opacity = 0; codePart2.style.opacity = 0; const sec = (input) => parseInt(input.value, 10) * 1000; const cycleDuration = sec(inputs.cycle); const headerAppearDelayMs = sec(inputs.headerAppearDelay); const headerAppearDurationMs = sec(inputs.headerAppearDuration); this.#animationTimers.push( setTimeout(() => { header.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`; header.style.opacity = 1; footer.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`; footer.style.opacity = 1; }, headerAppearDelayMs), ); const line1AppearDelayMs = sec(inputs.line1AppearDelay); const line1AppearDurationMs = sec(inputs.line1AppearDuration); this.#animationTimers.push( setTimeout(() => { codePart1.style.transition = `opacity ${line1AppearDurationMs / 1000}s ease-out`; codePart1.style.opacity = 1; }, line1AppearDelayMs), ); const line1HideTimeMs = sec(inputs.line1HideTime); const line1HideDurationMs = sec(inputs.line1HideDuration); this.#animationTimers.push( setTimeout(() => { codePart1.style.transition = `opacity ${line1HideDurationMs / 1000}s ease-out`; codePart1.style.opacity = 0; }, line1HideTimeMs), ); const line2AppearDelayMs = sec(inputs.line2AppearDelay); const line2AppearDurationMs = sec(inputs.line2AppearDuration); this.#animationTimers.push( setTimeout(() => { codePart2.style.transition = `opacity ${line2AppearDurationMs / 1000}s ease-out`; codePart2.style.opacity = 1; }, line2AppearDelayMs), ); const line2HideTimeMs = sec(inputs.line2HideTime); const line2HideDurationMs = sec(inputs.line2HideDuration); this.#animationTimers.push( setTimeout(() => { codePart2.style.transition = `opacity ${line2HideDurationMs / 1000}s ease-out`; codePart2.style.opacity = 0; }, line2HideTimeMs), ); const headerHideTimeMs = sec(inputs.headerHideTime); const headerHideDurationMs = sec(inputs.headerHideDuration); this.#animationTimers.push( setTimeout(() => { header.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`; header.style.opacity = 0; footer.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`; footer.style.opacity = 0; }, headerHideTimeMs), ); this.#animationTimers.push( setTimeout(() => { if (this.#active) { this.#startAnimation(); } }, cycleDuration), ); } #applyMeterGradient() { const header = this.#elements?.header; const inputs = this.#inputs; if (!header || !inputs) return; const pct = Math.round(this.#meterFill * 100); const baseColor = inputs.headerColor.value || '#f35dcb'; header.style.background = `linear-gradient(to right, #ffffff 0%, #ffffff ${pct}%, ${baseColor} ${pct}%, ${baseColor} 100%)`; header.style.webkitBackgroundClip = 'text'; header.style.backgroundClip = 'text'; header.style.webkitTextFillColor = 'transparent'; } #animateMeterTo(target) { const clamped = Math.max(0, Math.min(1, target)); this.#meterTarget = clamped; if (this.#meterRafId != null) { cancelAnimationFrame(this.#meterRafId); this.#meterRafId = null; } const start = this.#meterFill; const delta = clamped - start; if (Math.abs(delta) < 0.001) { this.#meterFill = clamped; this.#applyMeterGradient(); this.#checkFullPulse(); return; } const duration = 400; const startTime = performance.now(); const step = (now) => { const elapsed = now - startTime; const t = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - t, 3); this.#meterFill = start + delta * eased; this.#applyMeterGradient(); if (t < 1) { this.#meterRafId = requestAnimationFrame(step); } else { this.#meterRafId = null; this.#meterFill = clamped; this.#applyMeterGradient(); this.#checkFullPulse(); } }; this.#meterRafId = requestAnimationFrame(step); } #checkFullPulse() { const header = this.#elements?.header; if (!header) return; if (this.#meterFill >= 1) { header.classList.add('meter-full-pulse'); } else { header.classList.remove('meter-full-pulse'); } } } window.OBS = window.OBS || {}; window.OBS.RoomCodeDisplay = RoomCodeDisplay;