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 }