Files
OBS-overlay/js/room-code-display.js
cottongin 18d66c2dba fix: meter gradient now scales correctly across resolutions
The header element was width:100% so the gradient filled relative to
the viewport, not the visible text. With centered text, low fill
percentages fell entirely outside the text bounds — making the first
player (12.5%) invisible and causing shifts at different resolutions.
Changed to width:fit-content with translateX(-50%) centering so the
gradient maps 1:1 to the text content.

Made-with: Cursor
2026-03-20 23:06:45 -04:00

387 lines
11 KiB
JavaScript

/**
* 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<typeof setTimeout>[]} */
#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;