cleanly loop marquee when title overflows

Hunter
Tuesday, March 24th, 2026 at 12:54 AM

resources/script.js+37-60

resources/script.js
337  let marqueePaused = false;
338  let marqueeTimeoutId = null;
339  let marqueeOriginalText = '';
    -let marqueeDuration = 0;
340  let marqueeHalfWidth = 0;
    -let marqueeTransitionEndHandler = null;
    -let marqueeCurrentTransform = 'translateX(0)';
341 +let marqueeRafId = null;
342 +let marqueeStartTime = 0;
343 +let marqueeElapsedBeforePause = 0;
344 +const marqueeSpeed = 50; // pixels per second
345  
346  function setupMarquee() {
347  	const container = currentSongDisplay;

354  		clearTimeout(marqueeTimeoutId);
355  		marqueeTimeoutId = null;
356  	}
    -	if (marqueeTransitionEndHandler) {
    -		textSpan.removeEventListener('transitionend', marqueeTransitionEndHandler);
    -		marqueeTransitionEndHandler = null;
357 +	if (marqueeRafId) {
358 +		cancelAnimationFrame(marqueeRafId);
359 +		marqueeRafId = null;
360  	}
361  	marqueeAnimating = false;
362  	marqueePaused = false;
363 +	marqueeElapsedBeforePause = 0;
364  
365  	// Reset styles
    -	textSpan.style.transition = 'none';
366  	textSpan.style.transform = 'translateX(0)';
    -	marqueeCurrentTransform = 'translateX(0)';
367 +	textSpan.style.animation = 'none';
368  	container.classList.remove('no-overflow');
369  
370  	// Store original text

397  	// Calculate animation parameters
398  	const fullWidth = textSpan.scrollWidth;
399  	marqueeHalfWidth = fullWidth / 2;
    -	marqueeDuration = (marqueeHalfWidth / 50) * 1000; // Adjust speed here (pixels per second)
400  
401  	// Start animation after a brief delay (only if not paused)
    -	// Don't start animation if already paused
402  	if (!marqueePaused) {
403  		marqueeTimeoutId = setTimeout(() => {
404  			if (marqueePaused) return;
405 +			marqueeStartTime = performance.now();
406 +			marqueeElapsedBeforePause = 0;
407 +			marqueeRafId = requestAnimationFrame(marqueeStep);
408 +		}, 1000); // Initial delay before starting scroll
409 +	}
410 +}
411  
    -			textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
    -			textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
    -			marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
    -
    -			// Reset and loop
    -			marqueeTransitionEndHandler = () => {
    -				if (!marqueeAnimating || marqueePaused) return;
412 +function marqueeStep(now) {
413 +	if (!marqueeAnimating || marqueePaused) return;
414  
    -				textSpan.style.transition = 'none';
    -				textSpan.style.transform = 'translateX(0)';
    -				marqueeCurrentTransform = 'translateX(0)';
415 +	const elapsed = marqueeElapsedBeforePause + (now - marqueeStartTime);
416 +	const totalDistance = elapsed * marqueeSpeed / 1000;
417 +	// Modulo by one segment width for seamless wrapping — no loop boundary
418 +	const offset = totalDistance % marqueeHalfWidth;
419  
    -				setTimeout(() => {
    -					if (!marqueeAnimating || marqueePaused) return;
    -					textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
    -					textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
    -					marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
    -				}, 50);
    -			};
    -
    -			textSpan.addEventListener('transitionend', marqueeTransitionEndHandler);
    -		}, 1000); // Initial delay before starting scroll
420 +	const textSpan = currentSongDisplay.querySelector('span');
421 +	if (textSpan) {
422 +		textSpan.style.transform = `translateX(-${offset}px)`;
423  	}
424 +
425 +	marqueeRafId = requestAnimationFrame(marqueeStep);
426  }
427  
428  function pauseMarquee() {
429  	marqueePaused = true;
    -	const textSpan = currentSongDisplay.querySelector('span');
    -	if (!textSpan || !marqueeAnimating) return;
430 +	if (!marqueeAnimating) return;
431  
    -	// Capture current transform position
    -	const computedStyle = window.getComputedStyle(textSpan);
    -	const currentTransform = computedStyle.transform;
    -	marqueeCurrentTransform = currentTransform;
432 +	// Accumulate elapsed time before this pause
433 +	marqueeElapsedBeforePause += performance.now() - marqueeStartTime;
434  
    -	// Freeze at current position
    -	textSpan.style.transition = 'none';
    -	textSpan.style.transform = currentTransform;
435 +	if (marqueeRafId) {
436 +		cancelAnimationFrame(marqueeRafId);
437 +		marqueeRafId = null;
438 +	}
439  
    -	// Clear any pending timeout
440 +	// Clear any pending timeout (for initial delay)
441  	if (marqueeTimeoutId) {
442  		clearTimeout(marqueeTimeoutId);
443  		marqueeTimeoutId = null;

451  	const textSpan = currentSongDisplay.querySelector('span');
452  	if (!textSpan) return;
453  
    -	// Apply the stored transform position first
    -	textSpan.style.transition = 'none';
    -	textSpan.style.transform = marqueeCurrentTransform;
    -
    -	// Force reflow to apply the transform
    -	void textSpan.offsetWidth;
    -
    -	// Get current position from the stored transform
    -	const matrix = new DOMMatrix(marqueeCurrentTransform);
    -	const currentX = matrix.m41;
    -
    -	// Calculate remaining distance and time
    -	const distanceTraveled = Math.abs(currentX);
    -	const percentComplete = distanceTraveled / marqueeHalfWidth;
    -	const timeRemaining = marqueeDuration * (1 - percentComplete);
    -
    -	// Resume animation from current position
    -	textSpan.style.transition = `transform ${timeRemaining}ms linear`;
    -	textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
    -	marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
454 +	// Resume from where we left off
455 +	marqueeStartTime = performance.now();
456 +	marqueeRafId = requestAnimationFrame(marqueeStep);
457  }
458  
459  // Add window resize listener to recalculate marquee