marquee: use smoothstep for speed ramping

Hunter
Wednesday, April 29th, 2026 at 5:44 PM

resources/script.js+50-72

resources/script.js
433  
434  function updateCurrentTrackDisplay(text) {
435  	currentTrackDisplay.innerHTML = `<span>${text}</span>`;
    -	// Initialize marquee effect after a brief delay to ensure DOM is updated
    -	setTimeout(() => setupMarquee(), 50);
436 +	setupMarquee();
437  }
438  
439  // Marquee state

443  let marqueeOriginalText = '';
444  let marqueeHalfWidth = 0;
445  let marqueeRafId = null;
    -let marqueeStartTime = 0;
    -let marqueeElapsedBeforePause = 0;
446 +let marqueeOffset = 0; // current displayed translateX, in px (persists across pause/resize)
447 +let marqueeRampStart = 0; // performance.now() when the current ease-in ramp began
448 +let marqueeLastFrame = 0; // performance.now() of the previous animation frame
449  const marqueeSpeed = 50; // pixels per second
450 +const marqueeRampDuration = 2000; // ms to ease from 0 up to full speed
451  
    -function setupMarquee() {
452 +function setupMarquee({ preserveOffset = false } = {}) {
453  	const container = currentTrackDisplay;
454  	const textSpan = container.querySelector('span');
455  
456  	if (!textSpan) return;
457  
    -	// Clear any existing animation
458  	if (marqueeTimeoutId) {
459  		clearTimeout(marqueeTimeoutId);
460  		marqueeTimeoutId = null;

464  		marqueeRafId = null;
465  	}
466  	marqueeAnimating = false;
    -	marqueePaused = false;
    -	marqueeElapsedBeforePause = 0;
467 +	if (!preserveOffset) {
468 +		marqueeOffset = 0;
469 +		marqueePaused = false;
470 +	}
471  
    -	// Reset styles
    -	textSpan.style.transform = 'translateX(0)';
    -	textSpan.style.animation = 'none';
472  	container.classList.remove('no-overflow');
    -
    -	// Store original text
473  	marqueeOriginalText = textSpan.textContent;
474  
    -	// Force reflow
    -	void textSpan.offsetWidth;
    -
    -	// Check if text overflows
475  	const containerWidth = container.offsetWidth - 30; // Account for padding
    -	const textWidth = textSpan.scrollWidth;
    -	const overflows = textWidth > containerWidth;
476 +	const overflows = textSpan.scrollWidth > containerWidth;
477  
478  	if (!overflows) {
    -		// No overflow - show ellipsis behavior
479 +		textSpan.style.transform = 'translateX(0)';
480  		container.classList.add('no-overflow');
    -		container.classList.remove('marquee-active');
481 +		marqueeOffset = 0;
482  		return;
483  	}
484  
    -	// Text overflows - setup marquee
485  	marqueeAnimating = true;
    -	container.classList.add('marquee-active');
486  
    -	// 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
487 +	// Duplicate text for a seamless loop. Non-breaking spaces so HTML doesn't collapse them.
488 +	const spacing = '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0';
489  	textSpan.textContent = marqueeOriginalText + spacing + marqueeOriginalText + spacing;
490 +	marqueeHalfWidth = textSpan.scrollWidth / 2;
491  
    -	// 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
    -	}
492 +	marqueeOffset = marqueeOffset % marqueeHalfWidth;
493 +	textSpan.style.transform = `translateX(-${marqueeOffset}px)`;
494 +
495 +	if (marqueePaused) return;
496 +	marqueeTimeoutId = setTimeout(startMarqueeMotion, 1000);
497 +}
498 +
499 +function startMarqueeMotion() {
500 +	marqueeRampStart = performance.now();
501 +	marqueeLastFrame = marqueeRampStart;
502 +	marqueeRafId = requestAnimationFrame(marqueeStep);
503  }
504  
505  function marqueeStep(now) {
506  	if (!marqueeAnimating || marqueePaused) return;
507  
    -	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;
508 +	const dt = (now - marqueeLastFrame) / 1000;
509 +	marqueeLastFrame = now;
510 +
511 +	const rampElapsed = now - marqueeRampStart;
512 +	const t = Math.min(rampElapsed / marqueeRampDuration, 1);
513 +	const speed = marqueeSpeed * (t * t * (3 - 2 * t));
514 +
515 +	marqueeOffset = (marqueeOffset + speed * dt) % marqueeHalfWidth;
516  
517  	const textSpan = currentTrackDisplay.querySelector('span');
518  	if (textSpan) {
    -		textSpan.style.transform = `translateX(-${offset}px)`;
519 +		textSpan.style.transform = `translateX(-${marqueeOffset}px)`;
520  	}
521  
522  	marqueeRafId = requestAnimationFrame(marqueeStep);
523  }
524  
525  function pauseMarquee() {
    -	marqueePaused = true;
526  	if (!marqueeAnimating) return;
    -
    -	// Accumulate elapsed time before this pause
    -	marqueeElapsedBeforePause += performance.now() - marqueeStartTime;
527 +	marqueePaused = true;
528  
529  	if (marqueeRafId) {
530  		cancelAnimationFrame(marqueeRafId);
531  		marqueeRafId = null;
532  	}
    -
    -	// Clear any pending timeout (for initial delay)
533  	if (marqueeTimeoutId) {
534  		clearTimeout(marqueeTimeoutId);
535  		marqueeTimeoutId = null;

539  function resumeMarquee() {
540  	if (!marqueeAnimating) return;
541  	marqueePaused = false;
    -
    -	const textSpan = currentTrackDisplay.querySelector('span');
    -	if (!textSpan) return;
    -
    -	// Resume from where we left off
    -	marqueeStartTime = performance.now();
    -	marqueeRafId = requestAnimationFrame(marqueeStep);
542 +	startMarqueeMotion();
543  }
544  
    -// Add window resize listener to recalculate marquee
545  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;
    -		}
546 +	if (!marqueeOriginalText) return;
547 +	const textSpan = currentTrackDisplay.querySelector('span');
548 +	if (!textSpan) return;
549  
    -		setupMarquee();
550 +	const prevText = textSpan.textContent;
551 +	textSpan.textContent = marqueeOriginalText;
552 +	const containerWidth = currentTrackDisplay.offsetWidth - 30;
553 +	const overflowsNow = textSpan.scrollWidth > containerWidth;
554  
    -		// Restore paused state after recalculation
    -		if (wasPaused) {
    -			marqueePaused = true;
    -		}
555 +	if (overflowsNow === marqueeAnimating) {
556 +		textSpan.textContent = prevText;
557 +		return;
558  	}
559 +
560 +	setupMarquee({ preserveOffset: true });
561  });
562  
563  function togglePlayPause() {