Add white-space: nowrap to header, footer, and code-part elements to prevent line breaks in narrow viewports. Replace static CSS keyframes with dynamic generation so glow color, intensity, opacity, outline thickness, and pulse duration are all configurable from the controls panel. Double the default outline thickness from 4px to 8px. Co-authored-by: Cursor <cursoragent@cursor.com>
442 lines
13 KiB
JavaScript
442 lines
13 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
|
|
* @property {HTMLInputElement} glowColor
|
|
* @property {HTMLInputElement} glowIntensity
|
|
* @property {HTMLInputElement} glowOpacity
|
|
* @property {HTMLInputElement} glowWhiteIntensity
|
|
* @property {HTMLInputElement} glowOutline
|
|
* @property {HTMLInputElement} glowPulseDuration
|
|
*/
|
|
|
|
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;
|
|
|
|
/** @type {HTMLStyleElement | null} */
|
|
#glowStyleEl = null;
|
|
|
|
/**
|
|
* @param {RoomCodeDisplayElements} elements
|
|
* @param {RoomCodeDisplayInputs} inputs
|
|
*/
|
|
init(elements, inputs) {
|
|
this.#elements = elements;
|
|
this.#inputs = inputs;
|
|
|
|
if (!this.#glowStyleEl) {
|
|
this.#glowStyleEl = document.createElement('style');
|
|
this.#glowStyleEl.id = 'glow-keyframes';
|
|
document.head.appendChild(this.#glowStyleEl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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)`;
|
|
|
|
this.#rebuildGlowKeyframes();
|
|
this.#checkFullPulse();
|
|
}
|
|
|
|
#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);
|
|
}
|
|
|
|
#rebuildGlowKeyframes() {
|
|
const inputs = this.#inputs;
|
|
const style = this.#glowStyleEl;
|
|
if (!inputs || !style) return;
|
|
|
|
const outline = Number(inputs.glowOutline?.value ?? 8);
|
|
const whiteGlow = Number(inputs.glowWhiteIntensity?.value ?? 40);
|
|
const colorGlow = Number(inputs.glowIntensity?.value ?? 80);
|
|
const opacity = Number(inputs.glowOpacity?.value ?? 0.6);
|
|
const duration = Number(inputs.glowPulseDuration?.value ?? 1.2);
|
|
|
|
const glowHex = inputs.glowColor?.value ?? '#f35dcb';
|
|
const r = parseInt(glowHex.slice(1, 3), 16);
|
|
const g = parseInt(glowHex.slice(3, 5), 16);
|
|
const b = parseInt(glowHex.slice(5, 7), 16);
|
|
|
|
const outlineShadow =
|
|
`drop-shadow(0 0 ${outline}px black) drop-shadow(0 0 ${outline}px black)`;
|
|
|
|
style.textContent = `
|
|
@keyframes meter-full-pulse {
|
|
0%, 100% {
|
|
filter: ${outlineShadow} drop-shadow(0 0 0 rgba(255,255,255,0));
|
|
}
|
|
50% {
|
|
filter: ${outlineShadow}
|
|
drop-shadow(0 0 ${whiteGlow}px rgba(255,255,255,0.95))
|
|
drop-shadow(0 0 ${colorGlow}px rgba(${r},${g},${b},${opacity}));
|
|
}
|
|
}
|
|
#header.meter-full-pulse {
|
|
animation: meter-full-pulse ${duration}s ease-in-out infinite;
|
|
}
|
|
`;
|
|
}
|
|
|
|
#checkFullPulse() {
|
|
const header = this.#elements?.header;
|
|
if (!header) return;
|
|
|
|
if (this.#meterFill >= 1) {
|
|
this.#rebuildGlowKeyframes();
|
|
header.classList.add('meter-full-pulse');
|
|
} else {
|
|
header.classList.remove('meter-full-pulse');
|
|
header.style.animation = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
window.OBS = window.OBS || {};
|
|
window.OBS.RoomCodeDisplay = RoomCodeDisplay;
|