script.js (35.7 KB)


   1 const hostname = location.hostname;
   2 const isLocal =
   3 	hostname === 'localhost' ||
   4 	hostname === '127.0.0.1' ||
   5 	hostname === '::1' ||
   6 	/^10\./.test(hostname) ||
   7 	/^192\.168\./.test(hostname) ||
   8 	/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
   9 	/^169\.254\./.test(hostname);
  10 
  11 // In browser tab, don't hit cache.
  12 // When installed as a PWA, always hit cache.
  13 const isInstalled =
  14 	window.matchMedia('(display-mode: standalone)').matches ||
  15 	window.matchMedia('(display-mode: fullscreen)').matches ||
  16 	window.matchMedia('(display-mode: minimal-ui)').matches ||
  17 	window.navigator.standalone === true;
  18 
  19 if ('serviceWorker' in navigator && isInstalled && !isLocal) {
  20 	window.addEventListener('load', () => {
  21 		navigator.serviceWorker.register('service-worker.js')
  22 			.then(registration => {
  23 				console.log('Service Worker registered successfully:', registration.scope);
  24 			})
  25 			.catch(error => {
  26 				console.log('Service Worker registration failed:', error);
  27 			});
  28 	});
  29 } else if ('serviceWorker' in navigator) {
  30 	// Browser tab or local dev: tear down any existing SW and caches so
  31 	// the page always reflects the latest deploy.
  32 	navigator.serviceWorker.getRegistrations().then(registrations => {
  33 		registrations.forEach(r => r.unregister());
  34 	});
  35 	caches.keys().then(keys => {
  36 		keys.forEach(k => caches.delete(k));
  37 	});
  38 }
  39 
  40 const playPauseBtn = document.getElementById('playPause');
  41 const prevBtn = document.getElementById('prev');
  42 const nextBtn = document.getElementById('next');
  43 const playlist = document.getElementById('playlist');
  44 const playlistWrapper = document.querySelector('.playlist-wrapper');
  45 const controls = document.querySelector('.controls');
  46 const currentTrackDisplay = document.getElementById('currentTrack');
  47 const progressBar = document.getElementById('progressBar');
  48 const progressContainer = document.getElementById('progressContainer');
  49 const audio = document.getElementById('audioPlayer');
  50 audio.controls = true; // Enable controls for iOS media session
  51 
  52 const shuffle = false;
  53 
  54 function shuffleArray(array) {
  55 	const shuffled = [...array];
  56 	for (let i = shuffled.length - 1; i > 0; i--) {
  57 		const j = Math.floor(Math.random() * (i + 1));
  58 		[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  59 	}
  60 	return shuffled;
  61 }
  62 
  63 let currentTrackIndex = 0;
  64 let isPlaying = false;
  65 let canReorder = false;
  66 let progressInterval;
  67 let playerReady = false;
  68 let tracks = [];
  69 let animationFrameId = null;
  70 let prePlaySeekTime = 0;
  71 let preloadedAudio = {}; // Cache for preloaded audio elements
  72 let currentPreloadIndex = 0;
  73 let priorityPreloadQueue = []; // Tracks requested by user that need priority preloading
  74 let isPreloadingPriority = false;
  75 let totalBytesLoaded = 0; // Track total filesize of all preloaded tracks
  76 let cachedTracks = new Set(); // Track which tracks are cached for offline use
  77 let CACHE_NAME = 'my-mixapp'; // Default fallback
  78 const staticFiles = [
  79 	'./',
  80 	'index.html',
  81 	'resources/styles.css',
  82 	'resources/script.js',
  83 	'mix/tracks.json',
  84 	'resources/icon.png',
  85 	'resources/play.svg',
  86 	'resources/pause.svg',
  87 	'resources/prev.svg',
  88 	'resources/next.svg',
  89 	'resources/repeat.svg',
  90 	'resources/fonts/Basteleur/Basteleur-Moonlight.woff2',
  91 ];
  92 
  93 // Retry fetch with exponential backoff
  94 async function fetchWithRetry(url, maxRetries = 4, baseDelay = 2000) {
  95 	for (let attempt = 0; attempt <= maxRetries; attempt++) {
  96 		try {
  97 			const response = await fetch(url);
  98 			if (!response.ok) {
  99 				throw new Error(`HTTP error! status: ${response.status}`);
 100 			}
 101 			return response;
 102 		} catch (error) {
 103 			if (attempt === maxRetries) throw error;
 104 			const delay = baseDelay * Math.pow(2, attempt);
 105 			console.warn(`Fetch failed for ${url}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...`);
 106 			await new Promise(resolve => setTimeout(resolve, delay));
 107 		}
 108 	}
 109 }
 110 
 111 // Enable reordering UI only when served locally
 112 if (isLocal) {
 113 	canReorder = true;
 114 	document.body.classList.add('reorderable');
 115 }
 116 
 117 // Load cache name from manifest.json first, then load tracks
 118 fetch('manifest.json')
 119 	.then(response => {
 120 		if (!response.ok) return null;
 121 		return response.json();
 122 	})
 123 	.catch(() => null)
 124 	.then(manifest => {
 125 		if (manifest) {
 126 			CACHE_NAME = manifest.cache_name || manifest.name || CACHE_NAME;
 127 			console.log('Using cache name:', CACHE_NAME);
 128 
 129 			if (manifest.name) {
 130 				document.title = manifest.name;
 131 			}
 132 		}
 133 
 134 		// Probe for optional files and add them to staticFiles
 135 		const optionalFiles = ['mix/album_art.jpg', 'mix/custom.css', 'mix/custom.js'];
 136 		return Promise.all([
 137 			...optionalFiles.map(f =>
 138 				fetch(f, { method: 'HEAD' })
 139 					.then(r => { if (r.ok) staticFiles.push(f); })
 140 					.catch(() => {})
 141 			),
 142 			fetch('mix/tracks.json')
 143 				.then(r => {
 144 					if (!r.ok) throw new Error('tracks.json not found');
 145 					return r.json();
 146 				})
 147 				.catch(() => {
 148 					// Offline fallback: try loading from cache directly
 149 					return caches.open(CACHE_NAME)
 150 						.then(cache => cache.match('mix/tracks.json'))
 151 						.then(r => r ? r.json() : Promise.reject('tracks.json not in cache'));
 152 				})
 153 		]);
 154 	})
 155 	.then((results) => {
 156 		const data = results[results.length - 1];
 157 
 158 		tracks = shuffle ? shuffleArray(data) : data;
 159 		if (tracks.length > 0) {
 160 			playerReady = true;
 161 			updateCurrentTrackDisplay(`Ready to play: ${tracks[0].artist} – ${tracks[0].title}`);
 162 			// Check which tracks are already cached before rendering
 163 			return checkCachedTracks().then(() => {
 164 				renderPlaylist();
 165 				// Verify all static files are cached, then start caching tracks
 166 				return verifyStaticCache().then(() => {
 167 					startPreloadingTracks();
 168 				});
 169 			});
 170 		} else {
 171 			updateCurrentTrackDisplay('No tracks found');
 172 		}
 173 	})
 174 	.catch(error => {
 175 		console.error('Error loading tracks:', error);
 176 		updateCurrentTrackDisplay('Unable to load tracks. Please check your connection.');
 177 	});
 178 
 179 // Audio event listeners
 180 audio.addEventListener('play', () => {
 181 	isPlaying = true;
 182 	updatePlayPauseButton();
 183 	startProgressBar();
 184 	const track = tracks[currentTrackIndex];
 185 	const trackText = `${track.artist} – ${track.title}`;
 186 
 187 	// Always update display to ensure we remove "Ready to play:" prefix
 188 	const currentText = currentTrackDisplay.querySelector('span')?.textContent || '';
 189 
 190 	// Check if it's a different track (not just play/pause of same track)
 191 	const isNewTrack = !currentText.includes(trackText);
 192 	const hasReadyToPlay = currentText.includes('Ready to play:');
 193 
 194 	if (isNewTrack || hasReadyToPlay) {
 195 		updateCurrentTrackDisplay(trackText);
 196 	} else {
 197 		// Same track, just resume the marquee
 198 		resumeMarquee();
 199 	}
 200 
 201 	// Update media session metadata
 202 	if ('mediaSession' in navigator) {
 203 		// Convert relative path to absolute URL for media session
 204 		// Use document.baseURI to correctly resolve paths in subdirectories
 205 		const albumArtUrl = new URL('mix/album_art.jpg', document.baseURI).href;
 206 		navigator.mediaSession.metadata = new MediaMetadata({
 207 			title: track.title,
 208 			artist: track.artist,
 209 			artwork: [
 210 				{ src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' },
 211 				{ src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' },
 212 				{ src: albumArtUrl, sizes: '256x256', type: 'image/jpeg' },
 213 				{ src: albumArtUrl, sizes: '128x128', type: 'image/jpeg' }
 214 			]
 215 		});
 216 
 217 		// Set action handlers after playback starts (required for iOS)
 218 		// Explicitly set seek handlers to null so iOS shows next/prev instead
 219 		navigator.mediaSession.setActionHandler('play', () => {
 220 			if (playerReady && !isPlaying) {
 221 				togglePlayPause();
 222 			}
 223 		});
 224 
 225 		navigator.mediaSession.setActionHandler('pause', () => {
 226 			if (playerReady && isPlaying) {
 227 				togglePlayPause();
 228 			}
 229 		});
 230 
 231 		navigator.mediaSession.setActionHandler('previoustrack', () => {
 232 			if (playerReady) {
 233 				prevTrack();
 234 			}
 235 		});
 236 
 237 		navigator.mediaSession.setActionHandler('nexttrack', () => {
 238 			if (playerReady) {
 239 				nextTrack();
 240 			}
 241 		});
 242 
 243 		// Explicitly set seek handlers to null to show track controls instead
 244 		navigator.mediaSession.setActionHandler('seekbackward', null);
 245 		navigator.mediaSession.setActionHandler('seekforward', null);
 246 	}
 247 });
 248 
 249 audio.addEventListener('pause', () => {
 250 	isPlaying = false;
 251 	updatePlayPauseButton();
 252 	stopProgressBar();
 253 	pauseMarquee();
 254 });
 255 
 256 audio.addEventListener('ended', () => {
 257 	if (tracks[currentTrackIndex].looping) {
 258 		audio.currentTime = 0;
 259 		audio.play();
 260 	} else {
 261 		nextTrack();
 262 	}
 263 });
 264 
 265 audio.addEventListener('error', (e) => {
 266 	console.error('Audio error:', e);
 267 	updateCurrentTrackDisplay(`Error loading: ${tracks[currentTrackIndex].filename}`);
 268 	// Try next track after a brief delay
 269 	setTimeout(() => nextTrack(), 1000);
 270 });
 271 
 272 audio.addEventListener('loadedmetadata', () => {
 273 	resetProgressBar();
 274 });
 275 
 276 function renderPlaylist() {
 277 	playlist.innerHTML = '';
 278 	const currentDisplayText = currentTrackDisplay.textContent;
 279 	const isInitialized = currentDisplayText !== 'No track playing';
 280 
 281 	tracks.forEach((track, index) => {
 282 		const item = document.createElement('div');
 283 		item.classList.add('playlist-item');
 284 
 285 		const contentDiv = document.createElement('div');
 286 		contentDiv.classList.add('playlist-item-content');
 287 
 288 		const titleDiv = document.createElement('div');
 289 		titleDiv.classList.add('playlist-item-title');
 290 		if (isInitialized && index === currentTrackIndex) {
 291 			titleDiv.classList.add('current');
 292 		}
 293 		titleDiv.textContent = track.title;
 294 
 295 		const artistDiv = document.createElement('div');
 296 		artistDiv.classList.add('playlist-item-artist');
 297 		if (isInitialized && index === currentTrackIndex) {
 298 			artistDiv.classList.add('current');
 299 		}
 300 		artistDiv.textContent = track.artist;
 301 
 302 		// Set cached status for visual indication
 303 		const isCached = cachedTracks.has(track.filename);
 304 		if (!isCached) {
 305 			contentDiv.classList.add('uncached');
 306 		}
 307 
 308 		contentDiv.appendChild(titleDiv);
 309 		contentDiv.appendChild(artistDiv);
 310 
 311 		const loopIcon = document.createElement('span');
 312 		loopIcon.style.width = '1.18em';
 313 		loopIcon.style.height = '1.18em';
 314 		loopIcon.style.display = (track.looping || false) ? 'inline-block' : 'none';
 315 		loopIcon.style.color = 'var(--text)';
 316 		fetch('resources/repeat.svg')
 317 			.then(r => r.text())
 318 			.then(svgText => {
 319 				loopIcon.innerHTML = svgText;
 320 				const svg = loopIcon.querySelector('svg');
 321 				if (svg) {
 322 					svg.style.width = '1.18em';
 323 					svg.style.height = '1.18em';
 324 				}
 325 			});
 326 
 327 		item.appendChild(contentDiv);
 328 		item.appendChild(loopIcon);
 329 		item.addEventListener('click', (e) => {
 330 			if (item._suppressClick) {
 331 				item._suppressClick = false;
 332 				e.stopPropagation();
 333 				return;
 334 			}
 335 			toggleLooping(index);
 336 		});
 337 		if (canReorder) {
 338 			attachReorderHandlers(item, index);
 339 		}
 340 		playlist.appendChild(item);
 341 	});
 342 }
 343 
 344 function setCurrentTrackIndex(i) {
 345 	currentTrackIndex = i;
 346 }
 347 
 348 function toggleLooping(index) {
 349 	if (!playerReady) return;
 350 	if (index === currentTrackIndex) {
 351 		// First-tap on the auto-selected track: nothing is loaded yet, so start it like any other track.
 352 		if (!audio.src) {
 353 			playTrack(index);
 354 			return;
 355 		}
 356 		if (!isPlaying && currentTrackDisplay.textContent.includes('Ready to play')) {
 357 			audio.play().catch(err => {
 358 				console.error('Failed to play audio:', err);
 359 			});
 360 			isPlaying = true;
 361 			updatePlayPauseButton();
 362 			return;
 363 		}
 364 		// Toggle looping
 365 		tracks[index].looping = !(tracks[index].looping || false);
 366 		renderPlaylist();
 367 	} else {
 368 		playTrack(index);
 369 	}
 370 }
 371 
 372 function playTrack(index) {
 373 	console.log(`playTrack called with index: ${index}`);
 374 	if (!playerReady) {
 375 		console.log('Player not ready');
 376 		return;
 377 	}
 378 	// Clear looping from all tracks except the new one if it was already looping
 379 	const wasLooping = tracks[index].looping || false;
 380 	tracks.forEach(track => track.looping = false);
 381 	if (wasLooping) {
 382 		tracks[index].looping = true;
 383 	}
 384 
 385 	currentTrackIndex = index;
 386 	const track = tracks[currentTrackIndex];
 387 	console.log(`Attempting to play: ${track.artist} – ${track.title}`);
 388 	console.log(`Filename: ${track.filename}`);
 389 	console.log(`Is in preloadedAudio: ${!!preloadedAudio[track.filename]}`);
 390 
 391 	// Use preloaded blob if available, otherwise load from server
 392 	if (preloadedAudio[track.filename]) {
 393 		const blobUrl = preloadedAudio[track.filename].blobUrl;
 394 		console.log(`Playing from preloaded blob: ${track.filename}`);
 395 		console.log(`Blob URL: ${blobUrl}`);
 396 		audio.src = blobUrl;
 397 	} else {
 398 		console.log(`Track not preloaded, loading: ${track.filename}`);
 399 		audio.src = `mix/${track.filename}`;
 400 		// Request priority preloading for this track
 401 		requestPriorityPreload(track.filename);
 402 	}
 403 
 404 	console.log(`Audio src set to: ${audio.src}`);
 405 
 406 	// For iOS PWA: We need to call load() and play() synchronously
 407 	// Reset any previous state first
 408 	try {
 409 		audio.pause();
 410 		audio.currentTime = 0;
 411 	} catch (e) {
 412 		// Ignore errors from resetting
 413 	}
 414 
 415 	// Load the audio to ensure it's ready (important for iOS PWA)
 416 	audio.load();
 417 
 418 	// Small delay to let load() initialize, then play
 419 	// This needs to be synchronous enough that iOS considers it part of the user gesture
 420 	const playAttempt = audio.play();
 421 
 422 	if (playAttempt !== undefined) {
 423 		playAttempt.then(() => {
 424 			console.log('Audio playback started successfully');
 425 			isPlaying = true;
 426 			updatePlayPauseButton();
 427 		}).catch(err => {
 428 			console.error('Failed to play audio:', err);
 429 			console.error('Error name:', err.name);
 430 			console.error('Error message:', err.message);
 431 			isPlaying = false;
 432 			updatePlayPauseButton();
 433 			updateCurrentTrackDisplay(`Error playing: ${track.title}`);
 434 		});
 435 	}
 436 
 437 	// Optimistically set playing state
 438 	isPlaying = true;
 439 	updatePlayPauseButton();
 440 	renderPlaylist();
 441 }
 442 
 443 function updateCurrentTrackDisplay(text) {
 444 	currentTrackDisplay.innerHTML = `<span>${text}</span>`;
 445 	setupMarquee();
 446 }
 447 
 448 // Marquee state
 449 let marqueeAnimating = false;
 450 let marqueePaused = false;
 451 let marqueeTimeoutId = null;
 452 let marqueeOriginalText = '';
 453 let marqueeHalfWidth = 0;
 454 let marqueeRafId = null;
 455 let marqueeOffset = 0; // current displayed translateX, in px (persists across pause/resize)
 456 let marqueeRampStart = 0; // performance.now() when the current ease-in ramp began
 457 let marqueeLastFrame = 0; // performance.now() of the previous animation frame
 458 const marqueeSpeed = 50; // pixels per second
 459 const marqueeRampDuration = 2000; // ms to ease from 0 up to full speed
 460 
 461 function setupMarquee({ preserveOffset = false } = {}) {
 462 	const container = currentTrackDisplay;
 463 	const textSpan = container.querySelector('span');
 464 
 465 	if (!textSpan) return;
 466 
 467 	if (marqueeTimeoutId) {
 468 		clearTimeout(marqueeTimeoutId);
 469 		marqueeTimeoutId = null;
 470 	}
 471 	if (marqueeRafId) {
 472 		cancelAnimationFrame(marqueeRafId);
 473 		marqueeRafId = null;
 474 	}
 475 	marqueeAnimating = false;
 476 	if (!preserveOffset) {
 477 		marqueeOffset = 0;
 478 		marqueePaused = false;
 479 	}
 480 
 481 	container.classList.remove('no-overflow');
 482 	marqueeOriginalText = textSpan.textContent;
 483 
 484 	const containerWidth = container.offsetWidth - 30; // Account for padding
 485 	const overflows = textSpan.scrollWidth > containerWidth;
 486 
 487 	if (!overflows) {
 488 		textSpan.style.transform = 'translateX(0)';
 489 		container.classList.add('no-overflow');
 490 		marqueeOffset = 0;
 491 		return;
 492 	}
 493 
 494 	marqueeAnimating = true;
 495 
 496 	// Duplicate text for a seamless loop. Non-breaking spaces so HTML doesn't collapse them.
 497 	const spacing = '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0';
 498 	textSpan.textContent = marqueeOriginalText + spacing + marqueeOriginalText + spacing;
 499 	marqueeHalfWidth = textSpan.scrollWidth / 2;
 500 
 501 	marqueeOffset = marqueeOffset % marqueeHalfWidth;
 502 	textSpan.style.transform = `translateX(-${marqueeOffset}px)`;
 503 
 504 	if (marqueePaused) return;
 505 	marqueeTimeoutId = setTimeout(startMarqueeMotion, 1000);
 506 }
 507 
 508 function startMarqueeMotion() {
 509 	marqueeRampStart = performance.now();
 510 	marqueeLastFrame = marqueeRampStart;
 511 	marqueeRafId = requestAnimationFrame(marqueeStep);
 512 }
 513 
 514 function marqueeStep(now) {
 515 	if (!marqueeAnimating || marqueePaused) return;
 516 
 517 	const dt = (now - marqueeLastFrame) / 1000;
 518 	marqueeLastFrame = now;
 519 
 520 	const rampElapsed = now - marqueeRampStart;
 521 	const t = Math.min(rampElapsed / marqueeRampDuration, 1);
 522 	const speed = marqueeSpeed * (t * t * (3 - 2 * t));
 523 
 524 	marqueeOffset = (marqueeOffset + speed * dt) % marqueeHalfWidth;
 525 
 526 	const textSpan = currentTrackDisplay.querySelector('span');
 527 	if (textSpan) {
 528 		textSpan.style.transform = `translateX(-${marqueeOffset}px)`;
 529 	}
 530 
 531 	marqueeRafId = requestAnimationFrame(marqueeStep);
 532 }
 533 
 534 function pauseMarquee() {
 535 	if (!marqueeAnimating) return;
 536 	marqueePaused = true;
 537 
 538 	if (marqueeRafId) {
 539 		cancelAnimationFrame(marqueeRafId);
 540 		marqueeRafId = null;
 541 	}
 542 	if (marqueeTimeoutId) {
 543 		clearTimeout(marqueeTimeoutId);
 544 		marqueeTimeoutId = null;
 545 	}
 546 }
 547 
 548 function resumeMarquee() {
 549 	if (!marqueeAnimating) return;
 550 	marqueePaused = false;
 551 	startMarqueeMotion();
 552 }
 553 
 554 window.addEventListener('resize', () => {
 555 	if (!marqueeOriginalText) return;
 556 	const textSpan = currentTrackDisplay.querySelector('span');
 557 	if (!textSpan) return;
 558 
 559 	const prevText = textSpan.textContent;
 560 	textSpan.textContent = marqueeOriginalText;
 561 	const containerWidth = currentTrackDisplay.offsetWidth - 30;
 562 	const overflowsNow = textSpan.scrollWidth > containerWidth;
 563 
 564 	if (overflowsNow === marqueeAnimating) {
 565 		textSpan.textContent = prevText;
 566 		return;
 567 	}
 568 
 569 	setupMarquee({ preserveOffset: true });
 570 });
 571 
 572 function togglePlayPause() {
 573 	if (!playerReady) return;
 574 	if (isPlaying) {
 575 		audio.pause();
 576 		isPlaying = false;
 577 	} else {
 578 		// If no track is loaded, load the first one
 579 		if (!audio.src || audio.src === '') {
 580 			playTrack(currentTrackIndex);
 581 		} else {
 582 			audio.play().catch(err => {
 583 				console.error('Failed to play audio:', err);
 584 				const track = tracks[currentTrackIndex];
 585 				updateCurrentTrackDisplay(`Error playing: ${track.title}`);
 586 			});
 587 			isPlaying = true;
 588 		}
 589 	}
 590 	updatePlayPauseButton();
 591 }
 592 
 593 function updatePlayPauseButton() {
 594 	playPauseBtn.classList.toggle('pause', isPlaying);
 595 }
 596 
 597 function nextTrack() {
 598 	if (!playerReady) return;
 599 	currentTrackIndex = (currentTrackIndex + 1) % tracks.length;
 600 	playTrack(currentTrackIndex);
 601 }
 602 
 603 function prevTrack() {
 604 	if (!playerReady) return;
 605 	if (audio.currentTime <= 3) {
 606 		currentTrackIndex = (currentTrackIndex - 1 + tracks.length) % tracks.length;
 607 		playTrack(currentTrackIndex);
 608 	} else {
 609 		audio.currentTime = 0;
 610 	}
 611 }
 612 
 613 function startProgressBar() {
 614 	stopProgressBar();
 615 
 616 	function animate() {
 617 		updateProgressBar();
 618 		animationFrameId = requestAnimationFrame(animate);
 619 	}
 620 
 621 	animate();
 622 
 623 	// iOS PWA background playback fix: use setInterval as backup
 624 	// setInterval is less throttled than requestAnimationFrame in background
 625 	backgroundPlaybackCheckInterval = setInterval(() => {
 626 		if (!audio.paused && audio.duration && audio.currentTime >= audio.duration - 0.5) {
 627 			console.log('Background check: track ended, triggering next');
 628 			clearInterval(backgroundPlaybackCheckInterval);
 629 			backgroundPlaybackCheckInterval = null;
 630 
 631 			if (tracks[currentTrackIndex].looping) {
 632 				audio.currentTime = 0;
 633 				audio.play();
 634 			} else {
 635 				nextTrack();
 636 			}
 637 		}
 638 
 639 		// Update Media Session position state for iOS
 640 		if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
 641 			try {
 642 				if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
 643 					navigator.mediaSession.setPositionState({
 644 						duration: audio.duration,
 645 						playbackRate: audio.playbackRate,
 646 						position: audio.currentTime
 647 					});
 648 				}
 649 			} catch (e) {
 650 				// Ignore errors from setPositionState
 651 			}
 652 		}
 653 	}, 100); // Check every 100ms
 654 }
 655 
 656 function stopProgressBar() {
 657 	if (animationFrameId !== null) {
 658 		cancelAnimationFrame(animationFrameId);
 659 		animationFrameId = null;
 660 	}
 661 	if (backgroundPlaybackCheckInterval !== null) {
 662 		clearInterval(backgroundPlaybackCheckInterval);
 663 		backgroundPlaybackCheckInterval = null;
 664 	}
 665 }
 666 
 667 function resetProgressBar() {
 668 	progressBar.style.setProperty('--progress', '0');
 669 }
 670 
 671 function updateProgressBar() {
 672 	if (audio.duration && !isDragging && !isSeeking) {
 673 		const currentTime = audio.currentTime;
 674 		const duration = audio.duration;
 675 		const progressPercentage = (currentTime / duration) * 100;
 676 		const displayPercentage = isNaN(progressPercentage) ? 0 : progressPercentage;
 677 		progressBar.style.setProperty('--progress', displayPercentage);
 678 	}
 679 }
 680 
 681 let isDragging = false;
 682 let wasPlayingBeforeDrag = false;
 683 let pendingSeekPercentage = null;
 684 let backgroundPlaybackCheckInterval = null;
 685 
 686 function updateVisualProgress(event) {
 687 	if (!playerReady) return;
 688 
 689 	const rect = progressBar.getBoundingClientRect();
 690 	// Support both mouse and touch events
 691 	const clientX = event.touches ? event.touches[0].clientX : event.clientX;
 692 	const clickPosition = clientX - rect.left;
 693 	const clickPercentage = Math.max(0, Math.min(1, clickPosition / rect.width));
 694 
 695 	progressBar.style.setProperty('--progress', clickPercentage * 100);
 696 	return clickPercentage;
 697 }
 698 
 699 let isSeeking = false;
 700 let targetSeekTime = null;
 701 
 702 function applySeek(clickPercentage) {
 703 	if (!playerReady) return;
 704 
 705 	// If audio hasn't been loaded yet, load it but don't play
 706 	if (!audio.src || audio.src === '') {
 707 		const track = tracks[currentTrackIndex];
 708 
 709 		// Use preloaded blob if available, otherwise load from server
 710 		if (preloadedAudio[track.filename]) {
 711 			audio.src = preloadedAudio[track.filename].blobUrl;
 712 		} else {
 713 			audio.src = `mix/${track.filename}`;
 714 		}
 715 
 716 		// Wait for metadata to be loaded before seeking
 717 		audio.addEventListener('loadedmetadata', function setInitialTime() {
 718 			const duration = audio.duration;
 719 			const seekTime = duration * clickPercentage;
 720 			attemptSeekWithRetry(seekTime, clickPercentage);
 721 			prePlaySeekTime = seekTime;
 722 			audio.removeEventListener('loadedmetadata', setInitialTime);
 723 		}, { once: true });
 724 	} else if (audio.duration) {
 725 		const duration = audio.duration;
 726 		const seekTime = duration * clickPercentage;
 727 		attemptSeekWithRetry(seekTime, clickPercentage);
 728 		prePlaySeekTime = seekTime;
 729 	}
 730 }
 731 
 732 function isTimeBuffered(time) {
 733 	// Check if the given time is within any buffered time range
 734 	for (let i = 0; i < audio.buffered.length; i++) {
 735 		if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
 736 			return true;
 737 		}
 738 	}
 739 	return false;
 740 }
 741 
 742 function attemptSeekWithRetry(seekTime, targetPercentage) {
 743 	targetSeekTime = seekTime;
 744 	isSeeking = true;
 745 
 746 	// Lock the progress bar at the target position
 747 	progressBar.style.setProperty('--progress', targetPercentage * 100);
 748 
 749 	const wasPlaying = !audio.paused;
 750 
 751 	// Try to seek
 752 	audio.currentTime = seekTime;
 753 
 754 	// Handler to check if we reached the target after seeking completes
 755 	function checkSeekSuccess() {
 756 		// Allow small tolerance for floating point comparison
 757 		if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
 758 			// Browser clamped to buffered range - need to wait for more data
 759 			// Now pause during seeking
 760 			if (wasPlaying) {
 761 				audio.pause();
 762 			}
 763 			continueSeekingToTarget(wasPlaying);
 764 		} else {
 765 			// Successfully reached target immediately (was already buffered)
 766 			isSeeking = false;
 767 			targetSeekTime = null;
 768 			// No need to update display - the seek was instant and playback continues normally
 769 		}
 770 	}
 771 
 772 	audio.addEventListener('seeked', checkSeekSuccess, { once: true });
 773 }
 774 
 775 function continueSeekingToTarget(wasPlaying) {
 776 	// Handler for when more data loads
 777 	function retrySeek() {
 778 		if (!isSeeking || targetSeekTime === null) {
 779 			return; // Seeking was cancelled
 780 		}
 781 
 782 		audio.currentTime = targetSeekTime;
 783 
 784 		// Check again after this seek completes
 785 		function checkAgain() {
 786 			if (!isSeeking || targetSeekTime === null) {
 787 				return;
 788 			}
 789 
 790 			if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
 791 				// Still not there, keep trying
 792 				continueSeekingToTarget(wasPlaying);
 793 			} else {
 794 				// Success!
 795 				isSeeking = false;
 796 				targetSeekTime = null;
 797 
 798 				if (wasPlaying) {
 799 					audio.play();
 800 				}
 801 			}
 802 		}
 803 
 804 		audio.addEventListener('seeked', checkAgain, { once: true });
 805 	}
 806 
 807 	// Wait for more data to load, then try again
 808 	audio.addEventListener('progress', retrySeek, { once: true });
 809 
 810 	// Also set a timeout fallback in case progress doesn't fire
 811 	setTimeout(() => {
 812 		if (isSeeking && targetSeekTime !== null && Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
 813 			retrySeek();
 814 		}
 815 	}, 1000);
 816 }
 817 
 818 function onProgressMouseDown(event) {
 819 	if (!playerReady) return;
 820 	isDragging = true;
 821 	wasPlayingBeforeDrag = isPlaying;
 822 	progressContainer.style.cursor = 'grabbing';
 823 	document.body.style.cursor = 'grabbing';
 824 	pendingSeekPercentage = updateVisualProgress(event);
 825 	event.preventDefault();
 826 }
 827 
 828 function onProgressMouseMove(event) {
 829 	if (isDragging) {
 830 		pendingSeekPercentage = updateVisualProgress(event);
 831 	}
 832 }
 833 
 834 function onProgressMouseUp(event) {
 835 	if (isDragging) {
 836 		isDragging = false;
 837 		progressContainer.style.cursor = '';
 838 		document.body.style.cursor = '';
 839 
 840 		// Apply the seek now that drag is complete
 841 		if (pendingSeekPercentage !== null) {
 842 			applySeek(pendingSeekPercentage);
 843 			pendingSeekPercentage = null;
 844 		}
 845 
 846 		// If it was "Ready to play" (not playing before), start playing now
 847 		if (!wasPlayingBeforeDrag && !isPlaying && audio.src) {
 848 			audio.play();
 849 			isPlaying = true;
 850 			updatePlayPauseButton();
 851 		}
 852 	}
 853 }
 854 
 855 playPauseBtn.addEventListener('click', togglePlayPause);
 856 nextBtn.addEventListener('click', nextTrack);
 857 prevBtn.addEventListener('click', prevTrack);
 858 
 859 // Mouse events for desktop
 860 progressContainer.addEventListener('mousedown', onProgressMouseDown);
 861 document.addEventListener('mousemove', onProgressMouseMove);
 862 document.addEventListener('mouseup', onProgressMouseUp);
 863 
 864 // Touch events for mobile
 865 progressContainer.addEventListener('touchstart', onProgressMouseDown, { passive: false });
 866 document.addEventListener('touchmove', onProgressMouseMove, { passive: false });
 867 document.addEventListener('touchend', onProgressMouseUp);
 868 
 869 // Keyboard controls
 870 document.addEventListener('keydown', function(event) {
 871 	if (!playerReady) return;
 872 
 873 	// Spacebar: play/pause
 874 	if (event.code === 'Space') {
 875 		event.preventDefault();
 876 		togglePlayPause();
 877 	}
 878 });
 879 
 880 // Verify all static files are in the cache, fetching any that are missing
 881 async function verifyStaticCache() {
 882 	if (staticFiles.length === 0) {
 883 		console.warn('No static files list available');
 884 		return;
 885 	}
 886 
 887 	console.log(`Verifying ${staticFiles.length} static files are cached...`);
 888 
 889 	try {
 890 		const cache = await caches.open(CACHE_NAME);
 891 		const cachedRequests = await cache.keys();
 892 		const cachedUrls = new Set(cachedRequests.map(r => r.url));
 893 
 894 		const missing = [];
 895 		for (const file of staticFiles) {
 896 			const absoluteUrl = file === './'
 897 				? new URL('./', window.location.href).href
 898 				: new URL(file, window.location.href).href;
 899 			if (!cachedUrls.has(absoluteUrl)) {
 900 				missing.push({ file, absoluteUrl });
 901 			}
 902 		}
 903 
 904 		if (missing.length === 0) {
 905 			console.log('All static files already cached');
 906 			return;
 907 		}
 908 
 909 		console.log(`${missing.length} static files missing from cache, fetching...`);
 910 
 911 		await Promise.all(missing.map(async ({ file, absoluteUrl }) => {
 912 			try {
 913 				const response = await fetchWithRetry(file);
 914 				await cache.put(absoluteUrl, response);
 915 				console.log(`✓ Cached missing static file: ${file}`);
 916 			} catch (error) {
 917 				console.error(`✗ Failed to cache static file: ${file}`, error);
 918 			}
 919 		}));
 920 
 921 		console.log('Static cache verification complete');
 922 	} catch (error) {
 923 		console.error('Failed to verify static cache:', error);
 924 	}
 925 
 926 	// Preload image/SVG assets into the browser's in-memory cache
 927 	const imageFiles = staticFiles.filter(f =>
 928 		f.endsWith('.svg') || f.endsWith('.jpg') || f.endsWith('.png')
 929 	);
 930 	await Promise.all(imageFiles.map(src => new Promise(resolve => {
 931 		const img = new Image();
 932 		img.onload = () => {
 933 			console.log(`Preloaded: ${src}`);
 934 			resolve();
 935 		};
 936 		img.onerror = () => {
 937 			console.error(`Failed to preload: ${src}`);
 938 			resolve();
 939 		};
 940 		img.src = src;
 941 	})));
 942 }
 943 
 944 function startPreloadingTracks() {
 945 	// Start with the first track
 946 	currentPreloadIndex = 0;
 947 	preloadNextTrack();
 948 }
 949 
 950 function preloadNextTrack() {
 951 	if (currentPreloadIndex >= tracks.length) {
 952 		const totalMB = (totalBytesLoaded / 1024 / 1024).toFixed(2);
 953 		console.log(`All tracks preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`);
 954 		return;
 955 	}
 956 
 957 	const track = tracks[currentPreloadIndex];
 958 	const filename = track.filename;
 959 
 960 	// Skip if already preloaded in memory
 961 	if (preloadedAudio[filename]) {
 962 		currentPreloadIndex++;
 963 		preloadNextTrack();
 964 		return;
 965 	}
 966 
 967 	// If already cached, load from cache into memory
 968 	if (cachedTracks.has(filename)) {
 969 		console.log(`Loading from cache: ${track.artist} – ${track.title}`);
 970 		loadFromCache(filename).then(() => {
 971 			currentPreloadIndex++;
 972 			setTimeout(() => preloadNextTrack(), 100);
 973 		}).catch(err => {
 974 			console.error(`Failed to load from cache, fetching instead:`, err);
 975 			// If cache load fails, fetch from network
 976 			fetchAndPreloadTrack(track, filename);
 977 		});
 978 		return;
 979 	}
 980 
 981 	console.log(`Preloading: ${track.artist} – ${track.title}`);
 982 	fetchAndPreloadTrack(track, filename);
 983 }
 984 
 985 function fetchAndPreloadTrack(track, filename) {
 986 	fetchWithRetry(`mix/${filename}`)
 987 		.then(response => {
 988 			const contentLength = response.headers.get('content-length');
 989 			console.log(`Downloading ${track.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`);
 990 			return response.blob();
 991 		})
 992 		.then(blob => {
 993 			// Add blob size to total
 994 			totalBytesLoaded += blob.size;
 995 
 996 			// Create a blob URL that will persist in memory
 997 			const blobUrl = URL.createObjectURL(blob);
 998 
 999 			preloadedAudio[filename] = {
1000 				blobUrl: blobUrl,
1001 				blob: blob
1002 			};
1003 
1004 			console.log(`✓ Fully preloaded: ${track.artist} – ${track.title}`);
1005 
1006 			// Store in Cache API for offline access
1007 			return storeBlobInCache(filename, blob).then(() => {
1008 				// Mark as cached and update UI
1009 				cachedTracks.add(filename);
1010 				updateTrackCachedStatus(filename);
1011 
1012 				// Move to next track
1013 				currentPreloadIndex++;
1014 				setTimeout(() => preloadNextTrack(), 100);
1015 			});
1016 		})
1017 		.catch(error => {
1018 			console.error(`Failed to preload ${filename}:`, error);
1019 			currentPreloadIndex++;
1020 			preloadNextTrack();
1021 		});
1022 }
1023 
1024 // Check which tracks are already cached on app load
1025 async function checkCachedTracks() {
1026 	try {
1027 		const cache = await caches.open(CACHE_NAME);
1028 		const cachedRequests = await cache.keys();
1029 
1030 		// Check each track to see if it's cached
1031 		for (const track of tracks) {
1032 			// Build the same absolute URL that storeBlobInCache uses for consistency
1033 			const absoluteUrl = new URL(`mix/${track.filename}`, window.location.href).href;
1034 			const isInCache = cachedRequests.some(request => request.url === absoluteUrl);
1035 			if (isInCache) {
1036 				cachedTracks.add(track.filename);
1037 			}
1038 		}
1039 
1040 		console.log(`Found ${cachedTracks.size}/${tracks.length} tracks already cached`);
1041 		console.log('Cached tracks:', Array.from(cachedTracks));
1042 	} catch (error) {
1043 		console.error('Failed to check cached tracks:', error);
1044 	}
1045 }
1046 
1047 // Debug function to check preloaded state
1048 window.debugAudioState = function() {
1049 	console.log('=== Audio State Debug ===');
1050 	console.log('Player ready:', playerReady);
1051 	console.log('Is playing:', isPlaying);
1052 	console.log('Current track index:', currentTrackIndex);
1053 	console.log('Total tracks:', tracks.length);
1054 	console.log('Cached tracks count:', cachedTracks.size);
1055 	console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
1056 	console.log('Current audio src:', audio.src);
1057 	console.log('Audio paused:', audio.paused);
1058 	console.log('Audio error:', audio.error);
1059 	if (tracks[currentTrackIndex]) {
1060 		console.log('Current track:', tracks[currentTrackIndex].filename);
1061 		console.log('Is preloaded:', !!preloadedAudio[tracks[currentTrackIndex].filename]);
1062 		console.log('Is cached:', cachedTracks.has(tracks[currentTrackIndex].filename));
1063 	}
1064 	console.log('======================');
1065 };
1066 
1067 // Load a track from cache into memory
1068 async function loadFromCache(filename) {
1069 	try {
1070 		const cache = await caches.open(CACHE_NAME);
1071 		// Try both relative and absolute URLs
1072 		let response = await cache.match(`mix/${filename}`);
1073 		if (!response) {
1074 			// Try with absolute URL
1075 			const absoluteUrl = new URL(`mix/${filename}`, window.location.href).href;
1076 			response = await cache.match(absoluteUrl);
1077 		}
1078 
1079 		if (!response) {
1080 			throw new Error('Not in cache');
1081 		}
1082 
1083 		const blob = await response.blob();
1084 
1085 		// Add blob size to total
1086 		totalBytesLoaded += blob.size;
1087 
1088 		// Create a blob URL that will persist in memory
1089 		const blobUrl = URL.createObjectURL(blob);
1090 
1091 		preloadedAudio[filename] = {
1092 			blobUrl: blobUrl,
1093 			blob: blob
1094 		};
1095 		console.log(`✓ Loaded from cache: ${filename}`);
1096 	} catch (error) {
1097 		console.error(`Failed to load from cache: ${filename}`, error);
1098 		throw error;
1099 	}
1100 }
1101 
1102 // MIME type mapping for supported audio formats
1103 const AUDIO_MIME_TYPES = {
1104 	'.mp3': 'audio/mpeg',
1105 	'.m4a': 'audio/mp4',
1106 	'.ogg': 'audio/ogg',
1107 	'.flac': 'audio/flac',
1108 	'.wav': 'audio/wav'
1109 };
1110 
1111 // Get MIME type based on file extension
1112 function getAudioMimeType(filename) {
1113 	const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
1114 	return AUDIO_MIME_TYPES[ext] || 'audio/mpeg';
1115 }
1116 
1117 // Store blob in Cache API for offline access
1118 async function storeBlobInCache(filename, blob) {
1119 	try {
1120 		const cache = await caches.open(CACHE_NAME);
1121 		const mimeType = getAudioMimeType(filename);
1122 		const response = new Response(blob, {
1123 			headers: {
1124 				'Content-Type': mimeType,
1125 				'Content-Length': blob.size
1126 			}
1127 		});
1128 		// Use absolute URL for consistency
1129 		const absoluteUrl = new URL(`mix/${filename}`, window.location.href).href;
1130 		await cache.put(absoluteUrl, response);
1131 		console.log(`✓ Cached for offline: ${filename}`);
1132 	} catch (error) {
1133 		console.error(`Failed to cache ${filename}:`, error);
1134 	}
1135 }
1136 
1137 // Update UI to show track is cached
1138 function updateTrackCachedStatus(filename) {
1139 	const trackIndex = tracks.findIndex(s => s.filename === filename);
1140 	if (trackIndex === -1) return;
1141 
1142 	// Find the playlist item and remove uncached class
1143 	const playlistItems = playlist.querySelectorAll('.playlist-item');
1144 	if (playlistItems[trackIndex]) {
1145 		const contentDiv = playlistItems[trackIndex].querySelector('.playlist-item-content');
1146 		if (contentDiv) {
1147 			contentDiv.classList.remove('uncached');
1148 		}
1149 	}
1150 }
1151 
1152 // Priority preloading system
1153 function requestPriorityPreload(filename) {
1154 	// Skip if already preloaded or already in priority queue
1155 	if (preloadedAudio[filename] || priorityPreloadQueue.includes(filename)) {
1156 		return;
1157 	}
1158 
1159 	console.log(`🔥 Priority preload requested: ${filename}`);
1160 	priorityPreloadQueue.push(filename);
1161 
1162 	// Start priority preloading if not already running
1163 	if (!isPreloadingPriority) {
1164 		processPriorityPreload();
1165 	}
1166 }
1167 
1168 function processPriorityPreload() {
1169 	if (priorityPreloadQueue.length === 0) {
1170 		isPreloadingPriority = false;
1171 		return;
1172 	}
1173 
1174 	isPreloadingPriority = true;
1175 	const filename = priorityPreloadQueue.shift();
1176 
1177 	// Check if already preloaded (might have finished during normal preloading)
1178 	if (preloadedAudio[filename]) {
1179 		processPriorityPreload();
1180 		return;
1181 	}
1182 
1183 	// Find the track info
1184 	const track = tracks.find(s => s.filename === filename);
1185 	if (!track) {
1186 		processPriorityPreload();
1187 		return;
1188 	}
1189 
1190 	// If already cached, load from cache
1191 	if (cachedTracks.has(filename)) {
1192 		console.log(`🔥 Priority loading from cache: ${track.artist} – ${track.title}`);
1193 		loadFromCache(filename).then(() => {
1194 			processPriorityPreload();
1195 		}).catch(err => {
1196 			console.error(`Failed to load from cache, fetching instead:`, err);
1197 			priorityFetchAndPreloadTrack(track, filename);
1198 		});
1199 		return;
1200 	}
1201 
1202 	console.log(`🔥 Priority preloading: ${track.artist} – ${track.title}`);
1203 	priorityFetchAndPreloadTrack(track, filename);
1204 }
1205 
1206 function priorityFetchAndPreloadTrack(track, filename) {
1207 	fetchWithRetry(`mix/${filename}`)
1208 		.then(response => {
1209 			return response.blob();
1210 		})
1211 		.then(blob => {
1212 			// Create a blob URL that will persist in memory
1213 			const blobUrl = URL.createObjectURL(blob);
1214 
1215 			preloadedAudio[filename] = {
1216 				blobUrl: blobUrl,
1217 				blob: blob
1218 			};
1219 
1220 			// Next time this track plays, it will use the cached version
1221 			console.log(`✓ Priority preloaded: ${track.artist} – ${track.title}`);
1222 
1223 			// Store in Cache API for offline access
1224 			return storeBlobInCache(filename, blob).then(() => {
1225 				// Mark as cached and update UI
1226 				cachedTracks.add(filename);
1227 				updateTrackCachedStatus(filename);
1228 
1229 				// Process next priority request
1230 				processPriorityPreload();
1231 			});
1232 		})
1233 		.catch(error => {
1234 			// Ignore abort errors (happens when normal preload finishes first)
1235 			if (error.name === 'AbortError' || error.message.includes('aborted')) {
1236 				// This is expected - normal preloading probably finished first
1237 			} else {
1238 				console.error(`Failed to priority preload ${filename}:`, error);
1239 			}
1240 			processPriorityPreload();
1241 		});
1242 }