restructure

Hunter
Tuesday, November 11th, 2025 at 12:22 AM

.gitignore+1-1
generate_manifests.py+4-4
index.html+2-2
readme.md+1-1
resources/script.jsnew
resources/styles.cssnew
script.jsdeleted
styles.cssdeleted

.gitignore
 8  # Tracks and playlists
 9  tracks/*
10  !tracks/readme.md
   -resources/album_art.jpg
11 +tracks/album_art.jpg
12  
13  # macOS
14  .DS_Store
generate_manifests.py
 68  # File paths (no need to edit these)
 69  SCRIPT_DIR = Path(__file__).parent.absolute()
 70  TRACKS_JSON = SCRIPT_DIR / "tracks" / "tracks.json"
    -STYLES_CSS = SCRIPT_DIR / "styles.css"
 71 +STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css"
 72  
 73  
 74  def get_background_color():

154  		"static_files": [
155  			"./",
156  			"index.html",
    -			"styles.css",
    -			"script.js",
157 +			"resources/styles.css",
158 +			"resources/script.js",
159  			"tracks/tracks.json",
160  			"resources/icon.png",
161  			"resources/play.png",
162  			"resources/pause.png",
163  			"resources/prev.png",
164  			"resources/next.png",
    -			"resources/album_art.jpg"
165 +			"tracks/album_art.jpg"
166  		],
167  		"tracks": [f"tracks/{track['filename']}" for track in tracks]
168  	}
index.html
10  	<link rel="apple-touch-icon" href="resources/icon.png">
11  	<link rel="manifest" href="manifest.json">
12  	<title>vibe capsule</title>
   -	<link rel="stylesheet" href="styles.css">
13 +	<link rel="stylesheet" href="resources/styles.css">
14  </head>
15  <body>
16  	<div class="window-container">

38  
39  	<audio id="audioPlayer"></audio>
40  
   -	<script src="script.js"></script>
41 +	<script src="resources/script.js"></script>
42  </body>
43  </html>
readme.md
41  		- `manifest.json` (PWA configuration file)
42  		- `resource-manifest.json` (defines the files to be cached for offline use)
43  		- `service-worker.js` (manages offline caching)
   -	- optionally, add an `album_art.jpg` to `/resources`. it'll be used as the cover for your mix when playing on supported devices.
44 +	- optionally, add an `album_art.jpg` to `/tracks`. it'll be used as the cover for your mix when playing on supported devices.
45  
46  4. **deploy your mixapp**
47  	- upload the entire project directory to any web host with HTTPS support (GitHub Pages, AWS S3, etc.)
resources/script.js
   1 +// Register service worker for PWA functionality
   2 +if ('serviceWorker' in navigator) {
   3 +	window.addEventListener('load', () => {
   4 +		navigator.serviceWorker.register('service-worker.js')
   5 +			.then(registration => {
   6 +				console.log('Service Worker registered successfully:', registration.scope);
   7 +			})
   8 +			.catch(error => {
   9 +				console.log('Service Worker registration failed:', error);
  10 +			});
  11 +	});
  12 +}
  13 +
  14 +const playPauseBtn = document.getElementById('playPause');
  15 +const prevBtn = document.getElementById('prev');
  16 +const nextBtn = document.getElementById('next');
  17 +const playlist = document.getElementById('playlist');
  18 +const currentSongDisplay = document.getElementById('currentSong');
  19 +const progressBar = document.getElementById('progressBar');
  20 +const progressContainer = document.getElementById('progressContainer');
  21 +const audio = document.getElementById('audioPlayer');
  22 +audio.controls = true; // Enable controls for iOS media session
  23 +
  24 +const shuffle = false;
  25 +
  26 +function shuffleArray(array) {
  27 +	const shuffled = [...array];
  28 +	for (let i = shuffled.length - 1; i > 0; i--) {
  29 +		const j = Math.floor(Math.random() * (i + 1));
  30 +		[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  31 +	}
  32 +	return shuffled;
  33 +}
  34 +
  35 +let currentSongIndex = 0;
  36 +let isPlaying = false;
  37 +let progressInterval;
  38 +let playerReady = false;
  39 +let songs = [];
  40 +let animationFrameId = null;
  41 +let prePlaySeekTime = 0;
  42 +let preloadedAudio = {}; // Cache for preloaded audio elements
  43 +let currentPreloadIndex = 0;
  44 +let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
  45 +let isPreloadingPriority = false;
  46 +let totalBytesLoaded = 0; // Track total filesize of all preloaded songs
  47 +let cachedTracks = new Set(); // Track which songs are cached for offline use
  48 +let CACHE_NAME = null; // Will be loaded from manifest.json
  49 +
  50 +// Load cache name from manifest.json first, then load tracks
  51 +fetch('manifest.json')
  52 +	.then(response => response.json())
  53 +	.then(manifest => {
  54 +		CACHE_NAME = manifest.cache_name || manifest.name;
  55 +		console.log('Using cache name:', CACHE_NAME);
  56 +
  57 +		// Set page title from manifest
  58 +		if (manifest.name) {
  59 +			document.title = manifest.name;
  60 +		}
  61 +
  62 +		// Now that we have CACHE_NAME, load tracks
  63 +		return fetch('tracks/tracks.json');
  64 +	})
  65 +	.then(response => {
  66 +		if (!response.ok) {
  67 +			throw new Error('tracks.json not found');
  68 +		}
  69 +		return response.json();
  70 +	})
  71 +	.then(data => {
  72 +		songs = shuffle ? shuffleArray(data) : data;
  73 +		if (songs.length > 0) {
  74 +			playerReady = true;
  75 +			updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`);
  76 +			// Check which tracks are already cached before rendering
  77 +			return checkCachedTracks().then(() => {
  78 +				renderPlaylist();
  79 +				// Pre-cache resources first, then songs
  80 +				return preloadResources().then(() => {
  81 +					startPreloadingSongs();
  82 +				});
  83 +			});
  84 +		} else {
  85 +			updateCurrentSongDisplay('No tracks found');
  86 +		}
  87 +	})
  88 +	.catch(error => {
  89 +		console.error('Error loading manifest or tracks:', error);
  90 +		updateCurrentSongDisplay('Unable to load tracks. Please check your connection.');
  91 +	});
  92 +
  93 +// Audio event listeners
  94 +audio.addEventListener('play', () => {
  95 +	startProgressBar();
  96 +	const song = songs[currentSongIndex];
  97 +	const songText = `${song.artist} – ${song.title}`;
  98 +
  99 +	// Always update display to ensure we remove "Ready to play:" prefix
 100 +	const currentText = currentSongDisplay.querySelector('span')?.textContent || '';
 101 +
 102 +	// Check if it's a different song (not just play/pause of same song)
 103 +	const isNewSong = !currentText.includes(songText);
 104 +	const hasReadyToPlay = currentText.includes('Ready to play:');
 105 +
 106 +	if (isNewSong || hasReadyToPlay) {
 107 +		updateCurrentSongDisplay(songText);
 108 +	} else {
 109 +		// Same song, just resume the marquee
 110 +		resumeMarquee();
 111 +	}
 112 +
 113 +	// Update media session metadata
 114 +	if ('mediaSession' in navigator) {
 115 +		// Convert relative path to absolute URL for media session
 116 +		// Use document.baseURI to correctly resolve paths in subdirectories
 117 +		const albumArtUrl = new URL('tracks/album_art.jpg', document.baseURI).href;
 118 +		navigator.mediaSession.metadata = new MediaMetadata({
 119 +			title: song.title,
 120 +			artist: song.artist,
 121 +			artwork: [
 122 +				{ src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' },
 123 +				{ src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' },
 124 +				{ src: albumArtUrl, sizes: '256x256', type: 'image/jpeg' },
 125 +				{ src: albumArtUrl, sizes: '128x128', type: 'image/jpeg' }
 126 +			]
 127 +		});
 128 +
 129 +		// Set action handlers after playback starts (required for iOS)
 130 +		// Explicitly set seek handlers to null so iOS shows next/prev instead
 131 +		navigator.mediaSession.setActionHandler('play', () => {
 132 +			if (playerReady && !isPlaying) {
 133 +				togglePlayPause();
 134 +			}
 135 +		});
 136 +
 137 +		navigator.mediaSession.setActionHandler('pause', () => {
 138 +			if (playerReady && isPlaying) {
 139 +				togglePlayPause();
 140 +			}
 141 +		});
 142 +
 143 +		navigator.mediaSession.setActionHandler('previoustrack', () => {
 144 +			if (playerReady) {
 145 +				prevSong();
 146 +			}
 147 +		});
 148 +
 149 +		navigator.mediaSession.setActionHandler('nexttrack', () => {
 150 +			if (playerReady) {
 151 +				nextSong();
 152 +			}
 153 +		});
 154 +
 155 +		// Explicitly set seek handlers to null to show track controls instead
 156 +		navigator.mediaSession.setActionHandler('seekbackward', null);
 157 +		navigator.mediaSession.setActionHandler('seekforward', null);
 158 +	}
 159 +});
 160 +
 161 +audio.addEventListener('pause', () => {
 162 +	stopProgressBar();
 163 +	pauseMarquee();
 164 +});
 165 +
 166 +audio.addEventListener('ended', () => {
 167 +	if (songs[currentSongIndex].looping) {
 168 +		audio.currentTime = 0;
 169 +		audio.play();
 170 +	} else {
 171 +		nextSong();
 172 +	}
 173 +});
 174 +
 175 +audio.addEventListener('error', (e) => {
 176 +	console.error('Audio error:', e);
 177 +	updateCurrentSongDisplay(`Error loading: ${songs[currentSongIndex].filename}`);
 178 +	// Try next song after a brief delay
 179 +	setTimeout(() => nextSong(), 1000);
 180 +});
 181 +
 182 +audio.addEventListener('loadedmetadata', () => {
 183 +	resetProgressBar();
 184 +});
 185 +
 186 +function renderPlaylist() {
 187 +	playlist.innerHTML = '';
 188 +	const currentDisplayText = currentSongDisplay.textContent;
 189 +	const isInitialized = currentDisplayText !== 'No song playing';
 190 +
 191 +	songs.forEach((song, index) => {
 192 +		const item = document.createElement('div');
 193 +		item.classList.add('playlist-item');
 194 +
 195 +		const contentDiv = document.createElement('div');
 196 +		contentDiv.classList.add('playlist-item-content');
 197 +
 198 +		const titleDiv = document.createElement('div');
 199 +		titleDiv.classList.add('playlist-item-title');
 200 +		if (isInitialized && index === currentSongIndex) {
 201 +			titleDiv.classList.add('current');
 202 +		}
 203 +		titleDiv.textContent = song.title;
 204 +
 205 +		const artistDiv = document.createElement('div');
 206 +		artistDiv.classList.add('playlist-item-artist');
 207 +		if (isInitialized && index === currentSongIndex) {
 208 +			artistDiv.classList.add('current');
 209 +		}
 210 +		artistDiv.textContent = song.artist;
 211 +
 212 +		// Set cached status for visual indication
 213 +		const isCached = cachedTracks.has(song.filename);
 214 +		if (!isCached) {
 215 +			contentDiv.classList.add('uncached');
 216 +		}
 217 +
 218 +		contentDiv.appendChild(titleDiv);
 219 +		contentDiv.appendChild(artistDiv);
 220 +
 221 +		const loopIcon = document.createElement('span');
 222 +		loopIcon.textContent = '🔁';
 223 +		loopIcon.style.display = (song.looping || false) ? 'inline' : 'none';
 224 +
 225 +		item.appendChild(contentDiv);
 226 +		item.appendChild(loopIcon);
 227 +		item.addEventListener('click', () => toggleLooping(index));
 228 +		playlist.appendChild(item);
 229 +	});
 230 +}
 231 +
 232 +function toggleLooping(index) {
 233 +	if (!playerReady) return;
 234 +	if (index === currentSongIndex) {
 235 +		if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
 236 +			audio.play().catch(err => {
 237 +				console.error('Failed to play audio:', err);
 238 +			});
 239 +			isPlaying = true;
 240 +			updatePlayPauseButton();
 241 +			return;
 242 +		}
 243 +		// Toggle looping
 244 +		songs[index].looping = !(songs[index].looping || false);
 245 +		renderPlaylist();
 246 +	} else {
 247 +		playSong(index);
 248 +	}
 249 +}
 250 +
 251 +function playSong(index) {
 252 +	console.log(`playSong called with index: ${index}`);
 253 +	if (!playerReady) {
 254 +		console.log('Player not ready');
 255 +		return;
 256 +	}
 257 +	// Clear looping from all songs except the new one if it was already looping
 258 +	const wasLooping = songs[index].looping || false;
 259 +	songs.forEach(song => song.looping = false);
 260 +	if (wasLooping) {
 261 +		songs[index].looping = true;
 262 +	}
 263 +
 264 +	currentSongIndex = index;
 265 +	const song = songs[currentSongIndex];
 266 +	console.log(`Attempting to play: ${song.artist} – ${song.title}`);
 267 +	console.log(`Filename: ${song.filename}`);
 268 +	console.log(`Is in preloadedAudio: ${!!preloadedAudio[song.filename]}`);
 269 +
 270 +	// Use preloaded blob if available, otherwise load from server
 271 +	if (preloadedAudio[song.filename]) {
 272 +		const blobUrl = preloadedAudio[song.filename].blobUrl;
 273 +		console.log(`Playing from preloaded blob: ${song.filename}`);
 274 +		console.log(`Blob URL: ${blobUrl}`);
 275 +		audio.src = blobUrl;
 276 +	} else {
 277 +		console.log(`Song not preloaded, loading: ${song.filename}`);
 278 +		audio.src = `tracks/${song.filename}`;
 279 +		// Request priority preloading for this song
 280 +		requestPriorityPreload(song.filename);
 281 +	}
 282 +
 283 +	console.log(`Audio src set to: ${audio.src}`);
 284 +
 285 +	// For iOS PWA: We need to call load() and play() synchronously
 286 +	// Reset any previous state first
 287 +	try {
 288 +		audio.pause();
 289 +		audio.currentTime = 0;
 290 +	} catch (e) {
 291 +		// Ignore errors from resetting
 292 +	}
 293 +
 294 +	// Load the audio to ensure it's ready (important for iOS PWA)
 295 +	audio.load();
 296 +
 297 +	// Small delay to let load() initialize, then play
 298 +	// This needs to be synchronous enough that iOS considers it part of the user gesture
 299 +	const playAttempt = audio.play();
 300 +
 301 +	if (playAttempt !== undefined) {
 302 +		playAttempt.then(() => {
 303 +			console.log('Audio playback started successfully');
 304 +			isPlaying = true;
 305 +			updatePlayPauseButton();
 306 +		}).catch(err => {
 307 +			console.error('Failed to play audio:', err);
 308 +			console.error('Error name:', err.name);
 309 +			console.error('Error message:', err.message);
 310 +			isPlaying = false;
 311 +			updatePlayPauseButton();
 312 +			updateCurrentSongDisplay(`Error playing: ${song.title}`);
 313 +		});
 314 +	}
 315 +
 316 +	// Optimistically set playing state
 317 +	isPlaying = true;
 318 +	updatePlayPauseButton();
 319 +	renderPlaylist();
 320 +}
 321 +
 322 +function updateCurrentSongDisplay(text) {
 323 +	currentSongDisplay.innerHTML = `<span>${text}</span>`;
 324 +	// Initialize marquee effect after a brief delay to ensure DOM is updated
 325 +	setTimeout(() => setupMarquee(), 50);
 326 +}
 327 +
 328 +// Marquee state
 329 +let marqueeAnimating = false;
 330 +let marqueePaused = false;
 331 +let marqueeTimeoutId = null;
 332 +let marqueeOriginalText = '';
 333 +let marqueeDuration = 0;
 334 +let marqueeHalfWidth = 0;
 335 +let marqueeTransitionEndHandler = null;
 336 +let marqueeCurrentTransform = 'translateX(0)';
 337 +
 338 +function setupMarquee() {
 339 +	const container = currentSongDisplay;
 340 +	const textSpan = container.querySelector('span');
 341 +
 342 +	if (!textSpan) return;
 343 +
 344 +	// Clear any existing animation
 345 +	if (marqueeTimeoutId) {
 346 +		clearTimeout(marqueeTimeoutId);
 347 +		marqueeTimeoutId = null;
 348 +	}
 349 +	if (marqueeTransitionEndHandler) {
 350 +		textSpan.removeEventListener('transitionend', marqueeTransitionEndHandler);
 351 +		marqueeTransitionEndHandler = null;
 352 +	}
 353 +	marqueeAnimating = false;
 354 +	marqueePaused = false;
 355 +
 356 +	// Reset styles
 357 +	textSpan.style.transition = 'none';
 358 +	textSpan.style.transform = 'translateX(0)';
 359 +	marqueeCurrentTransform = 'translateX(0)';
 360 +	container.classList.remove('no-overflow');
 361 +
 362 +	// Store original text
 363 +	marqueeOriginalText = textSpan.textContent;
 364 +
 365 +	// Force reflow
 366 +	void textSpan.offsetWidth;
 367 +
 368 +	// Check if text overflows
 369 +	const containerWidth = container.offsetWidth - 30; // Account for padding
 370 +	const textWidth = textSpan.scrollWidth;
 371 +	const overflows = textWidth > containerWidth;
 372 +
 373 +	if (!overflows) {
 374 +		// No overflow - show ellipsis behavior
 375 +		container.classList.add('no-overflow');
 376 +		container.classList.remove('marquee-active');
 377 +		return;
 378 +	}
 379 +
 380 +	// Text overflows - setup marquee
 381 +	marqueeAnimating = true;
 382 +	container.classList.add('marquee-active');
 383 +
 384 +	// Add spacing and duplicate text for seamless loop
 385 +	// Using non-breaking spaces (\u00A0) so they don't collapse in HTML
 386 +	const spacing = '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0'; // 12 non-breaking spaces
 387 +	textSpan.textContent = marqueeOriginalText + spacing + marqueeOriginalText + spacing;
 388 +
 389 +	// Calculate animation parameters
 390 +	const fullWidth = textSpan.scrollWidth;
 391 +	marqueeHalfWidth = fullWidth / 2;
 392 +	marqueeDuration = (marqueeHalfWidth / 50) * 1000; // Adjust speed here (pixels per second)
 393 +
 394 +	// Start animation after a brief delay (only if not paused)
 395 +	// Don't start animation if already paused
 396 +	if (!marqueePaused) {
 397 +		marqueeTimeoutId = setTimeout(() => {
 398 +			if (marqueePaused) return;
 399 +
 400 +			textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
 401 +			textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
 402 +			marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
 403 +
 404 +			// Reset and loop
 405 +			marqueeTransitionEndHandler = () => {
 406 +				if (!marqueeAnimating || marqueePaused) return;
 407 +
 408 +				textSpan.style.transition = 'none';
 409 +				textSpan.style.transform = 'translateX(0)';
 410 +				marqueeCurrentTransform = 'translateX(0)';
 411 +
 412 +				setTimeout(() => {
 413 +					if (!marqueeAnimating || marqueePaused) return;
 414 +					textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
 415 +					textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
 416 +					marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
 417 +				}, 50);
 418 +			};
 419 +
 420 +			textSpan.addEventListener('transitionend', marqueeTransitionEndHandler);
 421 +		}, 1000); // Initial delay before starting scroll
 422 +	}
 423 +}
 424 +
 425 +function pauseMarquee() {
 426 +	marqueePaused = true;
 427 +	const textSpan = currentSongDisplay.querySelector('span');
 428 +	if (!textSpan || !marqueeAnimating) return;
 429 +
 430 +	// Capture current transform position
 431 +	const computedStyle = window.getComputedStyle(textSpan);
 432 +	const currentTransform = computedStyle.transform;
 433 +	marqueeCurrentTransform = currentTransform;
 434 +
 435 +	// Freeze at current position
 436 +	textSpan.style.transition = 'none';
 437 +	textSpan.style.transform = currentTransform;
 438 +
 439 +	// Clear any pending timeout
 440 +	if (marqueeTimeoutId) {
 441 +		clearTimeout(marqueeTimeoutId);
 442 +		marqueeTimeoutId = null;
 443 +	}
 444 +}
 445 +
 446 +function resumeMarquee() {
 447 +	if (!marqueeAnimating) return;
 448 +	marqueePaused = false;
 449 +
 450 +	const textSpan = currentSongDisplay.querySelector('span');
 451 +	if (!textSpan) return;
 452 +
 453 +	// Apply the stored transform position first
 454 +	textSpan.style.transition = 'none';
 455 +	textSpan.style.transform = marqueeCurrentTransform;
 456 +
 457 +	// Force reflow to apply the transform
 458 +	void textSpan.offsetWidth;
 459 +
 460 +	// Get current position from the stored transform
 461 +	const matrix = new DOMMatrix(marqueeCurrentTransform);
 462 +	const currentX = matrix.m41;
 463 +
 464 +	// Calculate remaining distance and time
 465 +	const distanceTraveled = Math.abs(currentX);
 466 +	const percentComplete = distanceTraveled / marqueeHalfWidth;
 467 +	const timeRemaining = marqueeDuration * (1 - percentComplete);
 468 +
 469 +	// Resume animation from current position
 470 +	textSpan.style.transition = `transform ${timeRemaining}ms linear`;
 471 +	textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
 472 +	marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
 473 +}
 474 +
 475 +// Add window resize listener to recalculate marquee
 476 +window.addEventListener('resize', () => {
 477 +	if (marqueeOriginalText) {
 478 +		// Store the paused state before recalculating
 479 +		const wasPaused = marqueePaused;
 480 +
 481 +		// Restore original text before recalculating
 482 +		const textSpan = currentSongDisplay.querySelector('span');
 483 +		if (textSpan) {
 484 +			textSpan.textContent = marqueeOriginalText;
 485 +		}
 486 +
 487 +		setupMarquee();
 488 +
 489 +		// Restore paused state after recalculation
 490 +		if (wasPaused) {
 491 +			marqueePaused = true;
 492 +		}
 493 +	}
 494 +});
 495 +
 496 +function togglePlayPause() {
 497 +	if (!playerReady) return;
 498 +	if (isPlaying) {
 499 +		audio.pause();
 500 +		isPlaying = false;
 501 +	} else {
 502 +		// If no song is loaded, load the first one
 503 +		if (!audio.src || audio.src === '') {
 504 +			playSong(currentSongIndex);
 505 +		} else {
 506 +			audio.play().catch(err => {
 507 +				console.error('Failed to play audio:', err);
 508 +				const song = songs[currentSongIndex];
 509 +				updateCurrentSongDisplay(`Error playing: ${song.title}`);
 510 +			});
 511 +			isPlaying = true;
 512 +		}
 513 +	}
 514 +	updatePlayPauseButton();
 515 +}
 516 +
 517 +function updatePlayPauseButton() {
 518 +	playPauseBtn.classList.toggle('pause', isPlaying);
 519 +}
 520 +
 521 +function nextSong() {
 522 +	if (!playerReady) return;
 523 +	currentSongIndex = (currentSongIndex + 1) % songs.length;
 524 +	playSong(currentSongIndex);
 525 +}
 526 +
 527 +function prevSong() {
 528 +	if (!playerReady) return;
 529 +	if (audio.currentTime <= 3) {
 530 +		currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
 531 +		playSong(currentSongIndex);
 532 +	} else {
 533 +		audio.currentTime = 0;
 534 +	}
 535 +}
 536 +
 537 +function startProgressBar() {
 538 +	stopProgressBar();
 539 +
 540 +	function animate() {
 541 +		updateProgressBar();
 542 +		animationFrameId = requestAnimationFrame(animate);
 543 +	}
 544 +
 545 +	animate();
 546 +
 547 +	// iOS PWA background playback fix: use setInterval as backup
 548 +	// setInterval is less throttled than requestAnimationFrame in background
 549 +	backgroundPlaybackCheckInterval = setInterval(() => {
 550 +		if (!audio.paused && audio.duration && audio.currentTime >= audio.duration - 0.5) {
 551 +			console.log('Background check: song ended, triggering next');
 552 +			clearInterval(backgroundPlaybackCheckInterval);
 553 +			backgroundPlaybackCheckInterval = null;
 554 +
 555 +			if (songs[currentSongIndex].looping) {
 556 +				audio.currentTime = 0;
 557 +				audio.play();
 558 +			} else {
 559 +				nextSong();
 560 +			}
 561 +		}
 562 +
 563 +		// Update Media Session position state for iOS
 564 +		if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
 565 +			try {
 566 +				if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
 567 +					navigator.mediaSession.setPositionState({
 568 +						duration: audio.duration,
 569 +						playbackRate: audio.playbackRate,
 570 +						position: audio.currentTime
 571 +					});
 572 +				}
 573 +			} catch (e) {
 574 +				// Ignore errors from setPositionState
 575 +			}
 576 +		}
 577 +	}, 500); // Check every 500ms
 578 +}
 579 +
 580 +function stopProgressBar() {
 581 +	if (animationFrameId !== null) {
 582 +		cancelAnimationFrame(animationFrameId);
 583 +		animationFrameId = null;
 584 +	}
 585 +	if (backgroundPlaybackCheckInterval !== null) {
 586 +		clearInterval(backgroundPlaybackCheckInterval);
 587 +		backgroundPlaybackCheckInterval = null;
 588 +	}
 589 +}
 590 +
 591 +function resetProgressBar() {
 592 +	progressBar.style.setProperty('--progress', '0');
 593 +}
 594 +
 595 +function updateProgressBar() {
 596 +	if (audio.duration && !isDragging && !isSeeking) {
 597 +		const currentTime = audio.currentTime;
 598 +		const duration = audio.duration;
 599 +		const progressPercentage = (currentTime / duration) * 100;
 600 +		const displayPercentage = isNaN(progressPercentage) ? 0 : progressPercentage;
 601 +		progressBar.style.setProperty('--progress', displayPercentage);
 602 +	}
 603 +}
 604 +
 605 +let isDragging = false;
 606 +let wasPlayingBeforeDrag = false;
 607 +let pendingSeekPercentage = null;
 608 +let backgroundPlaybackCheckInterval = null;
 609 +
 610 +function updateVisualProgress(event) {
 611 +	if (!playerReady) return;
 612 +
 613 +	const rect = progressBar.getBoundingClientRect();
 614 +	// Support both mouse and touch events
 615 +	const clientX = event.touches ? event.touches[0].clientX : event.clientX;
 616 +	const clickPosition = clientX - rect.left;
 617 +	const clickPercentage = Math.max(0, Math.min(1, clickPosition / rect.width));
 618 +
 619 +	progressBar.style.setProperty('--progress', clickPercentage * 100);
 620 +	return clickPercentage;
 621 +}
 622 +
 623 +let isSeeking = false;
 624 +let targetSeekTime = null;
 625 +
 626 +function applySeek(clickPercentage) {
 627 +	if (!playerReady) return;
 628 +
 629 +	// If audio hasn't been loaded yet, load it but don't play
 630 +	if (!audio.src || audio.src === '') {
 631 +		const song = songs[currentSongIndex];
 632 +
 633 +		// Use preloaded blob if available, otherwise load from server
 634 +		if (preloadedAudio[song.filename]) {
 635 +			audio.src = preloadedAudio[song.filename].blobUrl;
 636 +		} else {
 637 +			audio.src = `tracks/${song.filename}`;
 638 +		}
 639 +
 640 +		// Wait for metadata to be loaded before seeking
 641 +		audio.addEventListener('loadedmetadata', function setInitialTime() {
 642 +			const duration = audio.duration;
 643 +			const seekTime = duration * clickPercentage;
 644 +			attemptSeekWithRetry(seekTime, clickPercentage);
 645 +			prePlaySeekTime = seekTime;
 646 +			audio.removeEventListener('loadedmetadata', setInitialTime);
 647 +		}, { once: true });
 648 +	} else if (audio.duration) {
 649 +		const duration = audio.duration;
 650 +		const seekTime = duration * clickPercentage;
 651 +		attemptSeekWithRetry(seekTime, clickPercentage);
 652 +		prePlaySeekTime = seekTime;
 653 +	}
 654 +}
 655 +
 656 +function isTimeBuffered(time) {
 657 +	// Check if the given time is within any buffered time range
 658 +	for (let i = 0; i < audio.buffered.length; i++) {
 659 +		if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
 660 +			return true;
 661 +		}
 662 +	}
 663 +	return false;
 664 +}
 665 +
 666 +function attemptSeekWithRetry(seekTime, targetPercentage) {
 667 +	targetSeekTime = seekTime;
 668 +	isSeeking = true;
 669 +
 670 +	// Lock the progress bar at the target position
 671 +	progressBar.style.setProperty('--progress', targetPercentage * 100);
 672 +
 673 +	const wasPlaying = !audio.paused;
 674 +
 675 +	// Try to seek
 676 +	audio.currentTime = seekTime;
 677 +
 678 +	// Handler to check if we reached the target after seeking completes
 679 +	function checkSeekSuccess() {
 680 +		// Allow small tolerance for floating point comparison
 681 +		if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
 682 +			// Browser clamped to buffered range - need to wait for more data
 683 +			// Now pause during seeking
 684 +			if (wasPlaying) {
 685 +				audio.pause();
 686 +			}
 687 +			continueSeekingToTarget(wasPlaying);
 688 +		} else {
 689 +			// Successfully reached target immediately (was already buffered)
 690 +			isSeeking = false;
 691 +			targetSeekTime = null;
 692 +			// No need to update display - the seek was instant and playback continues normally
 693 +		}
 694 +	}
 695 +
 696 +	audio.addEventListener('seeked', checkSeekSuccess, { once: true });
 697 +}
 698 +
 699 +function continueSeekingToTarget(wasPlaying) {
 700 +	// Handler for when more data loads
 701 +	function retrySeek() {
 702 +		if (!isSeeking || targetSeekTime === null) {
 703 +			return; // Seeking was cancelled
 704 +		}
 705 +
 706 +		audio.currentTime = targetSeekTime;
 707 +
 708 +		// Check again after this seek completes
 709 +		function checkAgain() {
 710 +			if (!isSeeking || targetSeekTime === null) {
 711 +				return;
 712 +			}
 713 +
 714 +			if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
 715 +				// Still not there, keep trying
 716 +				continueSeekingToTarget(wasPlaying);
 717 +			} else {
 718 +				// Success!
 719 +				isSeeking = false;
 720 +				targetSeekTime = null;
 721 +
 722 +				if (wasPlaying) {
 723 +					audio.play();
 724 +				}
 725 +			}
 726 +		}
 727 +
 728 +		audio.addEventListener('seeked', checkAgain, { once: true });
 729 +	}
 730 +
 731 +	// Wait for more data to load, then try again
 732 +	audio.addEventListener('progress', retrySeek, { once: true });
 733 +
 734 +	// Also set a timeout fallback in case progress doesn't fire
 735 +	setTimeout(() => {
 736 +		if (isSeeking && targetSeekTime !== null && Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
 737 +			retrySeek();
 738 +		}
 739 +	}, 1000);
 740 +}
 741 +
 742 +function onProgressMouseDown(event) {
 743 +	if (!playerReady) return;
 744 +	isDragging = true;
 745 +	wasPlayingBeforeDrag = isPlaying;
 746 +	progressContainer.style.cursor = 'grabbing';
 747 +	document.body.style.cursor = 'grabbing';
 748 +	pendingSeekPercentage = updateVisualProgress(event);
 749 +	event.preventDefault();
 750 +}
 751 +
 752 +function onProgressMouseMove(event) {
 753 +	if (isDragging) {
 754 +		pendingSeekPercentage = updateVisualProgress(event);
 755 +	}
 756 +}
 757 +
 758 +function onProgressMouseUp(event) {
 759 +	if (isDragging) {
 760 +		isDragging = false;
 761 +		progressContainer.style.cursor = '';
 762 +		document.body.style.cursor = '';
 763 +
 764 +		// Apply the seek now that drag is complete
 765 +		if (pendingSeekPercentage !== null) {
 766 +			applySeek(pendingSeekPercentage);
 767 +			pendingSeekPercentage = null;
 768 +		}
 769 +
 770 +		// If it was "Ready to play" (not playing before), start playing now
 771 +		if (!wasPlayingBeforeDrag && !isPlaying && audio.src) {
 772 +			audio.play();
 773 +			isPlaying = true;
 774 +			updatePlayPauseButton();
 775 +		}
 776 +	}
 777 +}
 778 +
 779 +playPauseBtn.addEventListener('click', togglePlayPause);
 780 +nextBtn.addEventListener('click', nextSong);
 781 +prevBtn.addEventListener('click', prevSong);
 782 +
 783 +// Mouse events for desktop
 784 +progressContainer.addEventListener('mousedown', onProgressMouseDown);
 785 +document.addEventListener('mousemove', onProgressMouseMove);
 786 +document.addEventListener('mouseup', onProgressMouseUp);
 787 +
 788 +// Touch events for mobile
 789 +progressContainer.addEventListener('touchstart', onProgressMouseDown, { passive: false });
 790 +document.addEventListener('touchmove', onProgressMouseMove, { passive: false });
 791 +document.addEventListener('touchend', onProgressMouseUp);
 792 +
 793 +// Keyboard controls
 794 +document.addEventListener('keydown', function(event) {
 795 +	if (!playerReady) return;
 796 +
 797 +	// Spacebar: play/pause
 798 +	if (event.code === 'Space') {
 799 +		event.preventDefault();
 800 +		togglePlayPause();
 801 +	}
 802 +});
 803 +
 804 +// Pre-caching system
 805 +function preloadResources() {
 806 +	console.log('Preloading UI resources...');
 807 +
 808 +	const resources = [
 809 +		'resources/play.png',
 810 +		'resources/pause.png',
 811 +		'resources/prev.png',
 812 +		'resources/next.png',
 813 +		'tracks/album_art.jpg'
 814 +	];
 815 +
 816 +	const imagePromises = resources.map(src => {
 817 +		return new Promise((resolve, reject) => {
 818 +			const img = new Image();
 819 +			img.onload = () => {
 820 +				console.log(`Preloaded: ${src}`);
 821 +				resolve();
 822 +			};
 823 +			img.onerror = () => {
 824 +				console.error(`Failed to preload: ${src}`);
 825 +				resolve(); // Resolve anyway to not block other resources
 826 +			};
 827 +			img.src = src;
 828 +		});
 829 +	});
 830 +
 831 +	return Promise.all(imagePromises).then(() => {
 832 +		console.log('All UI resources preloaded');
 833 +	});
 834 +}
 835 +
 836 +function startPreloadingSongs() {
 837 +	// Start with the first song
 838 +	currentPreloadIndex = 0;
 839 +	preloadNextSong();
 840 +}
 841 +
 842 +function preloadNextSong() {
 843 +	if (currentPreloadIndex >= songs.length) {
 844 +		const totalMB = (totalBytesLoaded / 1024 / 1024).toFixed(2);
 845 +		console.log(`All songs preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`);
 846 +		return;
 847 +	}
 848 +
 849 +	const song = songs[currentPreloadIndex];
 850 +	const filename = song.filename;
 851 +
 852 +	// Skip if already preloaded in memory
 853 +	if (preloadedAudio[filename]) {
 854 +		currentPreloadIndex++;
 855 +		preloadNextSong();
 856 +		return;
 857 +	}
 858 +
 859 +	// If already cached, load from cache into memory
 860 +	if (cachedTracks.has(filename)) {
 861 +		console.log(`Loading from cache: ${song.artist} – ${song.title}`);
 862 +		loadFromCache(filename).then(() => {
 863 +			currentPreloadIndex++;
 864 +			setTimeout(() => preloadNextSong(), 100);
 865 +		}).catch(err => {
 866 +			console.error(`Failed to load from cache, fetching instead:`, err);
 867 +			// If cache load fails, fetch from network
 868 +			fetchAndPreloadSong(song, filename);
 869 +		});
 870 +		return;
 871 +	}
 872 +
 873 +	console.log(`Preloading: ${song.artist} – ${song.title}`);
 874 +	fetchAndPreloadSong(song, filename);
 875 +}
 876 +
 877 +function fetchAndPreloadSong(song, filename) {
 878 +	// Use fetch to force full download of the entire file
 879 +	fetch(`tracks/${filename}`)
 880 +		.then(response => {
 881 +			if (!response.ok) {
 882 +				throw new Error(`HTTP error! status: ${response.status}`);
 883 +			}
 884 +			// Get total file size for progress tracking
 885 +			const contentLength = response.headers.get('content-length');
 886 +			console.log(`Downloading ${song.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`);
 887 +
 888 +			// Read the entire response as a blob
 889 +			return response.blob();
 890 +		})
 891 +		.then(blob => {
 892 +			// Add blob size to total
 893 +			totalBytesLoaded += blob.size;
 894 +
 895 +			// Create a blob URL that will persist in memory
 896 +			const blobUrl = URL.createObjectURL(blob);
 897 +
 898 +			preloadedAudio[filename] = {
 899 +				blobUrl: blobUrl,
 900 +				blob: blob
 901 +			};
 902 +
 903 +			console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`);
 904 +
 905 +			// Store in Cache API for offline access
 906 +			return storeBlobInCache(filename, blob).then(() => {
 907 +				// Mark as cached and update UI
 908 +				cachedTracks.add(filename);
 909 +				updateTrackCachedStatus(filename);
 910 +
 911 +				// Move to next song
 912 +				currentPreloadIndex++;
 913 +				setTimeout(() => preloadNextSong(), 100);
 914 +			});
 915 +		})
 916 +		.catch(error => {
 917 +			console.error(`Failed to preload ${filename}:`, error);
 918 +			currentPreloadIndex++;
 919 +			preloadNextSong();
 920 +		});
 921 +}
 922 +
 923 +// Check which tracks are already cached on app load
 924 +async function checkCachedTracks() {
 925 +	try {
 926 +		const cache = await caches.open(CACHE_NAME);
 927 +		const cachedRequests = await cache.keys();
 928 +
 929 +		// Check each song to see if it's cached
 930 +		for (const song of songs) {
 931 +			// Build the same absolute URL that storeBlobInCache uses for consistency
 932 +			const absoluteUrl = new URL(`tracks/${song.filename}`, window.location.href).href;
 933 +			const isInCache = cachedRequests.some(request => request.url === absoluteUrl);
 934 +			if (isInCache) {
 935 +				cachedTracks.add(song.filename);
 936 +			}
 937 +		}
 938 +
 939 +		console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`);
 940 +		console.log('Cached tracks:', Array.from(cachedTracks));
 941 +	} catch (error) {
 942 +		console.error('Failed to check cached tracks:', error);
 943 +	}
 944 +}
 945 +
 946 +// Debug function to check preloaded state
 947 +window.debugAudioState = function() {
 948 +	console.log('=== Audio State Debug ===');
 949 +	console.log('Player ready:', playerReady);
 950 +	console.log('Is playing:', isPlaying);
 951 +	console.log('Current song index:', currentSongIndex);
 952 +	console.log('Total songs:', songs.length);
 953 +	console.log('Cached tracks count:', cachedTracks.size);
 954 +	console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
 955 +	console.log('Current audio src:', audio.src);
 956 +	console.log('Audio paused:', audio.paused);
 957 +	console.log('Audio error:', audio.error);
 958 +	if (songs[currentSongIndex]) {
 959 +		console.log('Current song:', songs[currentSongIndex].filename);
 960 +		console.log('Is preloaded:', !!preloadedAudio[songs[currentSongIndex].filename]);
 961 +		console.log('Is cached:', cachedTracks.has(songs[currentSongIndex].filename));
 962 +	}
 963 +	console.log('======================');
 964 +};
 965 +
 966 +// Load a track from cache into memory
 967 +async function loadFromCache(filename) {
 968 +	try {
 969 +		const cache = await caches.open(CACHE_NAME);
 970 +		// Try both relative and absolute URLs
 971 +		let response = await cache.match(`tracks/${filename}`);
 972 +		if (!response) {
 973 +			// Try with absolute URL
 974 +			const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
 975 +			response = await cache.match(absoluteUrl);
 976 +		}
 977 +
 978 +		if (!response) {
 979 +			throw new Error('Not in cache');
 980 +		}
 981 +
 982 +		const blob = await response.blob();
 983 +
 984 +		// Add blob size to total
 985 +		totalBytesLoaded += blob.size;
 986 +
 987 +		// Create a blob URL that will persist in memory
 988 +		const blobUrl = URL.createObjectURL(blob);
 989 +
 990 +		preloadedAudio[filename] = {
 991 +			blobUrl: blobUrl,
 992 +			blob: blob
 993 +		};
 994 +		console.log(`✓ Loaded from cache: ${filename}`);
 995 +	} catch (error) {
 996 +		console.error(`Failed to load from cache: ${filename}`, error);
 997 +		throw error;
 998 +	}
 999 +}
1000 +
1001 +// Store blob in Cache API for offline access
1002 +async function storeBlobInCache(filename, blob) {
1003 +	try {
1004 +		const cache = await caches.open(CACHE_NAME);
1005 +		const response = new Response(blob, {
1006 +			headers: {
1007 +				'Content-Type': 'audio/mpeg',
1008 +				'Content-Length': blob.size
1009 +			}
1010 +		});
1011 +		// Use absolute URL for consistency
1012 +		const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
1013 +		await cache.put(absoluteUrl, response);
1014 +		console.log(`✓ Cached for offline: ${filename}`);
1015 +	} catch (error) {
1016 +		console.error(`Failed to cache ${filename}:`, error);
1017 +	}
1018 +}
1019 +
1020 +// Update UI to show track is cached
1021 +function updateTrackCachedStatus(filename) {
1022 +	const songIndex = songs.findIndex(s => s.filename === filename);
1023 +	if (songIndex === -1) return;
1024 +
1025 +	// Find the playlist item and remove uncached class
1026 +	const playlistItems = playlist.querySelectorAll('.playlist-item');
1027 +	if (playlistItems[songIndex]) {
1028 +		const contentDiv = playlistItems[songIndex].querySelector('.playlist-item-content');
1029 +		if (contentDiv) {
1030 +			contentDiv.classList.remove('uncached');
1031 +		}
1032 +	}
1033 +}
1034 +
1035 +// Priority preloading system
1036 +function requestPriorityPreload(filename) {
1037 +	// Skip if already preloaded or already in priority queue
1038 +	if (preloadedAudio[filename] || priorityPreloadQueue.includes(filename)) {
1039 +		return;
1040 +	}
1041 +
1042 +	console.log(`🔥 Priority preload requested: ${filename}`);
1043 +	priorityPreloadQueue.push(filename);
1044 +
1045 +	// Start priority preloading if not already running
1046 +	if (!isPreloadingPriority) {
1047 +		processPriorityPreload();
1048 +	}
1049 +}
1050 +
1051 +function processPriorityPreload() {
1052 +	if (priorityPreloadQueue.length === 0) {
1053 +		isPreloadingPriority = false;
1054 +		return;
1055 +	}
1056 +
1057 +	isPreloadingPriority = true;
1058 +	const filename = priorityPreloadQueue.shift();
1059 +
1060 +	// Check if already preloaded (might have finished during normal preloading)
1061 +	if (preloadedAudio[filename]) {
1062 +		processPriorityPreload();
1063 +		return;
1064 +	}
1065 +
1066 +	// Find the song info
1067 +	const song = songs.find(s => s.filename === filename);
1068 +	if (!song) {
1069 +		processPriorityPreload();
1070 +		return;
1071 +	}
1072 +
1073 +	// If already cached, load from cache
1074 +	if (cachedTracks.has(filename)) {
1075 +		console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`);
1076 +		loadFromCache(filename).then(() => {
1077 +			processPriorityPreload();
1078 +		}).catch(err => {
1079 +			console.error(`Failed to load from cache, fetching instead:`, err);
1080 +			priorityFetchAndPreloadSong(song, filename);
1081 +		});
1082 +		return;
1083 +	}
1084 +
1085 +	console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`);
1086 +	priorityFetchAndPreloadSong(song, filename);
1087 +}
1088 +
1089 +function priorityFetchAndPreloadSong(song, filename) {
1090 +	// Use fetch to force full download of the entire file
1091 +	fetch(`tracks/${filename}`)
1092 +		.then(response => {
1093 +			if (!response.ok) {
1094 +				throw new Error(`HTTP error! status: ${response.status}`);
1095 +			}
1096 +			return response.blob();
1097 +		})
1098 +		.then(blob => {
1099 +			// Create a blob URL that will persist in memory
1100 +			const blobUrl = URL.createObjectURL(blob);
1101 +
1102 +			preloadedAudio[filename] = {
1103 +				blobUrl: blobUrl,
1104 +				blob: blob
1105 +			};
1106 +
1107 +			// Next time this song plays, it will use the cached version
1108 +			console.log(`✓ Priority preloaded: ${song.artist} – ${song.title}`);
1109 +
1110 +			// Store in Cache API for offline access
1111 +			return storeBlobInCache(filename, blob).then(() => {
1112 +				// Mark as cached and update UI
1113 +				cachedTracks.add(filename);
1114 +				updateTrackCachedStatus(filename);
1115 +
1116 +				// Process next priority request
1117 +				processPriorityPreload();
1118 +			});
1119 +		})
1120 +		.catch(error => {
1121 +			// Ignore abort errors (happens when normal preload finishes first)
1122 +			if (error.name === 'AbortError' || error.message.includes('aborted')) {
1123 +				// This is expected - normal preloading probably finished first
1124 +			} else {
1125 +				console.error(`Failed to priority preload ${filename}:`, error);
1126 +			}
1127 +			processPriorityPreload();
1128 +		});
1129 +}
resources/styles.css
  1 +:root {
  2 +	--text: #dddddd;
  3 +	--dimmed-text: #ddddddcc;
  4 +	--primary: #4c7ae6;
  5 +	--accent: #f421ff;
  6 +	--background: #080a0c;
  7 +}
  8 +
  9 +* {
 10 +	scrollbar-color: var(--dimmed-text) var(--background);
 11 +	user-select: none;
 12 +	-webkit-user-select: none;
 13 +	-webkit-touch-callout: none;
 14 +	-webkit-tap-highlight-color: transparent;
 15 +}
 16 +
 17 +html {
 18 +	scroll-behavior: smooth;
 19 +	overscroll-behavior: none;
 20 +	-ms-overflow-style: none;
 21 +	scrollbar-width: none;
 22 +}
 23 +
 24 +html::-webkit-scrollbar {
 25 +	display: none;
 26 +}
 27 +
 28 +.playlist-wrapper::-webkit-scrollbar {
 29 +	display: none;
 30 +}
 31 +
 32 +body {
 33 +	margin: 0;
 34 +	padding: 0;
 35 +	font-family: "MS Sans Serif", Arial, sans-serif;
 36 +	height: 100dvh;
 37 +	display: flex;
 38 +	flex-direction: column;
 39 +	font-size: 25px;
 40 +	background-color: var(--background);
 41 +	position: fixed;
 42 +	width: 100%;
 43 +}
 44 +
 45 +.window-container {
 46 +	background: transparent;
 47 +	height: 100%;
 48 +	display: flex;
 49 +	flex-direction: column;
 50 +}
 51 +
 52 +.player-container {
 53 +	flex-grow: 1;
 54 +	display: flex;
 55 +	flex-direction: column;
 56 +	overflow: hidden;
 57 +	position: relative;
 58 +	padding-top: 10px;
 59 +}
 60 +
 61 +.controls {
 62 +	display: flex;
 63 +	justify-content: center;
 64 +	background: transparent;
 65 +	position: absolute;
 66 +	bottom: 0;
 67 +	left: 0;
 68 +	right: 0;
 69 +	box-sizing: border-box;
 70 +	z-index: 3;
 71 +	pointer-events: none;
 72 +}
 73 +
 74 +.control-button {
 75 +	width: clamp(60px, calc((100vw - 58px) / 3), 120px);
 76 +	aspect-ratio: 8 / 3;
 77 +	background-size: contain;
 78 +	background-repeat: no-repeat;
 79 +	background-position: center;
 80 +	border: none;
 81 +	cursor: pointer;
 82 +	margin: 0;
 83 +	border-radius: 50%;
 84 +	background-color: var(--text);
 85 +	box-shadow:
 86 +		inset -2px -2px 4px rgba(0, 0, 0, 0.3),
 87 +		inset 2px 2px 4px rgba(255, 255, 255, 0.4),
 88 +		0 2px 4px rgba(0, 0, 0, 0.5),
 89 +		0 4px 8px rgba(0, 0, 0, 0.3);
 90 +	filter:
 91 +		drop-shadow(0 0 1px rgba(0, 0, 0, 0.8))
 92 +		drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.6))
 93 +		drop-shadow(-0.5px -0.5px 0 rgba(255, 255, 255, 0.2));
 94 +	pointer-events: auto;
 95 +}
 96 +
 97 +.controls-inner {
 98 +	display: flex;
 99 +	justify-content: space-between;
100 +	padding: 10px 15px;
101 +	pointer-events: auto;
102 +	width: 100%;
103 +	box-sizing: border-box;
104 +}
105 +
106 +@media (min-width: 420px) {
107 +	.controls-inner {
108 +		justify-content: center;
109 +		gap: 14px;
110 +		width: auto;
111 +	}
112 +
113 +	.control-button {
114 +		width: 120px;
115 +	}
116 +}
117 +
118 +.playlist-wrapper {
119 +	flex-grow: 1;
120 +	position: relative;
121 +	overflow-y: auto;
122 +	padding-bottom: 70px;
123 +	overscroll-behavior: none;
124 +	margin-top: -1px;
125 +}
126 +
127 +.playlist {
128 +	padding-top: 0;
129 +	cursor: initial;
130 +}
131 +
132 +.playlist-item {
133 +	display: flex;
134 +	justify-content: space-between;
135 +	align-items: center;
136 +	color: var(--text);
137 +	min-height: 45px;
138 +	padding-left: 15px;
139 +	padding-right: 10px;
140 +	padding-bottom: 18px;
141 +}
142 +
143 +.playlist-item-content {
144 +	flex-grow: 1;
145 +	display: flex;
146 +	flex-direction: column;
147 +	gap: 2px;
148 +	transition: opacity 0.3s ease;
149 +}
150 +
151 +.playlist-item-content.uncached {
152 +	opacity: 0.4;
153 +}
154 +
155 +.playlist-item-title {
156 +	font-size: 21px;
157 +	line-height: 1.2;
158 +}
159 +
160 +.playlist-item-title.current {
161 +	font-weight: bold;
162 +	color: var(--primary);
163 +}
164 +
165 +.playlist-item-artist {
166 +	font-size: 17px;
167 +	color: var(--dimmed-text);
168 +	line-height: 1.2;
169 +}
170 +
171 +.playlist-item-artist.current {
172 +	font-weight: bold;
173 +	color: var(--primary);
174 +	opacity: 1;
175 +}
176 +
177 +@media (hover: hover) and (pointer: fine) {
178 +	.playlist-item:hover {
179 +		cursor: pointer;
180 +		color: var(--accent);
181 +	}
182 +
183 +	.playlist-item:hover .playlist-item-artist {
184 +		color: var(--accent);
185 +	}
186 +
187 +	.playlist-item:hover .playlist-item-title.current,
188 +	.playlist-item:hover .playlist-item-artist.current {
189 +		color: var(--accent);
190 +	}
191 +}
192 +
193 +.current-song {
194 +	text-align: left;
195 +	padding-left: 15px;
196 +	padding-bottom: 10px;
197 +	font-weight: bold;
198 +	color: var(--primary);
199 +	min-height: 35px;
200 +	display: flex;
201 +	align-items: center;
202 +	background-color: var(--background);
203 +	z-index: 2;
204 +	position: relative;
205 +}
206 +
207 +.current-song span {
208 +	display: inline-block;
209 +	white-space: nowrap;
210 +	will-change: transform;
211 +}
212 +
213 +.current-song.no-overflow span {
214 +	overflow: hidden;
215 +	text-overflow: hidden;
216 +	max-width: 100%;
217 +}
218 +
219 +#audioPlayer {
220 +	display: none;
221 +}
222 +
223 +.progress-container {
224 +	width: 100%;
225 +	font-size: 24px;
226 +	cursor: grab;
227 +	margin-top: 0;
228 +	height: 35px;
229 +	display: flex;
230 +	align-items: center;
231 +	padding: 0 15px;
232 +	padding-bottom: 7px;
233 +	box-sizing: border-box;
234 +	position: relative;
235 +	background-color: var(--background);
236 +	z-index: 2;
237 +}
238 +
239 +.progress-container::before {
240 +	content: '';
241 +	position: absolute;
242 +	left: 15px;
243 +	right: 15px;
244 +	top: 50%;
245 +	transform: translateY(-50%);
246 +	margin-top: -3.5px;
247 +	height: 24px;
248 +	pointer-events: auto;
249 +}
250 +
251 +.shadow-overlay {
252 +	position: sticky;
253 +	top: -1px;
254 +	left: 0;
255 +	right: 0;
256 +	height: 14px;
257 +	background: linear-gradient(
258 +		to bottom,
259 +		var(--background) 0%,
260 +		rgba(8, 10, 12, 0.5) 40%,
261 +		rgba(8, 10, 12, 0.2) 70%,
262 +		transparent 100%
263 +	);
264 +	pointer-events: none;
265 +	z-index: 2;
266 +}
267 +
268 +.progress-container:hover {
269 +	cursor: grab;
270 +}
271 +
272 +.progress-bar {
273 +	width: 100%;
274 +	height: 4px;
275 +	background-color: var(--text);
276 +	position: relative;
277 +	border-radius: 1px;
278 +	--progress: 0;
279 +	--circle-size: 16px;
280 +}
281 +
282 +.progress-bar::before {
283 +	content: '';
284 +	position: absolute;
285 +	left: 0;
286 +	top: 0;
287 +	height: 100%;
288 +	width: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 + var(--circle-size) / 2);
289 +	background-color: var(--accent);
290 +	border-radius: 1px;
291 +	transition: none;
292 +}
293 +
294 +.progress-bar::after {
295 +	content: '';
296 +	position: absolute;
297 +	left: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 - 1px);
298 +	top: 50%;
299 +	transform: translateY(-50%);
300 +	width: var(--circle-size);
301 +	height: var(--circle-size);
302 +	background-color: var(--accent);
303 +	border-radius: 50%;
304 +	box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
305 +	transition: none;
306 +}
307 +
308 +.progress-text {
309 +	display: none;
310 +}
311 +
312 +.progress-text-left {
313 +	display: none;
314 +}
315 +
316 +.progress-text-center {
317 +	display: none;
318 +}
319 +
320 +#playPause {
321 +	background-image: url('play.png');
322 +	background-position: 52.5% center;
323 +}
324 +
325 +#playPause.pause {
326 +	background-image: url('pause.png');
327 +	background-position: center;
328 +}
329 +
330 +#prev {
331 +	background-image: url('prev.png');
332 +	background-position: 45% center;
333 +}
334 +
335 +#next {
336 +	background-image: url('next.png');
337 +	background-position: 55% center;
338 +}
script.js
  -// Register service worker for PWA functionality
  -if ('serviceWorker' in navigator) {
  -	window.addEventListener('load', () => {
  -		navigator.serviceWorker.register('service-worker.js')
  -			.then(registration => {
  -				console.log('Service Worker registered successfully:', registration.scope);
  -			})
  -			.catch(error => {
  -				console.log('Service Worker registration failed:', error);
  -			});
  -	});
  -}
  -
  -const playPauseBtn = document.getElementById('playPause');
  -const prevBtn = document.getElementById('prev');
  -const nextBtn = document.getElementById('next');
  -const playlist = document.getElementById('playlist');
  -const currentSongDisplay = document.getElementById('currentSong');
  -const progressBar = document.getElementById('progressBar');
  -const progressContainer = document.getElementById('progressContainer');
  -const audio = document.getElementById('audioPlayer');
  -audio.controls = true; // Enable controls for iOS media session
  -
  -const shuffle = false;
  -
  -function shuffleArray(array) {
  -	const shuffled = [...array];
  -	for (let i = shuffled.length - 1; i > 0; i--) {
  -		const j = Math.floor(Math.random() * (i + 1));
  -		[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  -	}
  -	return shuffled;
  -}
  -
  -let currentSongIndex = 0;
  -let isPlaying = false;
  -let progressInterval;
  -let playerReady = false;
  -let songs = [];
  -let animationFrameId = null;
  -let prePlaySeekTime = 0;
  -let preloadedAudio = {}; // Cache for preloaded audio elements
  -let currentPreloadIndex = 0;
  -let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
  -let isPreloadingPriority = false;
  -let totalBytesLoaded = 0; // Track total filesize of all preloaded songs
  -let cachedTracks = new Set(); // Track which songs are cached for offline use
  -let CACHE_NAME = null; // Will be loaded from manifest.json
  -
  -// Load cache name from manifest.json first, then load tracks
  -fetch('manifest.json')
  -	.then(response => response.json())
  -	.then(manifest => {
  -		CACHE_NAME = manifest.cache_name || manifest.name;
  -		console.log('Using cache name:', CACHE_NAME);
  -
  -		// Set page title from manifest
  -		if (manifest.name) {
  -			document.title = manifest.name;
  -		}
  -
  -		// Now that we have CACHE_NAME, load tracks
  -		return fetch('tracks/tracks.json');
  -	})
  -	.then(response => {
  -		if (!response.ok) {
  -			throw new Error('tracks.json not found');
  -		}
  -		return response.json();
  -	})
  -	.then(data => {
  -		songs = shuffle ? shuffleArray(data) : data;
  -		if (songs.length > 0) {
  -			playerReady = true;
  -			updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`);
  -			// Check which tracks are already cached before rendering
  -			return checkCachedTracks().then(() => {
  -				renderPlaylist();
  -				// Pre-cache resources first, then songs
  -				return preloadResources().then(() => {
  -					startPreloadingSongs();
  -				});
  -			});
  -		} else {
  -			updateCurrentSongDisplay('No tracks found');
  -		}
  -	})
  -	.catch(error => {
  -		console.error('Error loading manifest or tracks:', error);
  -		updateCurrentSongDisplay('Unable to load tracks. Please check your connection.');
  -	});
  -
  -// Audio event listeners
  -audio.addEventListener('play', () => {
  -	startProgressBar();
  -	const song = songs[currentSongIndex];
  -	const songText = `${song.artist} – ${song.title}`;
  -
  -	// Always update display to ensure we remove "Ready to play:" prefix
  -	const currentText = currentSongDisplay.querySelector('span')?.textContent || '';
  -
  -	// Check if it's a different song (not just play/pause of same song)
  -	const isNewSong = !currentText.includes(songText);
  -	const hasReadyToPlay = currentText.includes('Ready to play:');
  -
  -	if (isNewSong || hasReadyToPlay) {
  -		updateCurrentSongDisplay(songText);
  -	} else {
  -		// Same song, just resume the marquee
  -		resumeMarquee();
  -	}
  -
  -	// Update media session metadata
  -	if ('mediaSession' in navigator) {
  -		// Convert relative path to absolute URL for media session
  -		// Use document.baseURI to correctly resolve paths in subdirectories
  -		const albumArtUrl = new URL('resources/album_art.jpg', document.baseURI).href;
  -		navigator.mediaSession.metadata = new MediaMetadata({
  -			title: song.title,
  -			artist: song.artist,
  -			artwork: [
  -				{ src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' },
  -				{ src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' },
  -				{ src: albumArtUrl, sizes: '256x256', type: 'image/jpeg' },
  -				{ src: albumArtUrl, sizes: '128x128', type: 'image/jpeg' }
  -			]
  -		});
  -
  -		// Set action handlers after playback starts (required for iOS)
  -		// Explicitly set seek handlers to null so iOS shows next/prev instead
  -		navigator.mediaSession.setActionHandler('play', () => {
  -			if (playerReady && !isPlaying) {
  -				togglePlayPause();
  -			}
  -		});
  -
  -		navigator.mediaSession.setActionHandler('pause', () => {
  -			if (playerReady && isPlaying) {
  -				togglePlayPause();
  -			}
  -		});
  -
  -		navigator.mediaSession.setActionHandler('previoustrack', () => {
  -			if (playerReady) {
  -				prevSong();
  -			}
  -		});
  -
  -		navigator.mediaSession.setActionHandler('nexttrack', () => {
  -			if (playerReady) {
  -				nextSong();
  -			}
  -		});
  -
  -		// Explicitly set seek handlers to null to show track controls instead
  -		navigator.mediaSession.setActionHandler('seekbackward', null);
  -		navigator.mediaSession.setActionHandler('seekforward', null);
  -	}
  -});
  -
  -audio.addEventListener('pause', () => {
  -	stopProgressBar();
  -	pauseMarquee();
  -});
  -
  -audio.addEventListener('ended', () => {
  -	if (songs[currentSongIndex].looping) {
  -		audio.currentTime = 0;
  -		audio.play();
  -	} else {
  -		nextSong();
  -	}
  -});
  -
  -audio.addEventListener('error', (e) => {
  -	console.error('Audio error:', e);
  -	updateCurrentSongDisplay(`Error loading: ${songs[currentSongIndex].filename}`);
  -	// Try next song after a brief delay
  -	setTimeout(() => nextSong(), 1000);
  -});
  -
  -audio.addEventListener('loadedmetadata', () => {
  -	resetProgressBar();
  -});
  -
  -function renderPlaylist() {
  -	playlist.innerHTML = '';
  -	const currentDisplayText = currentSongDisplay.textContent;
  -	const isInitialized = currentDisplayText !== 'No song playing';
  -
  -	songs.forEach((song, index) => {
  -		const item = document.createElement('div');
  -		item.classList.add('playlist-item');
  -
  -		const contentDiv = document.createElement('div');
  -		contentDiv.classList.add('playlist-item-content');
  -
  -		const titleDiv = document.createElement('div');
  -		titleDiv.classList.add('playlist-item-title');
  -		if (isInitialized && index === currentSongIndex) {
  -			titleDiv.classList.add('current');
  -		}
  -		titleDiv.textContent = song.title;
  -
  -		const artistDiv = document.createElement('div');
  -		artistDiv.classList.add('playlist-item-artist');
  -		if (isInitialized && index === currentSongIndex) {
  -			artistDiv.classList.add('current');
  -		}
  -		artistDiv.textContent = song.artist;
  -
  -		// Set cached status for visual indication
  -		const isCached = cachedTracks.has(song.filename);
  -		if (!isCached) {
  -			contentDiv.classList.add('uncached');
  -		}
  -
  -		contentDiv.appendChild(titleDiv);
  -		contentDiv.appendChild(artistDiv);
  -
  -		const loopIcon = document.createElement('span');
  -		loopIcon.textContent = '🔁';
  -		loopIcon.style.display = (song.looping || false) ? 'inline' : 'none';
  -
  -		item.appendChild(contentDiv);
  -		item.appendChild(loopIcon);
  -		item.addEventListener('click', () => toggleLooping(index));
  -		playlist.appendChild(item);
  -	});
  -}
  -
  -function toggleLooping(index) {
  -	if (!playerReady) return;
  -	if (index === currentSongIndex) {
  -		if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
  -			audio.play().catch(err => {
  -				console.error('Failed to play audio:', err);
  -			});
  -			isPlaying = true;
  -			updatePlayPauseButton();
  -			return;
  -		}
  -		// Toggle looping
  -		songs[index].looping = !(songs[index].looping || false);
  -		renderPlaylist();
  -	} else {
  -		playSong(index);
  -	}
  -}
  -
  -function playSong(index) {
  -	console.log(`playSong called with index: ${index}`);
  -	if (!playerReady) {
  -		console.log('Player not ready');
  -		return;
  -	}
  -	// Clear looping from all songs except the new one if it was already looping
  -	const wasLooping = songs[index].looping || false;
  -	songs.forEach(song => song.looping = false);
  -	if (wasLooping) {
  -		songs[index].looping = true;
  -	}
  -
  -	currentSongIndex = index;
  -	const song = songs[currentSongIndex];
  -	console.log(`Attempting to play: ${song.artist} – ${song.title}`);
  -	console.log(`Filename: ${song.filename}`);
  -	console.log(`Is in preloadedAudio: ${!!preloadedAudio[song.filename]}`);
  -
  -	// Use preloaded blob if available, otherwise load from server
  -	if (preloadedAudio[song.filename]) {
  -		const blobUrl = preloadedAudio[song.filename].blobUrl;
  -		console.log(`Playing from preloaded blob: ${song.filename}`);
  -		console.log(`Blob URL: ${blobUrl}`);
  -		audio.src = blobUrl;
  -	} else {
  -		console.log(`Song not preloaded, loading: ${song.filename}`);
  -		audio.src = `tracks/${song.filename}`;
  -		// Request priority preloading for this song
  -		requestPriorityPreload(song.filename);
  -	}
  -
  -	console.log(`Audio src set to: ${audio.src}`);
  -
  -	// For iOS PWA: We need to call load() and play() synchronously
  -	// Reset any previous state first
  -	try {
  -		audio.pause();
  -		audio.currentTime = 0;
  -	} catch (e) {
  -		// Ignore errors from resetting
  -	}
  -
  -	// Load the audio to ensure it's ready (important for iOS PWA)
  -	audio.load();
  -
  -	// Small delay to let load() initialize, then play
  -	// This needs to be synchronous enough that iOS considers it part of the user gesture
  -	const playAttempt = audio.play();
  -
  -	if (playAttempt !== undefined) {
  -		playAttempt.then(() => {
  -			console.log('Audio playback started successfully');
  -			isPlaying = true;
  -			updatePlayPauseButton();
  -		}).catch(err => {
  -			console.error('Failed to play audio:', err);
  -			console.error('Error name:', err.name);
  -			console.error('Error message:', err.message);
  -			isPlaying = false;
  -			updatePlayPauseButton();
  -			updateCurrentSongDisplay(`Error playing: ${song.title}`);
  -		});
  -	}
  -
  -	// Optimistically set playing state
  -	isPlaying = true;
  -	updatePlayPauseButton();
  -	renderPlaylist();
  -}
  -
  -function updateCurrentSongDisplay(text) {
  -	currentSongDisplay.innerHTML = `<span>${text}</span>`;
  -	// Initialize marquee effect after a brief delay to ensure DOM is updated
  -	setTimeout(() => setupMarquee(), 50);
  -}
  -
  -// Marquee state
  -let marqueeAnimating = false;
  -let marqueePaused = false;
  -let marqueeTimeoutId = null;
  -let marqueeOriginalText = '';
  -let marqueeDuration = 0;
  -let marqueeHalfWidth = 0;
  -let marqueeTransitionEndHandler = null;
  -let marqueeCurrentTransform = 'translateX(0)';
  -
  -function setupMarquee() {
  -	const container = currentSongDisplay;
  -	const textSpan = container.querySelector('span');
  -
  -	if (!textSpan) return;
  -
  -	// Clear any existing animation
  -	if (marqueeTimeoutId) {
  -		clearTimeout(marqueeTimeoutId);
  -		marqueeTimeoutId = null;
  -	}
  -	if (marqueeTransitionEndHandler) {
  -		textSpan.removeEventListener('transitionend', marqueeTransitionEndHandler);
  -		marqueeTransitionEndHandler = null;
  -	}
  -	marqueeAnimating = false;
  -	marqueePaused = false;
  -
  -	// Reset styles
  -	textSpan.style.transition = 'none';
  -	textSpan.style.transform = 'translateX(0)';
  -	marqueeCurrentTransform = 'translateX(0)';
  -	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;
  -
  -	if (!overflows) {
  -		// No overflow - show ellipsis behavior
  -		container.classList.add('no-overflow');
  -		container.classList.remove('marquee-active');
  -		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
  -	textSpan.textContent = marqueeOriginalText + spacing + marqueeOriginalText + spacing;
  -
  -	// Calculate animation parameters
  -	const fullWidth = textSpan.scrollWidth;
  -	marqueeHalfWidth = fullWidth / 2;
  -	marqueeDuration = (marqueeHalfWidth / 50) * 1000; // Adjust speed here (pixels per second)
  -
  -	// Start animation after a brief delay (only if not paused)
  -	// Don't start animation if already paused
  -	if (!marqueePaused) {
  -		marqueeTimeoutId = setTimeout(() => {
  -			if (marqueePaused) return;
  -
  -			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;
  -
  -				textSpan.style.transition = 'none';
  -				textSpan.style.transform = 'translateX(0)';
  -				marqueeCurrentTransform = 'translateX(0)';
  -
  -				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
  -	}
  -}
  -
  -function pauseMarquee() {
  -	marqueePaused = true;
  -	const textSpan = currentSongDisplay.querySelector('span');
  -	if (!textSpan || !marqueeAnimating) return;
  -
  -	// Capture current transform position
  -	const computedStyle = window.getComputedStyle(textSpan);
  -	const currentTransform = computedStyle.transform;
  -	marqueeCurrentTransform = currentTransform;
  -
  -	// Freeze at current position
  -	textSpan.style.transition = 'none';
  -	textSpan.style.transform = currentTransform;
  -
  -	// Clear any pending timeout
  -	if (marqueeTimeoutId) {
  -		clearTimeout(marqueeTimeoutId);
  -		marqueeTimeoutId = null;
  -	}
  -}
  -
  -function resumeMarquee() {
  -	if (!marqueeAnimating) return;
  -	marqueePaused = false;
  -
  -	const textSpan = currentSongDisplay.querySelector('span');
  -	if (!textSpan) return;
  -
  -	// 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)`;
  -}
  -
  -// 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 = currentSongDisplay.querySelector('span');
  -		if (textSpan) {
  -			textSpan.textContent = marqueeOriginalText;
  -		}
  -
  -		setupMarquee();
  -
  -		// Restore paused state after recalculation
  -		if (wasPaused) {
  -			marqueePaused = true;
  -		}
  -	}
  -});
  -
  -function togglePlayPause() {
  -	if (!playerReady) return;
  -	if (isPlaying) {
  -		audio.pause();
  -		isPlaying = false;
  -	} else {
  -		// If no song is loaded, load the first one
  -		if (!audio.src || audio.src === '') {
  -			playSong(currentSongIndex);
  -		} else {
  -			audio.play().catch(err => {
  -				console.error('Failed to play audio:', err);
  -				const song = songs[currentSongIndex];
  -				updateCurrentSongDisplay(`Error playing: ${song.title}`);
  -			});
  -			isPlaying = true;
  -		}
  -	}
  -	updatePlayPauseButton();
  -}
  -
  -function updatePlayPauseButton() {
  -	playPauseBtn.classList.toggle('pause', isPlaying);
  -}
  -
  -function nextSong() {
  -	if (!playerReady) return;
  -	currentSongIndex = (currentSongIndex + 1) % songs.length;
  -	playSong(currentSongIndex);
  -}
  -
  -function prevSong() {
  -	if (!playerReady) return;
  -	if (audio.currentTime <= 3) {
  -		currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
  -		playSong(currentSongIndex);
  -	} else {
  -		audio.currentTime = 0;
  -	}
  -}
  -
  -function startProgressBar() {
  -	stopProgressBar();
  -
  -	function animate() {
  -		updateProgressBar();
  -		animationFrameId = requestAnimationFrame(animate);
  -	}
  -
  -	animate();
  -
  -	// iOS PWA background playback fix: use setInterval as backup
  -	// setInterval is less throttled than requestAnimationFrame in background
  -	backgroundPlaybackCheckInterval = setInterval(() => {
  -		if (!audio.paused && audio.duration && audio.currentTime >= audio.duration - 0.5) {
  -			console.log('Background check: song ended, triggering next');
  -			clearInterval(backgroundPlaybackCheckInterval);
  -			backgroundPlaybackCheckInterval = null;
  -
  -			if (songs[currentSongIndex].looping) {
  -				audio.currentTime = 0;
  -				audio.play();
  -			} else {
  -				nextSong();
  -			}
  -		}
  -
  -		// Update Media Session position state for iOS
  -		if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
  -			try {
  -				if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
  -					navigator.mediaSession.setPositionState({
  -						duration: audio.duration,
  -						playbackRate: audio.playbackRate,
  -						position: audio.currentTime
  -					});
  -				}
  -			} catch (e) {
  -				// Ignore errors from setPositionState
  -			}
  -		}
  -	}, 500); // Check every 500ms
  -}
  -
  -function stopProgressBar() {
  -	if (animationFrameId !== null) {
  -		cancelAnimationFrame(animationFrameId);
  -		animationFrameId = null;
  -	}
  -	if (backgroundPlaybackCheckInterval !== null) {
  -		clearInterval(backgroundPlaybackCheckInterval);
  -		backgroundPlaybackCheckInterval = null;
  -	}
  -}
  -
  -function resetProgressBar() {
  -	progressBar.style.setProperty('--progress', '0');
  -}
  -
  -function updateProgressBar() {
  -	if (audio.duration && !isDragging && !isSeeking) {
  -		const currentTime = audio.currentTime;
  -		const duration = audio.duration;
  -		const progressPercentage = (currentTime / duration) * 100;
  -		const displayPercentage = isNaN(progressPercentage) ? 0 : progressPercentage;
  -		progressBar.style.setProperty('--progress', displayPercentage);
  -	}
  -}
  -
  -let isDragging = false;
  -let wasPlayingBeforeDrag = false;
  -let pendingSeekPercentage = null;
  -let backgroundPlaybackCheckInterval = null;
  -
  -function updateVisualProgress(event) {
  -	if (!playerReady) return;
  -
  -	const rect = progressBar.getBoundingClientRect();
  -	// Support both mouse and touch events
  -	const clientX = event.touches ? event.touches[0].clientX : event.clientX;
  -	const clickPosition = clientX - rect.left;
  -	const clickPercentage = Math.max(0, Math.min(1, clickPosition / rect.width));
  -
  -	progressBar.style.setProperty('--progress', clickPercentage * 100);
  -	return clickPercentage;
  -}
  -
  -let isSeeking = false;
  -let targetSeekTime = null;
  -
  -function applySeek(clickPercentage) {
  -	if (!playerReady) return;
  -
  -	// If audio hasn't been loaded yet, load it but don't play
  -	if (!audio.src || audio.src === '') {
  -		const song = songs[currentSongIndex];
  -
  -		// Use preloaded blob if available, otherwise load from server
  -		if (preloadedAudio[song.filename]) {
  -			audio.src = preloadedAudio[song.filename].blobUrl;
  -		} else {
  -			audio.src = `tracks/${song.filename}`;
  -		}
  -
  -		// Wait for metadata to be loaded before seeking
  -		audio.addEventListener('loadedmetadata', function setInitialTime() {
  -			const duration = audio.duration;
  -			const seekTime = duration * clickPercentage;
  -			attemptSeekWithRetry(seekTime, clickPercentage);
  -			prePlaySeekTime = seekTime;
  -			audio.removeEventListener('loadedmetadata', setInitialTime);
  -		}, { once: true });
  -	} else if (audio.duration) {
  -		const duration = audio.duration;
  -		const seekTime = duration * clickPercentage;
  -		attemptSeekWithRetry(seekTime, clickPercentage);
  -		prePlaySeekTime = seekTime;
  -	}
  -}
  -
  -function isTimeBuffered(time) {
  -	// Check if the given time is within any buffered time range
  -	for (let i = 0; i < audio.buffered.length; i++) {
  -		if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
  -			return true;
  -		}
  -	}
  -	return false;
  -}
  -
  -function attemptSeekWithRetry(seekTime, targetPercentage) {
  -	targetSeekTime = seekTime;
  -	isSeeking = true;
  -
  -	// Lock the progress bar at the target position
  -	progressBar.style.setProperty('--progress', targetPercentage * 100);
  -
  -	const wasPlaying = !audio.paused;
  -
  -	// Try to seek
  -	audio.currentTime = seekTime;
  -
  -	// Handler to check if we reached the target after seeking completes
  -	function checkSeekSuccess() {
  -		// Allow small tolerance for floating point comparison
  -		if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
  -			// Browser clamped to buffered range - need to wait for more data
  -			// Now pause during seeking
  -			if (wasPlaying) {
  -				audio.pause();
  -			}
  -			continueSeekingToTarget(wasPlaying);
  -		} else {
  -			// Successfully reached target immediately (was already buffered)
  -			isSeeking = false;
  -			targetSeekTime = null;
  -			// No need to update display - the seek was instant and playback continues normally
  -		}
  -	}
  -
  -	audio.addEventListener('seeked', checkSeekSuccess, { once: true });
  -}
  -
  -function continueSeekingToTarget(wasPlaying) {
  -	// Handler for when more data loads
  -	function retrySeek() {
  -		if (!isSeeking || targetSeekTime === null) {
  -			return; // Seeking was cancelled
  -		}
  -
  -		audio.currentTime = targetSeekTime;
  -
  -		// Check again after this seek completes
  -		function checkAgain() {
  -			if (!isSeeking || targetSeekTime === null) {
  -				return;
  -			}
  -
  -			if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
  -				// Still not there, keep trying
  -				continueSeekingToTarget(wasPlaying);
  -			} else {
  -				// Success!
  -				isSeeking = false;
  -				targetSeekTime = null;
  -
  -				if (wasPlaying) {
  -					audio.play();
  -				}
  -			}
  -		}
  -
  -		audio.addEventListener('seeked', checkAgain, { once: true });
  -	}
  -
  -	// Wait for more data to load, then try again
  -	audio.addEventListener('progress', retrySeek, { once: true });
  -
  -	// Also set a timeout fallback in case progress doesn't fire
  -	setTimeout(() => {
  -		if (isSeeking && targetSeekTime !== null && Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
  -			retrySeek();
  -		}
  -	}, 1000);
  -}
  -
  -function onProgressMouseDown(event) {
  -	if (!playerReady) return;
  -	isDragging = true;
  -	wasPlayingBeforeDrag = isPlaying;
  -	progressContainer.style.cursor = 'grabbing';
  -	document.body.style.cursor = 'grabbing';
  -	pendingSeekPercentage = updateVisualProgress(event);
  -	event.preventDefault();
  -}
  -
  -function onProgressMouseMove(event) {
  -	if (isDragging) {
  -		pendingSeekPercentage = updateVisualProgress(event);
  -	}
  -}
  -
  -function onProgressMouseUp(event) {
  -	if (isDragging) {
  -		isDragging = false;
  -		progressContainer.style.cursor = '';
  -		document.body.style.cursor = '';
  -
  -		// Apply the seek now that drag is complete
  -		if (pendingSeekPercentage !== null) {
  -			applySeek(pendingSeekPercentage);
  -			pendingSeekPercentage = null;
  -		}
  -
  -		// If it was "Ready to play" (not playing before), start playing now
  -		if (!wasPlayingBeforeDrag && !isPlaying && audio.src) {
  -			audio.play();
  -			isPlaying = true;
  -			updatePlayPauseButton();
  -		}
  -	}
  -}
  -
  -playPauseBtn.addEventListener('click', togglePlayPause);
  -nextBtn.addEventListener('click', nextSong);
  -prevBtn.addEventListener('click', prevSong);
  -
  -// Mouse events for desktop
  -progressContainer.addEventListener('mousedown', onProgressMouseDown);
  -document.addEventListener('mousemove', onProgressMouseMove);
  -document.addEventListener('mouseup', onProgressMouseUp);
  -
  -// Touch events for mobile
  -progressContainer.addEventListener('touchstart', onProgressMouseDown, { passive: false });
  -document.addEventListener('touchmove', onProgressMouseMove, { passive: false });
  -document.addEventListener('touchend', onProgressMouseUp);
  -
  -// Keyboard controls
  -document.addEventListener('keydown', function(event) {
  -	if (!playerReady) return;
  -
  -	// Spacebar: play/pause
  -	if (event.code === 'Space') {
  -		event.preventDefault();
  -		togglePlayPause();
  -	}
  -});
  -
  -// Pre-caching system
  -function preloadResources() {
  -	console.log('Preloading UI resources...');
  -
  -	const resources = [
  -		'resources/play.png',
  -		'resources/pause.png',
  -		'resources/prev.png',
  -		'resources/next.png',
  -		'resources/album_art.jpg'
  -	];
  -
  -	const imagePromises = resources.map(src => {
  -		return new Promise((resolve, reject) => {
  -			const img = new Image();
  -			img.onload = () => {
  -				console.log(`Preloaded: ${src}`);
  -				resolve();
  -			};
  -			img.onerror = () => {
  -				console.error(`Failed to preload: ${src}`);
  -				resolve(); // Resolve anyway to not block other resources
  -			};
  -			img.src = src;
  -		});
  -	});
  -
  -	return Promise.all(imagePromises).then(() => {
  -		console.log('All UI resources preloaded');
  -	});
  -}
  -
  -function startPreloadingSongs() {
  -	// Start with the first song
  -	currentPreloadIndex = 0;
  -	preloadNextSong();
  -}
  -
  -function preloadNextSong() {
  -	if (currentPreloadIndex >= songs.length) {
  -		const totalMB = (totalBytesLoaded / 1024 / 1024).toFixed(2);
  -		console.log(`All songs preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`);
  -		return;
  -	}
  -
  -	const song = songs[currentPreloadIndex];
  -	const filename = song.filename;
  -
  -	// Skip if already preloaded in memory
  -	if (preloadedAudio[filename]) {
  -		currentPreloadIndex++;
  -		preloadNextSong();
  -		return;
  -	}
  -
  -	// If already cached, load from cache into memory
  -	if (cachedTracks.has(filename)) {
  -		console.log(`Loading from cache: ${song.artist} – ${song.title}`);
  -		loadFromCache(filename).then(() => {
  -			currentPreloadIndex++;
  -			setTimeout(() => preloadNextSong(), 100);
  -		}).catch(err => {
  -			console.error(`Failed to load from cache, fetching instead:`, err);
  -			// If cache load fails, fetch from network
  -			fetchAndPreloadSong(song, filename);
  -		});
  -		return;
  -	}
  -
  -	console.log(`Preloading: ${song.artist} – ${song.title}`);
  -	fetchAndPreloadSong(song, filename);
  -}
  -
  -function fetchAndPreloadSong(song, filename) {
  -	// Use fetch to force full download of the entire file
  -	fetch(`tracks/${filename}`)
  -		.then(response => {
  -			if (!response.ok) {
  -				throw new Error(`HTTP error! status: ${response.status}`);
  -			}
  -			// Get total file size for progress tracking
  -			const contentLength = response.headers.get('content-length');
  -			console.log(`Downloading ${song.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`);
  -
  -			// Read the entire response as a blob
  -			return response.blob();
  -		})
  -		.then(blob => {
  -			// Add blob size to total
  -			totalBytesLoaded += blob.size;
  -
  -			// Create a blob URL that will persist in memory
  -			const blobUrl = URL.createObjectURL(blob);
  -
  -			preloadedAudio[filename] = {
  -				blobUrl: blobUrl,
  -				blob: blob
  -			};
  -
  -			console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`);
  -
  -			// Store in Cache API for offline access
  -			return storeBlobInCache(filename, blob).then(() => {
  -				// Mark as cached and update UI
  -				cachedTracks.add(filename);
  -				updateTrackCachedStatus(filename);
  -
  -				// Move to next song
  -				currentPreloadIndex++;
  -				setTimeout(() => preloadNextSong(), 100);
  -			});
  -		})
  -		.catch(error => {
  -			console.error(`Failed to preload ${filename}:`, error);
  -			currentPreloadIndex++;
  -			preloadNextSong();
  -		});
  -}
  -
  -// Check which tracks are already cached on app load
  -async function checkCachedTracks() {
  -	try {
  -		const cache = await caches.open(CACHE_NAME);
  -		const cachedRequests = await cache.keys();
  -
  -		// Check each song to see if it's cached
  -		for (const song of songs) {
  -			// Build the same absolute URL that storeBlobInCache uses for consistency
  -			const absoluteUrl = new URL(`tracks/${song.filename}`, window.location.href).href;
  -			const isInCache = cachedRequests.some(request => request.url === absoluteUrl);
  -			if (isInCache) {
  -				cachedTracks.add(song.filename);
  -			}
  -		}
  -
  -		console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`);
  -		console.log('Cached tracks:', Array.from(cachedTracks));
  -	} catch (error) {
  -		console.error('Failed to check cached tracks:', error);
  -	}
  -}
  -
  -// Debug function to check preloaded state
  -window.debugAudioState = function() {
  -	console.log('=== Audio State Debug ===');
  -	console.log('Player ready:', playerReady);
  -	console.log('Is playing:', isPlaying);
  -	console.log('Current song index:', currentSongIndex);
  -	console.log('Total songs:', songs.length);
  -	console.log('Cached tracks count:', cachedTracks.size);
  -	console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
  -	console.log('Current audio src:', audio.src);
  -	console.log('Audio paused:', audio.paused);
  -	console.log('Audio error:', audio.error);
  -	if (songs[currentSongIndex]) {
  -		console.log('Current song:', songs[currentSongIndex].filename);
  -		console.log('Is preloaded:', !!preloadedAudio[songs[currentSongIndex].filename]);
  -		console.log('Is cached:', cachedTracks.has(songs[currentSongIndex].filename));
  -	}
  -	console.log('======================');
  -};
  -
  -// Load a track from cache into memory
  -async function loadFromCache(filename) {
  -	try {
  -		const cache = await caches.open(CACHE_NAME);
  -		// Try both relative and absolute URLs
  -		let response = await cache.match(`tracks/${filename}`);
  -		if (!response) {
  -			// Try with absolute URL
  -			const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
  -			response = await cache.match(absoluteUrl);
  -		}
  -
  -		if (!response) {
  -			throw new Error('Not in cache');
  -		}
  -
  -		const blob = await response.blob();
  -
  -		// Add blob size to total
  -		totalBytesLoaded += blob.size;
  -
  -		// Create a blob URL that will persist in memory
  -		const blobUrl = URL.createObjectURL(blob);
  -
  -		preloadedAudio[filename] = {
  -			blobUrl: blobUrl,
  -			blob: blob
  -		};
  -		console.log(`✓ Loaded from cache: ${filename}`);
  -	} catch (error) {
  -		console.error(`Failed to load from cache: ${filename}`, error);
  -		throw error;
  -	}
  -}
  -
  -// Store blob in Cache API for offline access
  -async function storeBlobInCache(filename, blob) {
  -	try {
  -		const cache = await caches.open(CACHE_NAME);
  -		const response = new Response(blob, {
  -			headers: {
  -				'Content-Type': 'audio/mpeg',
  -				'Content-Length': blob.size
  -			}
  -		});
  -		// Use absolute URL for consistency
  -		const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
  -		await cache.put(absoluteUrl, response);
  -		console.log(`✓ Cached for offline: ${filename}`);
  -	} catch (error) {
  -		console.error(`Failed to cache ${filename}:`, error);
  -	}
  -}
  -
  -// Update UI to show track is cached
  -function updateTrackCachedStatus(filename) {
  -	const songIndex = songs.findIndex(s => s.filename === filename);
  -	if (songIndex === -1) return;
  -
  -	// Find the playlist item and remove uncached class
  -	const playlistItems = playlist.querySelectorAll('.playlist-item');
  -	if (playlistItems[songIndex]) {
  -		const contentDiv = playlistItems[songIndex].querySelector('.playlist-item-content');
  -		if (contentDiv) {
  -			contentDiv.classList.remove('uncached');
  -		}
  -	}
  -}
  -
  -// Priority preloading system
  -function requestPriorityPreload(filename) {
  -	// Skip if already preloaded or already in priority queue
  -	if (preloadedAudio[filename] || priorityPreloadQueue.includes(filename)) {
  -		return;
  -	}
  -
  -	console.log(`🔥 Priority preload requested: ${filename}`);
  -	priorityPreloadQueue.push(filename);
  -
  -	// Start priority preloading if not already running
  -	if (!isPreloadingPriority) {
  -		processPriorityPreload();
  -	}
  -}
  -
  -function processPriorityPreload() {
  -	if (priorityPreloadQueue.length === 0) {
  -		isPreloadingPriority = false;
  -		return;
  -	}
  -
  -	isPreloadingPriority = true;
  -	const filename = priorityPreloadQueue.shift();
  -
  -	// Check if already preloaded (might have finished during normal preloading)
  -	if (preloadedAudio[filename]) {
  -		processPriorityPreload();
  -		return;
  -	}
  -
  -	// Find the song info
  -	const song = songs.find(s => s.filename === filename);
  -	if (!song) {
  -		processPriorityPreload();
  -		return;
  -	}
  -
  -	// If already cached, load from cache
  -	if (cachedTracks.has(filename)) {
  -		console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`);
  -		loadFromCache(filename).then(() => {
  -			processPriorityPreload();
  -		}).catch(err => {
  -			console.error(`Failed to load from cache, fetching instead:`, err);
  -			priorityFetchAndPreloadSong(song, filename);
  -		});
  -		return;
  -	}
  -
  -	console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`);
  -	priorityFetchAndPreloadSong(song, filename);
  -}
  -
  -function priorityFetchAndPreloadSong(song, filename) {
  -	// Use fetch to force full download of the entire file
  -	fetch(`tracks/${filename}`)
  -		.then(response => {
  -			if (!response.ok) {
  -				throw new Error(`HTTP error! status: ${response.status}`);
  -			}
  -			return response.blob();
  -		})
  -		.then(blob => {
  -			// Create a blob URL that will persist in memory
  -			const blobUrl = URL.createObjectURL(blob);
  -
  -			preloadedAudio[filename] = {
  -				blobUrl: blobUrl,
  -				blob: blob
  -			};
  -
  -			// Next time this song plays, it will use the cached version
  -			console.log(`✓ Priority preloaded: ${song.artist} – ${song.title}`);
  -
  -			// Store in Cache API for offline access
  -			return storeBlobInCache(filename, blob).then(() => {
  -				// Mark as cached and update UI
  -				cachedTracks.add(filename);
  -				updateTrackCachedStatus(filename);
  -
  -				// Process next priority request
  -				processPriorityPreload();
  -			});
  -		})
  -		.catch(error => {
  -			// Ignore abort errors (happens when normal preload finishes first)
  -			if (error.name === 'AbortError' || error.message.includes('aborted')) {
  -				// This is expected - normal preloading probably finished first
  -			} else {
  -				console.error(`Failed to priority preload ${filename}:`, error);
  -			}
  -			processPriorityPreload();
  -		});
  -}
styles.css
  -:root {
  -	--text: #dddddd;
  -	--dimmed-text: #ddddddcc;
  -	--primary: #4c7ae6;
  -	--accent: #f421ff;
  -	--background: #080a0c;
  -}
  -
  -* {
  -	scrollbar-color: var(--dimmed-text) var(--background);
  -	user-select: none;
  -	-webkit-user-select: none;
  -	-webkit-touch-callout: none;
  -	-webkit-tap-highlight-color: transparent;
  -}
  -
  -html {
  -	scroll-behavior: smooth;
  -	overscroll-behavior: none;
  -	-ms-overflow-style: none;
  -	scrollbar-width: none;
  -}
  -
  -html::-webkit-scrollbar {
  -	display: none;
  -}
  -
  -.playlist-wrapper::-webkit-scrollbar {
  -	display: none;
  -}
  -
  -body {
  -	margin: 0;
  -	padding: 0;
  -	font-family: "MS Sans Serif", Arial, sans-serif;
  -	height: 100dvh;
  -	display: flex;
  -	flex-direction: column;
  -	font-size: 25px;
  -	background-color: var(--background);
  -	position: fixed;
  -	width: 100%;
  -}
  -
  -.window-container {
  -	background: transparent;
  -	height: 100%;
  -	display: flex;
  -	flex-direction: column;
  -}
  -
  -.player-container {
  -	flex-grow: 1;
  -	display: flex;
  -	flex-direction: column;
  -	overflow: hidden;
  -	position: relative;
  -	padding-top: 10px;
  -}
  -
  -.controls {
  -	display: flex;
  -	justify-content: center;
  -	background: transparent;
  -	position: absolute;
  -	bottom: 0;
  -	left: 0;
  -	right: 0;
  -	box-sizing: border-box;
  -	z-index: 3;
  -	pointer-events: none;
  -}
  -
  -.control-button {
  -	width: clamp(60px, calc((100vw - 58px) / 3), 120px);
  -	aspect-ratio: 8 / 3;
  -	background-size: contain;
  -	background-repeat: no-repeat;
  -	background-position: center;
  -	border: none;
  -	cursor: pointer;
  -	margin: 0;
  -	border-radius: 50%;
  -	background-color: var(--text);
  -	box-shadow:
  -		inset -2px -2px 4px rgba(0, 0, 0, 0.3),
  -		inset 2px 2px 4px rgba(255, 255, 255, 0.4),
  -		0 2px 4px rgba(0, 0, 0, 0.5),
  -		0 4px 8px rgba(0, 0, 0, 0.3);
  -	filter:
  -		drop-shadow(0 0 1px rgba(0, 0, 0, 0.8))
  -		drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.6))
  -		drop-shadow(-0.5px -0.5px 0 rgba(255, 255, 255, 0.2));
  -	pointer-events: auto;
  -}
  -
  -.controls-inner {
  -	display: flex;
  -	justify-content: space-between;
  -	padding: 10px 15px;
  -	pointer-events: auto;
  -	width: 100%;
  -	box-sizing: border-box;
  -}
  -
  -@media (min-width: 420px) {
  -	.controls-inner {
  -		justify-content: center;
  -		gap: 14px;
  -		width: auto;
  -	}
  -
  -	.control-button {
  -		width: 120px;
  -	}
  -}
  -
  -.playlist-wrapper {
  -	flex-grow: 1;
  -	position: relative;
  -	overflow-y: auto;
  -	padding-bottom: 70px;
  -	overscroll-behavior: none;
  -	margin-top: -1px;
  -}
  -
  -.playlist {
  -	padding-top: 0;
  -	cursor: initial;
  -}
  -
  -.playlist-item {
  -	display: flex;
  -	justify-content: space-between;
  -	align-items: center;
  -	color: var(--text);
  -	min-height: 45px;
  -	padding-left: 15px;
  -	padding-right: 10px;
  -	padding-bottom: 18px;
  -}
  -
  -.playlist-item-content {
  -	flex-grow: 1;
  -	display: flex;
  -	flex-direction: column;
  -	gap: 2px;
  -	transition: opacity 0.3s ease;
  -}
  -
  -.playlist-item-content.uncached {
  -	opacity: 0.4;
  -}
  -
  -.playlist-item-title {
  -	font-size: 21px;
  -	line-height: 1.2;
  -}
  -
  -.playlist-item-title.current {
  -	font-weight: bold;
  -	color: var(--primary);
  -}
  -
  -.playlist-item-artist {
  -	font-size: 17px;
  -	color: var(--dimmed-text);
  -	line-height: 1.2;
  -}
  -
  -.playlist-item-artist.current {
  -	font-weight: bold;
  -	color: var(--primary);
  -	opacity: 1;
  -}
  -
  -@media (hover: hover) and (pointer: fine) {
  -	.playlist-item:hover {
  -		cursor: pointer;
  -		color: var(--accent);
  -	}
  -
  -	.playlist-item:hover .playlist-item-artist {
  -		color: var(--accent);
  -	}
  -
  -	.playlist-item:hover .playlist-item-title.current,
  -	.playlist-item:hover .playlist-item-artist.current {
  -		color: var(--accent);
  -	}
  -}
  -
  -.current-song {
  -	text-align: left;
  -	padding-left: 15px;
  -	padding-bottom: 10px;
  -	font-weight: bold;
  -	color: var(--primary);
  -	min-height: 35px;
  -	display: flex;
  -	align-items: center;
  -	background-color: var(--background);
  -	z-index: 2;
  -	position: relative;
  -}
  -
  -.current-song span {
  -	display: inline-block;
  -	white-space: nowrap;
  -	will-change: transform;
  -}
  -
  -.current-song.no-overflow span {
  -	overflow: hidden;
  -	text-overflow: hidden;
  -	max-width: 100%;
  -}
  -
  -#audioPlayer {
  -	display: none;
  -}
  -
  -.progress-container {
  -	width: 100%;
  -	font-size: 24px;
  -	cursor: grab;
  -	margin-top: 0;
  -	height: 35px;
  -	display: flex;
  -	align-items: center;
  -	padding: 0 15px;
  -	padding-bottom: 7px;
  -	box-sizing: border-box;
  -	position: relative;
  -	background-color: var(--background);
  -	z-index: 2;
  -}
  -
  -.progress-container::before {
  -	content: '';
  -	position: absolute;
  -	left: 15px;
  -	right: 15px;
  -	top: 50%;
  -	transform: translateY(-50%);
  -	margin-top: -3.5px;
  -	height: 24px;
  -	pointer-events: auto;
  -}
  -
  -.shadow-overlay {
  -	position: sticky;
  -	top: -1px;
  -	left: 0;
  -	right: 0;
  -	height: 14px;
  -	background: linear-gradient(
  -		to bottom,
  -		var(--background) 0%,
  -		rgba(8, 10, 12, 0.5) 40%,
  -		rgba(8, 10, 12, 0.2) 70%,
  -		transparent 100%
  -	);
  -	pointer-events: none;
  -	z-index: 2;
  -}
  -
  -.progress-container:hover {
  -	cursor: grab;
  -}
  -
  -.progress-bar {
  -	width: 100%;
  -	height: 4px;
  -	background-color: var(--text);
  -	position: relative;
  -	border-radius: 1px;
  -	--progress: 0;
  -	--circle-size: 16px;
  -}
  -
  -.progress-bar::before {
  -	content: '';
  -	position: absolute;
  -	left: 0;
  -	top: 0;
  -	height: 100%;
  -	width: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 + var(--circle-size) / 2);
  -	background-color: var(--accent);
  -	border-radius: 1px;
  -	transition: none;
  -}
  -
  -.progress-bar::after {
  -	content: '';
  -	position: absolute;
  -	left: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 - 1px);
  -	top: 50%;
  -	transform: translateY(-50%);
  -	width: var(--circle-size);
  -	height: var(--circle-size);
  -	background-color: var(--accent);
  -	border-radius: 50%;
  -	box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
  -	transition: none;
  -}
  -
  -.progress-text {
  -	display: none;
  -}
  -
  -.progress-text-left {
  -	display: none;
  -}
  -
  -.progress-text-center {
  -	display: none;
  -}
  -
  -#playPause {
  -	background-image: url('resources/play.png');
  -	background-position: 52.5% center;
  -}
  -
  -#playPause.pause {
  -	background-image: url('resources/pause.png');
  -	background-position: center;
  -}
  -
  -#prev {
  -	background-image: url('resources/prev.png');
  -	background-position: 45% center;
  -}
  -
  -#next {
  -	background-image: url('resources/next.png');
  -	background-position: 55% center;
  -}