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
387 lines
11 KiB
JavaScript
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;
|