commit e3af3d0b2ff9175ffbb383f8b731db4bfe91382a
parent a8f3cb95e0e0819af95a4982f521c2d4eca53834
Author: Hunter
Date:   Mon,  6 Apr 2026 15:55:00 -0400

remove resource-manifest; tracks instead of songs; load static files first

Diffstat:
M.gitignore | 1-
Mbuy.py | 18+++++++++---------
Mgenerate_manifests.py | 104++++++++++++++++++++++++++++++++++++-------------------------------------------
Mindex.html | 2+-
Mreadme.md | 6+++---
Mresources/script.js | 428+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mresources/styles.css | 6+++---
7 files changed, 308 insertions(+), 257 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -21,7 +21,6 @@ mix/* # auto-generated PWA files manifest.json -resource-manifest.json service-worker.js # Personal deployment scripts diff --git a/buy.py b/buy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Search for songs and open purchase links. +Search for tracks and open purchase links. Usage: ./buy.py <search query> @@ -27,7 +27,7 @@ POLL_INTERVAL = 1 # seconds between directory scans def search_itunes(query): - """Search iTunes for a song and return results.""" + """Search iTunes for a track and return results.""" encoded_query = urllib.parse.quote(query) url = f"https://itunes.apple.com/search?term={encoded_query}&media=music&entity=song&limit=10" @@ -92,8 +92,8 @@ def wait_for_new_file(watch_dir, before): Poll watch_dir recursively until an audio file appears that wasn't in the before snapshot. Returns the full path of the new file. """ - print(f"Watching for new file in:\n {watch_dir}") - print("(complete your purchase in iTunes — press Ctrl+C to cancel)") + print(f"Watching for new file...") + print("(complete your purchase in iTunes or press Ctrl+C to cancel)") while True: after = snapshot_audio_files(watch_dir) @@ -108,7 +108,7 @@ def main(): if len(sys.argv) > 1: query = ' '.join(sys.argv[1:]) else: - query = input("Enter song name, artist, or both: ").strip() + query = input("Enter track name, artist, or both: ").strip() if not query: print("No search query provided.") @@ -128,7 +128,7 @@ def main(): # Extract track info artist = best_track.get('artistName', 'Unknown') - song = best_track.get('trackName', 'Unknown') + track = best_track.get('trackName', 'Unknown') itunes_url = best_track.get('trackViewUrl') track_id = best_track.get('trackId') @@ -137,11 +137,11 @@ def main(): return # Open iTunes purchase page (macOS) or song.link (other platforms) - print(f"Opening: {artist} - {song}") + print(f"Opening: {artist} - {track}") if platform.system() == 'Darwin': - print("(Opening iTunes Store directly)") + print("Opening iTunes Store directly...") else: - print("(Opening song.link with all platform options)") + print("Opening song.link with all platform options...") opened_itunes = open_itunes_link(itunes_url, track_id) diff --git a/generate_manifests.py b/generate_manifests.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Creates manifest.json, resource-manifest.json, and service-worker.js +Creates manifest.json and service-worker.js based on the contents of tracks.json """ @@ -160,10 +160,11 @@ def generate_pwa_manifests(app_name=None, base_path=None): json.dump(manifest, f, indent=2) print("✓ Generated manifest.json") - # Generate resource-manifest.json + # Build static files list for service worker static_files = [ "./", "index.html", + "manifest.json", "resources/styles.css", "resources/script.js", "mix/tracks.json", @@ -180,20 +181,11 @@ def generate_pwa_manifests(app_name=None, base_path=None): if (SCRIPT_DIR / "mix" / optional_file).exists(): static_files.append(f"mix/{optional_file}") - resource_manifest = { - "static_files": static_files, - "tracks": [f"mix/{track['filename']}" for track in tracks] - } - - with open(SCRIPT_DIR / "resource-manifest.json", 'w', encoding='utf-8') as f: - json.dump(resource_manifest, f, indent=2) - print("✓ Generated resource-manifest.json") + static_files_js = json.dumps(static_files) # Generate service-worker.js - static_files = resource_manifest["static_files"] service_worker_content = f'''// Auto-generated service worker for {app_name} PWA const CACHE_NAME = '{cache_name}'; -const staticFilesToCache = {json.dumps(static_files, indent=2)}; // Get the base path from the service worker location const getBasePath = () => {{ @@ -203,55 +195,53 @@ const getBasePath = () => {{ const basePath = getBasePath(); -// Install event - cache only static resources (not audio files) +// Static files to cache on install +const STATIC_FILES = {static_files_js}; + +// Install event - cache static resources // Audio files will be cached by the main app's blob preloading system self.addEventListener('install', (event) => {{ console.log('Service Worker installing...', 'Base path:', basePath); event.waitUntil( - caches.open(CACHE_NAME) - .then((cache) => {{ - console.log('Opened cache'); - // Make URLs absolute relative to service worker location - const absoluteUrls = staticFilesToCache.map(url => {{ - if (url === './') return basePath; - return new URL(url, basePath + 'index.html').href; - }}); - console.log('Caching', absoluteUrls.length, 'static resources'); - console.log('URLs to cache:', absoluteUrls); - - // Cache files individually with better error handling - // Using Promise.allSettled to continue even if some fail - return Promise.allSettled( - absoluteUrls.map(url => - fetch(url, {{ cache: 'no-cache' }}) - .then(response => {{ - if (!response.ok) {{ - throw new Error(`HTTP error! status: ${{response.status}}`); - }} - return cache.put(url, response); - }}) - .then(() => console.log('✓ Cached:', url)) - .catch(err => {{ - console.error('✗ Failed to cache:', url, err); - throw err; - }}) - ) - ).then(results => {{ - const failed = results.filter(r => r.status === 'rejected'); - const succeeded = results.filter(r => r.status === 'fulfilled'); - console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`); - if (failed.length > 0) {{ - console.warn(`Failed to cache ${{failed.length}} resources`); - }} - }}); - }}) - .then(() => {{ - console.log('Service Worker installation complete'); - return self.skipWaiting(); - }}) - .catch(error => {{ - console.error('Service Worker installation failed:', error); - }}) + caches.open(CACHE_NAME).then(cache => {{ + // Make URLs absolute relative to service worker location + const absoluteUrls = STATIC_FILES.map(url => {{ + if (url === './') return basePath; + return new URL(url, basePath + 'index.html').href; + }}); + console.log('Caching', absoluteUrls.length, 'static resources'); + + // Cache files individually with better error handling + // Using Promise.allSettled to continue even if some fail + return Promise.allSettled( + absoluteUrls.map(url => + fetch(url, {{ cache: 'no-cache' }}) + .then(response => {{ + if (!response.ok) {{ + throw new Error(`HTTP error! status: ${{response.status}}`); + }} + return cache.put(url, response); + }}) + .then(() => console.log('✓ Cached:', url)) + .catch(err => {{ + console.error('✗ Failed to cache:', url, err); + throw err; + }}) + ) + ).then(results => {{ + const failed = results.filter(r => r.status === 'rejected'); + const succeeded = results.filter(r => r.status === 'fulfilled'); + console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`); + if (failed.length > 0) {{ + console.warn(`Failed to cache ${{failed.length}} resources`); + }} + }}); + }}).then(() => {{ + console.log('Service Worker installation complete'); + return self.skipWaiting(); + }}).catch(error => {{ + console.error('Service Worker installation failed:', error); + }}) ); }}); diff --git a/index.html b/index.html @@ -16,7 +16,7 @@ <body> <div class="window-container"> <div class="player-container"> - <div class="current-song" id="currentSong">No song playing</div> + <div class="current-track" id="currentTrack">No track playing</div> <div class="progress-container" id="progressContainer"> <div class="progress-bar" id="progressBar"> <span class="progress-text progress-text-left" id="progressTextLeft"></span> diff --git a/readme.md b/readme.md @@ -39,8 +39,8 @@ hits different, right?<br><br> 1. **prep your playlist** - add your audio files to the `/mix` directory, or use: - `./rip.py` to rip tracks from a physical CD - - `./buy.py` to search for songs to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise) - - run `./build.py` to parse `/mix` and populate `tracks.json`, which defines the songs available to the player. after running `./build.py` once, you can manually edit `tracks.json` to refine your mix. + - `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise) + - run `./build.py` to parse `/mix` and populate `tracks.json`, which defines the tracks available to the player. after running `./build.py` once, you can manually edit `tracks.json` to refine your mix. - optionally, add an `album_art.jpg` to `/mix` to set the cover art for your mix. - supported audio formats: `.mp3`, `.m4a`, `.ogg`, `.flac`, `.wav` @@ -49,7 +49,7 @@ hits different, right?<br><br> 3. **manifesting** - run `./generate_manifests.py` and follow the interactive prompts to specify an app name and the remote server path where your app will be hosted. - - this creates the config files that enable offline functionality: `manifest.json`, `resource-manifest.json`, and `service-worker.js`. + - this creates the config files that enable offline functionality: `manifest.json` and `service-worker.js`. 4. **ship it** - upload the entire project directory to any web host with HTTPS support (GitHub Pages, AWS S3, etc.) diff --git a/resources/script.js b/resources/script.js @@ -15,7 +15,7 @@ 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 currentTrackDisplay = document.getElementById('currentTrack'); const progressBar = document.getElementById('progressBar'); const progressContainer = document.getElementById('progressContainer'); const audio = document.getElementById('audioPlayer'); @@ -32,20 +32,52 @@ function shuffleArray(array) { return shuffled; } -let currentSongIndex = 0; +let currentTrackIndex = 0; let isPlaying = false; let progressInterval; let playerReady = false; -let songs = []; +let tracks = []; 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 priorityPreloadQueue = []; // Tracks 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 totalBytesLoaded = 0; // Track total filesize of all preloaded tracks +let cachedTracks = new Set(); // Track which tracks are cached for offline use let CACHE_NAME = 'my-mixapp'; // Default fallback +const staticFiles = [ + './', + 'index.html', + 'manifest.json', + 'resources/styles.css', + 'resources/script.js', + 'mix/tracks.json', + 'resources/icon.png', + 'resources/play.svg', + 'resources/pause.svg', + 'resources/prev.svg', + 'resources/next.svg', + 'resources/repeat.svg', +]; + +// Retry fetch with exponential backoff +async function fetchWithRetry(url, maxRetries = 4, baseDelay = 2000) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response; + } catch (error) { + if (attempt === maxRetries) throw error; + const delay = baseDelay * Math.pow(2, attempt); + console.warn(`Fetch failed for ${url}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } +} // Load cache name from manifest.json first, then load tracks fetch('manifest.json') @@ -63,34 +95,43 @@ fetch('manifest.json') } } - return fetch('mix/tracks.json'); + // Probe for optional files and add them to staticFiles + const optionalFiles = ['mix/album_art.jpg', 'mix/custom.css', 'mix/custom.js']; + return Promise.all([ + ...optionalFiles.map(f => + fetch(f, { method: 'HEAD' }) + .then(r => { if (r.ok) staticFiles.push(f); }) + .catch(() => {}) + ), + fetch('mix/tracks.json') + .then(r => { + if (!r.ok) throw new Error('tracks.json not found'); + return r.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) { + .then((results) => { + const data = results[results.length - 1]; + + tracks = shuffle ? shuffleArray(data) : data; + if (tracks.length > 0) { playerReady = true; - updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`); + updateCurrentTrackDisplay(`Ready to play: ${tracks[0].artist} – ${tracks[0].title}`); // Check which tracks are already cached before rendering return checkCachedTracks().then(() => { renderPlaylist(); - // Pre-cache resources first, then songs - return preloadResources().then(() => { - startPreloadingSongs(); + // Verify all static files are cached, then start caching tracks + return verifyStaticCache().then(() => { + startPreloadingTracks(); }); }); } else { - updateCurrentSongDisplay('No tracks found'); + updateCurrentTrackDisplay('No tracks found'); } }) .catch(error => { console.error('Error loading tracks:', error); - updateCurrentSongDisplay('Unable to load tracks. Please check your connection.'); + updateCurrentTrackDisplay('Unable to load tracks. Please check your connection.'); }); // Audio event listeners @@ -98,20 +139,20 @@ audio.addEventListener('play', () => { isPlaying = true; updatePlayPauseButton(); startProgressBar(); - const song = songs[currentSongIndex]; - const songText = `${song.artist} – ${song.title}`; + const track = tracks[currentTrackIndex]; + const trackText = `${track.artist} – ${track.title}`; // Always update display to ensure we remove "Ready to play:" prefix - const currentText = currentSongDisplay.querySelector('span')?.textContent || ''; + const currentText = currentTrackDisplay.querySelector('span')?.textContent || ''; - // Check if it's a different song (not just play/pause of same song) - const isNewSong = !currentText.includes(songText); + // Check if it's a different track (not just play/pause of same track) + const isNewTrack = !currentText.includes(trackText); const hasReadyToPlay = currentText.includes('Ready to play:'); - if (isNewSong || hasReadyToPlay) { - updateCurrentSongDisplay(songText); + if (isNewTrack || hasReadyToPlay) { + updateCurrentTrackDisplay(trackText); } else { - // Same song, just resume the marquee + // Same track, just resume the marquee resumeMarquee(); } @@ -121,8 +162,8 @@ audio.addEventListener('play', () => { // Use document.baseURI to correctly resolve paths in subdirectories const albumArtUrl = new URL('mix/album_art.jpg', document.baseURI).href; navigator.mediaSession.metadata = new MediaMetadata({ - title: song.title, - artist: song.artist, + title: track.title, + artist: track.artist, artwork: [ { src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' }, { src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' }, @@ -147,13 +188,13 @@ audio.addEventListener('play', () => { navigator.mediaSession.setActionHandler('previoustrack', () => { if (playerReady) { - prevSong(); + prevTrack(); } }); navigator.mediaSession.setActionHandler('nexttrack', () => { if (playerReady) { - nextSong(); + nextTrack(); } }); @@ -171,19 +212,19 @@ audio.addEventListener('pause', () => { }); audio.addEventListener('ended', () => { - if (songs[currentSongIndex].looping) { + if (tracks[currentTrackIndex].looping) { audio.currentTime = 0; audio.play(); } else { - nextSong(); + nextTrack(); } }); 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); + updateCurrentTrackDisplay(`Error loading: ${tracks[currentTrackIndex].filename}`); + // Try next track after a brief delay + setTimeout(() => nextTrack(), 1000); }); audio.addEventListener('loadedmetadata', () => { @@ -192,10 +233,10 @@ audio.addEventListener('loadedmetadata', () => { function renderPlaylist() { playlist.innerHTML = ''; - const currentDisplayText = currentSongDisplay.textContent; - const isInitialized = currentDisplayText !== 'No song playing'; + const currentDisplayText = currentTrackDisplay.textContent; + const isInitialized = currentDisplayText !== 'No track playing'; - songs.forEach((song, index) => { + tracks.forEach((track, index) => { const item = document.createElement('div'); item.classList.add('playlist-item'); @@ -204,20 +245,20 @@ function renderPlaylist() { const titleDiv = document.createElement('div'); titleDiv.classList.add('playlist-item-title'); - if (isInitialized && index === currentSongIndex) { + if (isInitialized && index === currentTrackIndex) { titleDiv.classList.add('current'); } - titleDiv.textContent = song.title; + titleDiv.textContent = track.title; const artistDiv = document.createElement('div'); artistDiv.classList.add('playlist-item-artist'); - if (isInitialized && index === currentSongIndex) { + if (isInitialized && index === currentTrackIndex) { artistDiv.classList.add('current'); } - artistDiv.textContent = song.artist; + artistDiv.textContent = track.artist; // Set cached status for visual indication - const isCached = cachedTracks.has(song.filename); + const isCached = cachedTracks.has(track.filename); if (!isCached) { contentDiv.classList.add('uncached'); } @@ -228,7 +269,7 @@ function renderPlaylist() { const loopIcon = document.createElement('span'); loopIcon.style.width = '1.18em'; loopIcon.style.height = '1.18em'; - loopIcon.style.display = (song.looping || false) ? 'inline-block' : 'none'; + loopIcon.style.display = (track.looping || false) ? 'inline-block' : 'none'; loopIcon.style.color = 'var(--text)'; fetch('resources/repeat.svg') .then(r => r.text()) @@ -250,8 +291,8 @@ function renderPlaylist() { function toggleLooping(index) { if (!playerReady) return; - if (index === currentSongIndex) { - if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) { + if (index === currentTrackIndex) { + if (!isPlaying && currentTrackDisplay.textContent.includes('Ready to play')) { audio.play().catch(err => { console.error('Failed to play audio:', err); }); @@ -260,43 +301,43 @@ function toggleLooping(index) { return; } // Toggle looping - songs[index].looping = !(songs[index].looping || false); + tracks[index].looping = !(tracks[index].looping || false); renderPlaylist(); } else { - playSong(index); + playTrack(index); } } -function playSong(index) { - console.log(`playSong called with index: ${index}`); +function playTrack(index) { + console.log(`playTrack 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); + // Clear looping from all tracks except the new one if it was already looping + const wasLooping = tracks[index].looping || false; + tracks.forEach(track => track.looping = false); if (wasLooping) { - songs[index].looping = true; + tracks[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]}`); + currentTrackIndex = index; + const track = tracks[currentTrackIndex]; + console.log(`Attempting to play: ${track.artist} – ${track.title}`); + console.log(`Filename: ${track.filename}`); + console.log(`Is in preloadedAudio: ${!!preloadedAudio[track.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}`); + if (preloadedAudio[track.filename]) { + const blobUrl = preloadedAudio[track.filename].blobUrl; + console.log(`Playing from preloaded blob: ${track.filename}`); console.log(`Blob URL: ${blobUrl}`); audio.src = blobUrl; } else { - console.log(`Song not preloaded, loading: ${song.filename}`); - audio.src = `mix/${song.filename}`; - // Request priority preloading for this song - requestPriorityPreload(song.filename); + console.log(`Track not preloaded, loading: ${track.filename}`); + audio.src = `mix/${track.filename}`; + // Request priority preloading for this track + requestPriorityPreload(track.filename); } console.log(`Audio src set to: ${audio.src}`); @@ -328,7 +369,7 @@ function playSong(index) { console.error('Error message:', err.message); isPlaying = false; updatePlayPauseButton(); - updateCurrentSongDisplay(`Error playing: ${song.title}`); + updateCurrentTrackDisplay(`Error playing: ${track.title}`); }); } @@ -338,8 +379,8 @@ function playSong(index) { renderPlaylist(); } -function updateCurrentSongDisplay(text) { - currentSongDisplay.innerHTML = `<span>${text}</span>`; +function updateCurrentTrackDisplay(text) { + currentTrackDisplay.innerHTML = `<span>${text}</span>`; // Initialize marquee effect after a brief delay to ensure DOM is updated setTimeout(() => setupMarquee(), 50); } @@ -356,7 +397,7 @@ let marqueeElapsedBeforePause = 0; const marqueeSpeed = 50; // pixels per second function setupMarquee() { - const container = currentSongDisplay; + const container = currentTrackDisplay; const textSpan = container.querySelector('span'); if (!textSpan) return; @@ -429,7 +470,7 @@ function marqueeStep(now) { // Modulo by one segment width for seamless wrapping — no loop boundary const offset = totalDistance % marqueeHalfWidth; - const textSpan = currentSongDisplay.querySelector('span'); + const textSpan = currentTrackDisplay.querySelector('span'); if (textSpan) { textSpan.style.transform = `translateX(-${offset}px)`; } @@ -460,7 +501,7 @@ function resumeMarquee() { if (!marqueeAnimating) return; marqueePaused = false; - const textSpan = currentSongDisplay.querySelector('span'); + const textSpan = currentTrackDisplay.querySelector('span'); if (!textSpan) return; // Resume from where we left off @@ -475,7 +516,7 @@ window.addEventListener('resize', () => { const wasPaused = marqueePaused; // Restore original text before recalculating - const textSpan = currentSongDisplay.querySelector('span'); + const textSpan = currentTrackDisplay.querySelector('span'); if (textSpan) { textSpan.textContent = marqueeOriginalText; } @@ -495,14 +536,14 @@ function togglePlayPause() { audio.pause(); isPlaying = false; } else { - // If no song is loaded, load the first one + // If no track is loaded, load the first one if (!audio.src || audio.src === '') { - playSong(currentSongIndex); + playTrack(currentTrackIndex); } else { audio.play().catch(err => { console.error('Failed to play audio:', err); - const song = songs[currentSongIndex]; - updateCurrentSongDisplay(`Error playing: ${song.title}`); + const track = tracks[currentTrackIndex]; + updateCurrentTrackDisplay(`Error playing: ${track.title}`); }); isPlaying = true; } @@ -514,17 +555,17 @@ function updatePlayPauseButton() { playPauseBtn.classList.toggle('pause', isPlaying); } -function nextSong() { +function nextTrack() { if (!playerReady) return; - currentSongIndex = (currentSongIndex + 1) % songs.length; - playSong(currentSongIndex); + currentTrackIndex = (currentTrackIndex + 1) % tracks.length; + playTrack(currentTrackIndex); } -function prevSong() { +function prevTrack() { if (!playerReady) return; if (audio.currentTime <= 3) { - currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length; - playSong(currentSongIndex); + currentTrackIndex = (currentTrackIndex - 1 + tracks.length) % tracks.length; + playTrack(currentTrackIndex); } else { audio.currentTime = 0; } @@ -544,15 +585,15 @@ function startProgressBar() { // 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'); + console.log('Background check: track ended, triggering next'); clearInterval(backgroundPlaybackCheckInterval); backgroundPlaybackCheckInterval = null; - if (songs[currentSongIndex].looping) { + if (tracks[currentTrackIndex].looping) { audio.currentTime = 0; audio.play(); } else { - nextSong(); + nextTrack(); } } @@ -624,13 +665,13 @@ function applySeek(clickPercentage) { // If audio hasn't been loaded yet, load it but don't play if (!audio.src || audio.src === '') { - const song = songs[currentSongIndex]; + const track = tracks[currentTrackIndex]; // Use preloaded blob if available, otherwise load from server - if (preloadedAudio[song.filename]) { - audio.src = preloadedAudio[song.filename].blobUrl; + if (preloadedAudio[track.filename]) { + audio.src = preloadedAudio[track.filename].blobUrl; } else { - audio.src = `mix/${song.filename}`; + audio.src = `mix/${track.filename}`; } // Wait for metadata to be loaded before seeking @@ -773,8 +814,8 @@ function onProgressMouseUp(event) { } playPauseBtn.addEventListener('click', togglePlayPause); -nextBtn.addEventListener('click', nextSong); -prevBtn.addEventListener('click', prevSong); +nextBtn.addEventListener('click', nextTrack); +prevBtn.addEventListener('click', prevTrack); // Mouse events for desktop progressContainer.addEventListener('mousedown', onProgressMouseDown); @@ -797,91 +838,116 @@ document.addEventListener('keydown', function(event) { } }); -// Pre-caching system -function preloadResources() { - console.log('Preloading UI resources...'); - - const resources = [ - 'resources/play.svg', - 'resources/pause.svg', - 'resources/prev.svg', - 'resources/next.svg', - 'mix/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; - }); - }); +// Verify all static files are in the cache, fetching any that are missing +async function verifyStaticCache() { + if (staticFiles.length === 0) { + console.warn('No static files list available'); + return; + } - return Promise.all(imagePromises).then(() => { - console.log('All UI resources preloaded'); - }); + console.log(`Verifying ${staticFiles.length} static files are cached...`); + + try { + const cache = await caches.open(CACHE_NAME); + const cachedRequests = await cache.keys(); + const cachedUrls = new Set(cachedRequests.map(r => r.url)); + + const missing = []; + for (const file of staticFiles) { + const absoluteUrl = file === './' + ? new URL('./', window.location.href).href + : new URL(file, window.location.href).href; + if (!cachedUrls.has(absoluteUrl)) { + missing.push({ file, absoluteUrl }); + } + } + + if (missing.length === 0) { + console.log('All static files already cached'); + return; + } + + console.log(`${missing.length} static files missing from cache, fetching...`); + + await Promise.all(missing.map(async ({ file, absoluteUrl }) => { + try { + const response = await fetchWithRetry(file); + await cache.put(absoluteUrl, response); + console.log(`✓ Cached missing static file: ${file}`); + } catch (error) { + console.error(`✗ Failed to cache static file: ${file}`, error); + } + })); + + console.log('Static cache verification complete'); + } catch (error) { + console.error('Failed to verify static cache:', error); + } + + // Preload image/SVG assets into the browser's in-memory cache + const imageFiles = staticFiles.filter(f => + f.endsWith('.svg') || f.endsWith('.jpg') || f.endsWith('.png') + ); + await Promise.all(imageFiles.map(src => new Promise(resolve => { + const img = new Image(); + img.onload = () => { + console.log(`Preloaded: ${src}`); + resolve(); + }; + img.onerror = () => { + console.error(`Failed to preload: ${src}`); + resolve(); + }; + img.src = src; + }))); } -function startPreloadingSongs() { - // Start with the first song +function startPreloadingTracks() { + // Start with the first track currentPreloadIndex = 0; - preloadNextSong(); + preloadNextTrack(); } -function preloadNextSong() { - if (currentPreloadIndex >= songs.length) { +function preloadNextTrack() { + if (currentPreloadIndex >= tracks.length) { const totalMB = (totalBytesLoaded / 1024 / 1024).toFixed(2); - console.log(`All songs preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`); + console.log(`All tracks preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`); return; } - const song = songs[currentPreloadIndex]; - const filename = song.filename; + const track = tracks[currentPreloadIndex]; + const filename = track.filename; // Skip if already preloaded in memory if (preloadedAudio[filename]) { currentPreloadIndex++; - preloadNextSong(); + preloadNextTrack(); return; } // If already cached, load from cache into memory if (cachedTracks.has(filename)) { - console.log(`Loading from cache: ${song.artist} – ${song.title}`); + console.log(`Loading from cache: ${track.artist} – ${track.title}`); loadFromCache(filename).then(() => { currentPreloadIndex++; - setTimeout(() => preloadNextSong(), 100); + setTimeout(() => preloadNextTrack(), 100); }).catch(err => { console.error(`Failed to load from cache, fetching instead:`, err); // If cache load fails, fetch from network - fetchAndPreloadSong(song, filename); + fetchAndPreloadTrack(track, filename); }); return; } - console.log(`Preloading: ${song.artist} – ${song.title}`); - fetchAndPreloadSong(song, filename); + console.log(`Preloading: ${track.artist} – ${track.title}`); + fetchAndPreloadTrack(track, filename); } -function fetchAndPreloadSong(song, filename) { - // Use fetch to force full download of the entire file - fetch(`mix/${filename}`) +function fetchAndPreloadTrack(track, filename) { + fetchWithRetry(`mix/${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 + console.log(`Downloading ${track.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`); return response.blob(); }) .then(blob => { @@ -896,7 +962,7 @@ function fetchAndPreloadSong(song, filename) { blob: blob }; - console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`); + console.log(`✓ Fully preloaded: ${track.artist} – ${track.title}`); // Store in Cache API for offline access return storeBlobInCache(filename, blob).then(() => { @@ -904,15 +970,15 @@ function fetchAndPreloadSong(song, filename) { cachedTracks.add(filename); updateTrackCachedStatus(filename); - // Move to next song + // Move to next track currentPreloadIndex++; - setTimeout(() => preloadNextSong(), 100); + setTimeout(() => preloadNextTrack(), 100); }); }) .catch(error => { console.error(`Failed to preload ${filename}:`, error); currentPreloadIndex++; - preloadNextSong(); + preloadNextTrack(); }); } @@ -922,17 +988,17 @@ async function checkCachedTracks() { 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) { + // Check each track to see if it's cached + for (const track of tracks) { // Build the same absolute URL that storeBlobInCache uses for consistency - const absoluteUrl = new URL(`mix/${song.filename}`, window.location.href).href; + const absoluteUrl = new URL(`mix/${track.filename}`, window.location.href).href; const isInCache = cachedRequests.some(request => request.url === absoluteUrl); if (isInCache) { - cachedTracks.add(song.filename); + cachedTracks.add(track.filename); } } - console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`); + console.log(`Found ${cachedTracks.size}/${tracks.length} tracks already cached`); console.log('Cached tracks:', Array.from(cachedTracks)); } catch (error) { console.error('Failed to check cached tracks:', error); @@ -944,17 +1010,17 @@ 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('Current track index:', currentTrackIndex); + console.log('Total tracks:', tracks.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)); + if (tracks[currentTrackIndex]) { + console.log('Current track:', tracks[currentTrackIndex].filename); + console.log('Is preloaded:', !!preloadedAudio[tracks[currentTrackIndex].filename]); + console.log('Is cached:', cachedTracks.has(tracks[currentTrackIndex].filename)); } console.log('======================'); }; @@ -1031,13 +1097,13 @@ async function storeBlobInCache(filename, blob) { // Update UI to show track is cached function updateTrackCachedStatus(filename) { - const songIndex = songs.findIndex(s => s.filename === filename); - if (songIndex === -1) return; + const trackIndex = tracks.findIndex(s => s.filename === filename); + if (trackIndex === -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 (playlistItems[trackIndex]) { + const contentDiv = playlistItems[trackIndex].querySelector('.playlist-item-content'); if (contentDiv) { contentDiv.classList.remove('uncached'); } @@ -1075,36 +1141,32 @@ function processPriorityPreload() { return; } - // Find the song info - const song = songs.find(s => s.filename === filename); - if (!song) { + // Find the track info + const track = tracks.find(s => s.filename === filename); + if (!track) { processPriorityPreload(); return; } // If already cached, load from cache if (cachedTracks.has(filename)) { - console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`); + console.log(`🔥 Priority loading from cache: ${track.artist} – ${track.title}`); loadFromCache(filename).then(() => { processPriorityPreload(); }).catch(err => { console.error(`Failed to load from cache, fetching instead:`, err); - priorityFetchAndPreloadSong(song, filename); + priorityFetchAndPreloadTrack(track, filename); }); return; } - console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`); - priorityFetchAndPreloadSong(song, filename); + console.log(`🔥 Priority preloading: ${track.artist} – ${track.title}`); + priorityFetchAndPreloadTrack(track, filename); } -function priorityFetchAndPreloadSong(song, filename) { - // Use fetch to force full download of the entire file - fetch(`mix/${filename}`) +function priorityFetchAndPreloadTrack(track, filename) { + fetchWithRetry(`mix/${filename}`) .then(response => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } return response.blob(); }) .then(blob => { @@ -1116,8 +1178,8 @@ function priorityFetchAndPreloadSong(song, filename) { blob: blob }; - // Next time this song plays, it will use the cached version - console.log(`✓ Priority preloaded: ${song.artist} – ${song.title}`); + // Next time this track plays, it will use the cached version + console.log(`✓ Priority preloaded: ${track.artist} – ${track.title}`); // Store in Cache API for offline access return storeBlobInCache(filename, blob).then(() => { diff --git a/resources/styles.css b/resources/styles.css @@ -198,7 +198,7 @@ body { } } -.current-song { +.current-track { text-align: left; padding-left: 15px; padding-bottom: 10px; @@ -212,13 +212,13 @@ body { position: relative; } -.current-song span { +.current-track span { display: inline-block; white-space: nowrap; will-change: transform; } -.current-song.no-overflow span { +.current-track.no-overflow span { overflow: hidden; text-overflow: hidden; max-width: 100%;