commit 3c73e4b5ab2b2986023b6c686634d40d01dff93e
parent 7d06b7602349e63a21064ed41bf83f26a7891c1f
Author: Hunter
Date:   Wed, 29 Apr 2026 17:44:42 -0400

marquee: use smoothstep for speed ramping

Diffstat:
Mresources/script.js | 122++++++++++++++++++++++++++++++++-----------------------------------------------
1 file changed, 50 insertions(+), 72 deletions(-)

diff --git a/resources/script.js b/resources/script.js @@ -433,8 +433,7 @@ function playTrack(index) { function updateCurrentTrackDisplay(text) { currentTrackDisplay.innerHTML = `<span>${text}</span>`; - // Initialize marquee effect after a brief delay to ensure DOM is updated - setTimeout(() => setupMarquee(), 50); + setupMarquee(); } // Marquee state @@ -444,17 +443,18 @@ let marqueeTimeoutId = null; let marqueeOriginalText = ''; let marqueeHalfWidth = 0; let marqueeRafId = null; -let marqueeStartTime = 0; -let marqueeElapsedBeforePause = 0; +let marqueeOffset = 0; // current displayed translateX, in px (persists across pause/resize) +let marqueeRampStart = 0; // performance.now() when the current ease-in ramp began +let marqueeLastFrame = 0; // performance.now() of the previous animation frame const marqueeSpeed = 50; // pixels per second +const marqueeRampDuration = 2000; // ms to ease from 0 up to full speed -function setupMarquee() { +function setupMarquee({ preserveOffset = false } = {}) { const container = currentTrackDisplay; const textSpan = container.querySelector('span'); if (!textSpan) return; - // Clear any existing animation if (marqueeTimeoutId) { clearTimeout(marqueeTimeoutId); marqueeTimeoutId = null; @@ -464,85 +464,72 @@ function setupMarquee() { marqueeRafId = null; } marqueeAnimating = false; - marqueePaused = false; - marqueeElapsedBeforePause = 0; + if (!preserveOffset) { + marqueeOffset = 0; + marqueePaused = false; + } - // Reset styles - textSpan.style.transform = 'translateX(0)'; - textSpan.style.animation = 'none'; container.classList.remove('no-overflow'); - - // Store original text marqueeOriginalText = textSpan.textContent; - // Force reflow - void textSpan.offsetWidth; - - // Check if text overflows const containerWidth = container.offsetWidth - 30; // Account for padding - const textWidth = textSpan.scrollWidth; - const overflows = textWidth > containerWidth; + const overflows = textSpan.scrollWidth > containerWidth; if (!overflows) { - // No overflow - show ellipsis behavior + textSpan.style.transform = 'translateX(0)'; container.classList.add('no-overflow'); - container.classList.remove('marquee-active'); + marqueeOffset = 0; return; } - // Text overflows - setup marquee marqueeAnimating = true; - container.classList.add('marquee-active'); - // Add spacing and duplicate text for seamless loop - // Using non-breaking spaces (\u00A0) so they don't collapse in HTML - const spacing = '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0'; // 12 non-breaking spaces + // Duplicate text for a seamless loop. Non-breaking spaces so HTML doesn't collapse them. + const spacing = '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0'; textSpan.textContent = marqueeOriginalText + spacing + marqueeOriginalText + spacing; + marqueeHalfWidth = textSpan.scrollWidth / 2; - // Calculate animation parameters - const fullWidth = textSpan.scrollWidth; - marqueeHalfWidth = fullWidth / 2; - - // Start animation after a brief delay (only if not paused) - if (!marqueePaused) { - marqueeTimeoutId = setTimeout(() => { - if (marqueePaused) return; - marqueeStartTime = performance.now(); - marqueeElapsedBeforePause = 0; - marqueeRafId = requestAnimationFrame(marqueeStep); - }, 1000); // Initial delay before starting scroll - } + marqueeOffset = marqueeOffset % marqueeHalfWidth; + textSpan.style.transform = `translateX(-${marqueeOffset}px)`; + + if (marqueePaused) return; + marqueeTimeoutId = setTimeout(startMarqueeMotion, 1000); +} + +function startMarqueeMotion() { + marqueeRampStart = performance.now(); + marqueeLastFrame = marqueeRampStart; + marqueeRafId = requestAnimationFrame(marqueeStep); } function marqueeStep(now) { if (!marqueeAnimating || marqueePaused) return; - const elapsed = marqueeElapsedBeforePause + (now - marqueeStartTime); - const totalDistance = elapsed * marqueeSpeed / 1000; - // Modulo by one segment width for seamless wrapping — no loop boundary - const offset = totalDistance % marqueeHalfWidth; + const dt = (now - marqueeLastFrame) / 1000; + marqueeLastFrame = now; + + const rampElapsed = now - marqueeRampStart; + const t = Math.min(rampElapsed / marqueeRampDuration, 1); + const speed = marqueeSpeed * (t * t * (3 - 2 * t)); + + marqueeOffset = (marqueeOffset + speed * dt) % marqueeHalfWidth; const textSpan = currentTrackDisplay.querySelector('span'); if (textSpan) { - textSpan.style.transform = `translateX(-${offset}px)`; + textSpan.style.transform = `translateX(-${marqueeOffset}px)`; } marqueeRafId = requestAnimationFrame(marqueeStep); } function pauseMarquee() { - marqueePaused = true; if (!marqueeAnimating) return; - - // Accumulate elapsed time before this pause - marqueeElapsedBeforePause += performance.now() - marqueeStartTime; + marqueePaused = true; if (marqueeRafId) { cancelAnimationFrame(marqueeRafId); marqueeRafId = null; } - - // Clear any pending timeout (for initial delay) if (marqueeTimeoutId) { clearTimeout(marqueeTimeoutId); marqueeTimeoutId = null; @@ -552,34 +539,25 @@ function pauseMarquee() { function resumeMarquee() { if (!marqueeAnimating) return; marqueePaused = false; - - const textSpan = currentTrackDisplay.querySelector('span'); - if (!textSpan) return; - - // Resume from where we left off - marqueeStartTime = performance.now(); - marqueeRafId = requestAnimationFrame(marqueeStep); + startMarqueeMotion(); } -// Add window resize listener to recalculate marquee window.addEventListener('resize', () => { - if (marqueeOriginalText) { - // Store the paused state before recalculating - const wasPaused = marqueePaused; - - // Restore original text before recalculating - const textSpan = currentTrackDisplay.querySelector('span'); - if (textSpan) { - textSpan.textContent = marqueeOriginalText; - } + if (!marqueeOriginalText) return; + const textSpan = currentTrackDisplay.querySelector('span'); + if (!textSpan) return; - setupMarquee(); + const prevText = textSpan.textContent; + textSpan.textContent = marqueeOriginalText; + const containerWidth = currentTrackDisplay.offsetWidth - 30; + const overflowsNow = textSpan.scrollWidth > containerWidth; - // Restore paused state after recalculation - if (wasPaused) { - marqueePaused = true; - } + if (overflowsNow === marqueeAnimating) { + textSpan.textContent = prevText; + return; } + + setupMarquee({ preserveOffset: true }); }); function togglePlayPause() {