marquee: use smoothstep for speed ramping
Hunter
Wednesday, April 29th, 2026 at 5:44 PM
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() {