remove resource-manifest; tracks instead of songs; load static files first
Hunter
Monday, April 6th, 2026 at 3:55 PM
21
22 # auto-generated PWA files
23 manifest.json
-resource-manifest.json
24 service-worker.js
25
26 # Personal deployment scripts
1 #!/usr/bin/env python3
2 """
-Search for songs and open purchase links.
3 +Search for tracks and open purchase links.
4
5 Usage:
6 ./buy.py <search query>
27
28
29 def search_itunes(query):
- """Search iTunes for a song and return results."""
30 + """Search iTunes for a track and return results."""
31 encoded_query = urllib.parse.quote(query)
32 url = f"https://itunes.apple.com/search?term={encoded_query}&media=music&entity=song&limit=10"
33
92 Poll watch_dir recursively until an audio file appears that wasn't in the
93 before snapshot. Returns the full path of the new file.
94 """
- print(f"Watching for new file in:\n {watch_dir}")
- print("(complete your purchase in iTunes — press Ctrl+C to cancel)")
95 + print(f"Watching for new file...")
96 + print("(complete your purchase in iTunes or press Ctrl+C to cancel)")
97
98 while True:
99 after = snapshot_audio_files(watch_dir)
108 if len(sys.argv) > 1:
109 query = ' '.join(sys.argv[1:])
110 else:
- query = input("Enter song name, artist, or both: ").strip()
111 + query = input("Enter track name, artist, or both: ").strip()
112
113 if not query:
114 print("No search query provided.")
128
129 # Extract track info
130 artist = best_track.get('artistName', 'Unknown')
- song = best_track.get('trackName', 'Unknown')
131 + track = best_track.get('trackName', 'Unknown')
132 itunes_url = best_track.get('trackViewUrl')
133 track_id = best_track.get('trackId')
134
137 return
138
139 # Open iTunes purchase page (macOS) or song.link (other platforms)
- print(f"Opening: {artist} - {song}")
140 + print(f"Opening: {artist} - {track}")
141 if platform.system() == 'Darwin':
- print("(Opening iTunes Store directly)")
142 + print("Opening iTunes Store directly...")
143 else:
- print("(Opening song.link with all platform options)")
144 + print("Opening song.link with all platform options...")
145
146 opened_itunes = open_itunes_link(itunes_url, track_id)
147
1 #!/usr/bin/env python3
2 """
-Creates manifest.json, resource-manifest.json, and service-worker.js
3 +Creates manifest.json and service-worker.js
4 based on the contents of tracks.json
5 """
6
160 json.dump(manifest, f, indent=2)
161 print("✓ Generated manifest.json")
162
- # Generate resource-manifest.json
163 + # Build static files list for service worker
164 static_files = [
165 "./",
166 "index.html",
167 + "manifest.json",
168 "resources/styles.css",
169 "resources/script.js",
170 "mix/tracks.json",
181 if (SCRIPT_DIR / "mix" / optional_file).exists():
182 static_files.append(f"mix/{optional_file}")
183
- 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")
184 + static_files_js = json.dumps(static_files)
185
186 # Generate service-worker.js
- static_files = resource_manifest["static_files"]
187 service_worker_content = f'''// Auto-generated service worker for {app_name} PWA
188 const CACHE_NAME = '{cache_name}';
-const staticFilesToCache = {json.dumps(static_files, indent=2)};
189
190 // Get the base path from the service worker location
191 const getBasePath = () => {{
195
196 const basePath = getBasePath();
197
-// Install event - cache only static resources (not audio files)
198 +// Static files to cache on install
199 +const STATIC_FILES = {static_files_js};
200 +
201 +// Install event - cache static resources
202 // Audio files will be cached by the main app's blob preloading system
203 self.addEventListener('install', (event) => {{
204 console.log('Service Worker installing...', 'Base path:', basePath);
205 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);
- }})
206 + caches.open(CACHE_NAME).then(cache => {{
207 + // Make URLs absolute relative to service worker location
208 + const absoluteUrls = STATIC_FILES.map(url => {{
209 + if (url === './') return basePath;
210 + return new URL(url, basePath + 'index.html').href;
211 + }});
212 + console.log('Caching', absoluteUrls.length, 'static resources');
213 +
214 + // Cache files individually with better error handling
215 + // Using Promise.allSettled to continue even if some fail
216 + return Promise.allSettled(
217 + absoluteUrls.map(url =>
218 + fetch(url, {{ cache: 'no-cache' }})
219 + .then(response => {{
220 + if (!response.ok) {{
221 + throw new Error(`HTTP error! status: ${{response.status}}`);
222 + }}
223 + return cache.put(url, response);
224 + }})
225 + .then(() => console.log('✓ Cached:', url))
226 + .catch(err => {{
227 + console.error('✗ Failed to cache:', url, err);
228 + throw err;
229 + }})
230 + )
231 + ).then(results => {{
232 + const failed = results.filter(r => r.status === 'rejected');
233 + const succeeded = results.filter(r => r.status === 'fulfilled');
234 + console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`);
235 + if (failed.length > 0) {{
236 + console.warn(`Failed to cache ${{failed.length}} resources`);
237 + }}
238 + }});
239 + }}).then(() => {{
240 + console.log('Service Worker installation complete');
241 + return self.skipWaiting();
242 + }}).catch(error => {{
243 + console.error('Service Worker installation failed:', error);
244 + }})
245 );
246 }});
247
16 <body>
17 <div class="window-container">
18 <div class="player-container">
- <div class="current-song" id="currentSong">No song playing</div>
19 + <div class="current-track" id="currentTrack">No track playing</div>
20 <div class="progress-container" id="progressContainer">
21 <div class="progress-bar" id="progressBar">
22 <span class="progress-text progress-text-left" id="progressTextLeft"></span>
39 1. **prep your playlist**
40 - add your audio files to the `/mix` directory, or use:
41 - `./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.
42 + - `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
43 + - 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.
44 - optionally, add an `album_art.jpg` to `/mix` to set the cover art for your mix.
45 - supported audio formats: `.mp3`, `.m4a`, `.ogg`, `.flac`, `.wav`
46
49
50 3. **manifesting**
51 - 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`.
52 + - this creates the config files that enable offline functionality: `manifest.json` and `service-worker.js`.
53
54 4. **ship it**
55 - upload the entire project directory to any web host with HTTPS support (GitHub Pages, AWS S3, etc.)
15 const prevBtn = document.getElementById('prev');
16 const nextBtn = document.getElementById('next');
17 const playlist = document.getElementById('playlist');
-const currentSongDisplay = document.getElementById('currentSong');
18 +const currentTrackDisplay = document.getElementById('currentTrack');
19 const progressBar = document.getElementById('progressBar');
20 const progressContainer = document.getElementById('progressContainer');
21 const audio = document.getElementById('audioPlayer');
32 return shuffled;
33 }
34
-let currentSongIndex = 0;
35 +let currentTrackIndex = 0;
36 let isPlaying = false;
37 let progressInterval;
38 let playerReady = false;
-let songs = [];
39 +let tracks = [];
40 let animationFrameId = null;
41 let prePlaySeekTime = 0;
42 let preloadedAudio = {}; // Cache for preloaded audio elements
43 let currentPreloadIndex = 0;
-let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
44 +let priorityPreloadQueue = []; // Tracks requested by user that need priority preloading
45 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
46 +let totalBytesLoaded = 0; // Track total filesize of all preloaded tracks
47 +let cachedTracks = new Set(); // Track which tracks are cached for offline use
48 let CACHE_NAME = 'my-mixapp'; // Default fallback
49 +const staticFiles = [
50 + './',
51 + 'index.html',
52 + 'manifest.json',
53 + 'resources/styles.css',
54 + 'resources/script.js',
55 + 'mix/tracks.json',
56 + 'resources/icon.png',
57 + 'resources/play.svg',
58 + 'resources/pause.svg',
59 + 'resources/prev.svg',
60 + 'resources/next.svg',
61 + 'resources/repeat.svg',
62 +];
63 +
64 +// Retry fetch with exponential backoff
65 +async function fetchWithRetry(url, maxRetries = 4, baseDelay = 2000) {
66 + for (let attempt = 0; attempt <= maxRetries; attempt++) {
67 + try {
68 + const response = await fetch(url);
69 + if (!response.ok) {
70 + throw new Error(`HTTP error! status: ${response.status}`);
71 + }
72 + return response;
73 + } catch (error) {
74 + if (attempt === maxRetries) throw error;
75 + const delay = baseDelay * Math.pow(2, attempt);
76 + console.warn(`Fetch failed for ${url}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...`);
77 + await new Promise(resolve => setTimeout(resolve, delay));
78 + }
79 + }
80 +}
81
82 // Load cache name from manifest.json first, then load tracks
83 fetch('manifest.json')
95 }
96 }
97
- return fetch('mix/tracks.json');
98 + // Probe for optional files and add them to staticFiles
99 + const optionalFiles = ['mix/album_art.jpg', 'mix/custom.css', 'mix/custom.js'];
100 + return Promise.all([
101 + ...optionalFiles.map(f =>
102 + fetch(f, { method: 'HEAD' })
103 + .then(r => { if (r.ok) staticFiles.push(f); })
104 + .catch(() => {})
105 + ),
106 + fetch('mix/tracks.json')
107 + .then(r => {
108 + if (!r.ok) throw new Error('tracks.json not found');
109 + return r.json();
110 + })
111 + ]);
112 })
- .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) {
113 + .then((results) => {
114 + const data = results[results.length - 1];
115 +
116 + tracks = shuffle ? shuffleArray(data) : data;
117 + if (tracks.length > 0) {
118 playerReady = true;
- updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`);
119 + updateCurrentTrackDisplay(`Ready to play: ${tracks[0].artist} – ${tracks[0].title}`);
120 // Check which tracks are already cached before rendering
121 return checkCachedTracks().then(() => {
122 renderPlaylist();
- // Pre-cache resources first, then songs
- return preloadResources().then(() => {
- startPreloadingSongs();
123 + // Verify all static files are cached, then start caching tracks
124 + return verifyStaticCache().then(() => {
125 + startPreloadingTracks();
126 });
127 });
128 } else {
- updateCurrentSongDisplay('No tracks found');
129 + updateCurrentTrackDisplay('No tracks found');
130 }
131 })
132 .catch(error => {
133 console.error('Error loading tracks:', error);
- updateCurrentSongDisplay('Unable to load tracks. Please check your connection.');
134 + updateCurrentTrackDisplay('Unable to load tracks. Please check your connection.');
135 });
136
137 // Audio event listeners
139 isPlaying = true;
140 updatePlayPauseButton();
141 startProgressBar();
- const song = songs[currentSongIndex];
- const songText = `${song.artist} – ${song.title}`;
142 + const track = tracks[currentTrackIndex];
143 + const trackText = `${track.artist} – ${track.title}`;
144
145 // Always update display to ensure we remove "Ready to play:" prefix
- const currentText = currentSongDisplay.querySelector('span')?.textContent || '';
146 + const currentText = currentTrackDisplay.querySelector('span')?.textContent || '';
147
- // Check if it's a different song (not just play/pause of same song)
- const isNewSong = !currentText.includes(songText);
148 + // Check if it's a different track (not just play/pause of same track)
149 + const isNewTrack = !currentText.includes(trackText);
150 const hasReadyToPlay = currentText.includes('Ready to play:');
151
- if (isNewSong || hasReadyToPlay) {
- updateCurrentSongDisplay(songText);
152 + if (isNewTrack || hasReadyToPlay) {
153 + updateCurrentTrackDisplay(trackText);
154 } else {
- // Same song, just resume the marquee
155 + // Same track, just resume the marquee
156 resumeMarquee();
157 }
158
162 // Use document.baseURI to correctly resolve paths in subdirectories
163 const albumArtUrl = new URL('mix/album_art.jpg', document.baseURI).href;
164 navigator.mediaSession.metadata = new MediaMetadata({
- title: song.title,
- artist: song.artist,
165 + title: track.title,
166 + artist: track.artist,
167 artwork: [
168 { src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' },
169 { src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' },
188
189 navigator.mediaSession.setActionHandler('previoustrack', () => {
190 if (playerReady) {
- prevSong();
191 + prevTrack();
192 }
193 });
194
195 navigator.mediaSession.setActionHandler('nexttrack', () => {
196 if (playerReady) {
- nextSong();
197 + nextTrack();
198 }
199 });
200
212 });
213
214 audio.addEventListener('ended', () => {
- if (songs[currentSongIndex].looping) {
215 + if (tracks[currentTrackIndex].looping) {
216 audio.currentTime = 0;
217 audio.play();
218 } else {
- nextSong();
219 + nextTrack();
220 }
221 });
222
223 audio.addEventListener('error', (e) => {
224 console.error('Audio error:', e);
- updateCurrentSongDisplay(`Error loading: ${songs[currentSongIndex].filename}`);
- // Try next song after a brief delay
- setTimeout(() => nextSong(), 1000);
225 + updateCurrentTrackDisplay(`Error loading: ${tracks[currentTrackIndex].filename}`);
226 + // Try next track after a brief delay
227 + setTimeout(() => nextTrack(), 1000);
228 });
229
230 audio.addEventListener('loadedmetadata', () => {
233
234 function renderPlaylist() {
235 playlist.innerHTML = '';
- const currentDisplayText = currentSongDisplay.textContent;
- const isInitialized = currentDisplayText !== 'No song playing';
236 + const currentDisplayText = currentTrackDisplay.textContent;
237 + const isInitialized = currentDisplayText !== 'No track playing';
238
- songs.forEach((song, index) => {
239 + tracks.forEach((track, index) => {
240 const item = document.createElement('div');
241 item.classList.add('playlist-item');
242
245
246 const titleDiv = document.createElement('div');
247 titleDiv.classList.add('playlist-item-title');
- if (isInitialized && index === currentSongIndex) {
248 + if (isInitialized && index === currentTrackIndex) {
249 titleDiv.classList.add('current');
250 }
- titleDiv.textContent = song.title;
251 + titleDiv.textContent = track.title;
252
253 const artistDiv = document.createElement('div');
254 artistDiv.classList.add('playlist-item-artist');
- if (isInitialized && index === currentSongIndex) {
255 + if (isInitialized && index === currentTrackIndex) {
256 artistDiv.classList.add('current');
257 }
- artistDiv.textContent = song.artist;
258 + artistDiv.textContent = track.artist;
259
260 // Set cached status for visual indication
- const isCached = cachedTracks.has(song.filename);
261 + const isCached = cachedTracks.has(track.filename);
262 if (!isCached) {
263 contentDiv.classList.add('uncached');
264 }
269 const loopIcon = document.createElement('span');
270 loopIcon.style.width = '1.18em';
271 loopIcon.style.height = '1.18em';
- loopIcon.style.display = (song.looping || false) ? 'inline-block' : 'none';
272 + loopIcon.style.display = (track.looping || false) ? 'inline-block' : 'none';
273 loopIcon.style.color = 'var(--text)';
274 fetch('resources/repeat.svg')
275 .then(r => r.text())
291
292 function toggleLooping(index) {
293 if (!playerReady) return;
- if (index === currentSongIndex) {
- if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
294 + if (index === currentTrackIndex) {
295 + if (!isPlaying && currentTrackDisplay.textContent.includes('Ready to play')) {
296 audio.play().catch(err => {
297 console.error('Failed to play audio:', err);
298 });
301 return;
302 }
303 // Toggle looping
- songs[index].looping = !(songs[index].looping || false);
304 + tracks[index].looping = !(tracks[index].looping || false);
305 renderPlaylist();
306 } else {
- playSong(index);
307 + playTrack(index);
308 }
309 }
310
-function playSong(index) {
- console.log(`playSong called with index: ${index}`);
311 +function playTrack(index) {
312 + console.log(`playTrack called with index: ${index}`);
313 if (!playerReady) {
314 console.log('Player not ready');
315 return;
316 }
- // 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);
317 + // Clear looping from all tracks except the new one if it was already looping
318 + const wasLooping = tracks[index].looping || false;
319 + tracks.forEach(track => track.looping = false);
320 if (wasLooping) {
- songs[index].looping = true;
321 + tracks[index].looping = true;
322 }
323
- 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]}`);
324 + currentTrackIndex = index;
325 + const track = tracks[currentTrackIndex];
326 + console.log(`Attempting to play: ${track.artist} – ${track.title}`);
327 + console.log(`Filename: ${track.filename}`);
328 + console.log(`Is in preloadedAudio: ${!!preloadedAudio[track.filename]}`);
329
330 // 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}`);
331 + if (preloadedAudio[track.filename]) {
332 + const blobUrl = preloadedAudio[track.filename].blobUrl;
333 + console.log(`Playing from preloaded blob: ${track.filename}`);
334 console.log(`Blob URL: ${blobUrl}`);
335 audio.src = blobUrl;
336 } else {
- console.log(`Song not preloaded, loading: ${song.filename}`);
- audio.src = `mix/${song.filename}`;
- // Request priority preloading for this song
- requestPriorityPreload(song.filename);
337 + console.log(`Track not preloaded, loading: ${track.filename}`);
338 + audio.src = `mix/${track.filename}`;
339 + // Request priority preloading for this track
340 + requestPriorityPreload(track.filename);
341 }
342
343 console.log(`Audio src set to: ${audio.src}`);
369 console.error('Error message:', err.message);
370 isPlaying = false;
371 updatePlayPauseButton();
- updateCurrentSongDisplay(`Error playing: ${song.title}`);
372 + updateCurrentTrackDisplay(`Error playing: ${track.title}`);
373 });
374 }
375
379 renderPlaylist();
380 }
381
-function updateCurrentSongDisplay(text) {
- currentSongDisplay.innerHTML = `<span>${text}</span>`;
382 +function updateCurrentTrackDisplay(text) {
383 + currentTrackDisplay.innerHTML = `<span>${text}</span>`;
384 // Initialize marquee effect after a brief delay to ensure DOM is updated
385 setTimeout(() => setupMarquee(), 50);
386 }
397 const marqueeSpeed = 50; // pixels per second
398
399 function setupMarquee() {
- const container = currentSongDisplay;
400 + const container = currentTrackDisplay;
401 const textSpan = container.querySelector('span');
402
403 if (!textSpan) return;
470 // Modulo by one segment width for seamless wrapping — no loop boundary
471 const offset = totalDistance % marqueeHalfWidth;
472
- const textSpan = currentSongDisplay.querySelector('span');
473 + const textSpan = currentTrackDisplay.querySelector('span');
474 if (textSpan) {
475 textSpan.style.transform = `translateX(-${offset}px)`;
476 }
501 if (!marqueeAnimating) return;
502 marqueePaused = false;
503
- const textSpan = currentSongDisplay.querySelector('span');
504 + const textSpan = currentTrackDisplay.querySelector('span');
505 if (!textSpan) return;
506
507 // Resume from where we left off
516 const wasPaused = marqueePaused;
517
518 // Restore original text before recalculating
- const textSpan = currentSongDisplay.querySelector('span');
519 + const textSpan = currentTrackDisplay.querySelector('span');
520 if (textSpan) {
521 textSpan.textContent = marqueeOriginalText;
522 }
536 audio.pause();
537 isPlaying = false;
538 } else {
- // If no song is loaded, load the first one
539 + // If no track is loaded, load the first one
540 if (!audio.src || audio.src === '') {
- playSong(currentSongIndex);
541 + playTrack(currentTrackIndex);
542 } else {
543 audio.play().catch(err => {
544 console.error('Failed to play audio:', err);
- const song = songs[currentSongIndex];
- updateCurrentSongDisplay(`Error playing: ${song.title}`);
545 + const track = tracks[currentTrackIndex];
546 + updateCurrentTrackDisplay(`Error playing: ${track.title}`);
547 });
548 isPlaying = true;
549 }
555 playPauseBtn.classList.toggle('pause', isPlaying);
556 }
557
-function nextSong() {
558 +function nextTrack() {
559 if (!playerReady) return;
- currentSongIndex = (currentSongIndex + 1) % songs.length;
- playSong(currentSongIndex);
560 + currentTrackIndex = (currentTrackIndex + 1) % tracks.length;
561 + playTrack(currentTrackIndex);
562 }
563
-function prevSong() {
564 +function prevTrack() {
565 if (!playerReady) return;
566 if (audio.currentTime <= 3) {
- currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
- playSong(currentSongIndex);
567 + currentTrackIndex = (currentTrackIndex - 1 + tracks.length) % tracks.length;
568 + playTrack(currentTrackIndex);
569 } else {
570 audio.currentTime = 0;
571 }
585 // setInterval is less throttled than requestAnimationFrame in background
586 backgroundPlaybackCheckInterval = setInterval(() => {
587 if (!audio.paused && audio.duration && audio.currentTime >= audio.duration - 0.5) {
- console.log('Background check: song ended, triggering next');
588 + console.log('Background check: track ended, triggering next');
589 clearInterval(backgroundPlaybackCheckInterval);
590 backgroundPlaybackCheckInterval = null;
591
- if (songs[currentSongIndex].looping) {
592 + if (tracks[currentTrackIndex].looping) {
593 audio.currentTime = 0;
594 audio.play();
595 } else {
- nextSong();
596 + nextTrack();
597 }
598 }
599
665
666 // If audio hasn't been loaded yet, load it but don't play
667 if (!audio.src || audio.src === '') {
- const song = songs[currentSongIndex];
668 + const track = tracks[currentTrackIndex];
669
670 // Use preloaded blob if available, otherwise load from server
- if (preloadedAudio[song.filename]) {
- audio.src = preloadedAudio[song.filename].blobUrl;
671 + if (preloadedAudio[track.filename]) {
672 + audio.src = preloadedAudio[track.filename].blobUrl;
673 } else {
- audio.src = `mix/${song.filename}`;
674 + audio.src = `mix/${track.filename}`;
675 }
676
677 // Wait for metadata to be loaded before seeking
814 }
815
816 playPauseBtn.addEventListener('click', togglePlayPause);
-nextBtn.addEventListener('click', nextSong);
-prevBtn.addEventListener('click', prevSong);
817 +nextBtn.addEventListener('click', nextTrack);
818 +prevBtn.addEventListener('click', prevTrack);
819
820 // Mouse events for desktop
821 progressContainer.addEventListener('mousedown', onProgressMouseDown);
838 }
839 });
840
-// 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;
- });
- });
841 +// Verify all static files are in the cache, fetching any that are missing
842 +async function verifyStaticCache() {
843 + if (staticFiles.length === 0) {
844 + console.warn('No static files list available');
845 + return;
846 + }
847
- return Promise.all(imagePromises).then(() => {
- console.log('All UI resources preloaded');
- });
848 + console.log(`Verifying ${staticFiles.length} static files are cached...`);
849 +
850 + try {
851 + const cache = await caches.open(CACHE_NAME);
852 + const cachedRequests = await cache.keys();
853 + const cachedUrls = new Set(cachedRequests.map(r => r.url));
854 +
855 + const missing = [];
856 + for (const file of staticFiles) {
857 + const absoluteUrl = file === './'
858 + ? new URL('./', window.location.href).href
859 + : new URL(file, window.location.href).href;
860 + if (!cachedUrls.has(absoluteUrl)) {
861 + missing.push({ file, absoluteUrl });
862 + }
863 + }
864 +
865 + if (missing.length === 0) {
866 + console.log('All static files already cached');
867 + return;
868 + }
869 +
870 + console.log(`${missing.length} static files missing from cache, fetching...`);
871 +
872 + await Promise.all(missing.map(async ({ file, absoluteUrl }) => {
873 + try {
874 + const response = await fetchWithRetry(file);
875 + await cache.put(absoluteUrl, response);
876 + console.log(`✓ Cached missing static file: ${file}`);
877 + } catch (error) {
878 + console.error(`✗ Failed to cache static file: ${file}`, error);
879 + }
880 + }));
881 +
882 + console.log('Static cache verification complete');
883 + } catch (error) {
884 + console.error('Failed to verify static cache:', error);
885 + }
886 +
887 + // Preload image/SVG assets into the browser's in-memory cache
888 + const imageFiles = staticFiles.filter(f =>
889 + f.endsWith('.svg') || f.endsWith('.jpg') || f.endsWith('.png')
890 + );
891 + await Promise.all(imageFiles.map(src => new Promise(resolve => {
892 + const img = new Image();
893 + img.onload = () => {
894 + console.log(`Preloaded: ${src}`);
895 + resolve();
896 + };
897 + img.onerror = () => {
898 + console.error(`Failed to preload: ${src}`);
899 + resolve();
900 + };
901 + img.src = src;
902 + })));
903 }
904
-function startPreloadingSongs() {
- // Start with the first song
905 +function startPreloadingTracks() {
906 + // Start with the first track
907 currentPreloadIndex = 0;
- preloadNextSong();
908 + preloadNextTrack();
909 }
910
-function preloadNextSong() {
- if (currentPreloadIndex >= songs.length) {
911 +function preloadNextTrack() {
912 + if (currentPreloadIndex >= tracks.length) {
913 const totalMB = (totalBytesLoaded / 1024 / 1024).toFixed(2);
- console.log(`All songs preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`);
914 + console.log(`All tracks preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`);
915 return;
916 }
917
- const song = songs[currentPreloadIndex];
- const filename = song.filename;
918 + const track = tracks[currentPreloadIndex];
919 + const filename = track.filename;
920
921 // Skip if already preloaded in memory
922 if (preloadedAudio[filename]) {
923 currentPreloadIndex++;
- preloadNextSong();
924 + preloadNextTrack();
925 return;
926 }
927
928 // If already cached, load from cache into memory
929 if (cachedTracks.has(filename)) {
- console.log(`Loading from cache: ${song.artist} – ${song.title}`);
930 + console.log(`Loading from cache: ${track.artist} – ${track.title}`);
931 loadFromCache(filename).then(() => {
932 currentPreloadIndex++;
- setTimeout(() => preloadNextSong(), 100);
933 + setTimeout(() => preloadNextTrack(), 100);
934 }).catch(err => {
935 console.error(`Failed to load from cache, fetching instead:`, err);
936 // If cache load fails, fetch from network
- fetchAndPreloadSong(song, filename);
937 + fetchAndPreloadTrack(track, filename);
938 });
939 return;
940 }
941
- console.log(`Preloading: ${song.artist} – ${song.title}`);
- fetchAndPreloadSong(song, filename);
942 + console.log(`Preloading: ${track.artist} – ${track.title}`);
943 + fetchAndPreloadTrack(track, filename);
944 }
945
-function fetchAndPreloadSong(song, filename) {
- // Use fetch to force full download of the entire file
- fetch(`mix/${filename}`)
946 +function fetchAndPreloadTrack(track, filename) {
947 + fetchWithRetry(`mix/${filename}`)
948 .then(response => {
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- // Get total file size for progress tracking
949 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
950 + console.log(`Downloading ${track.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`);
951 return response.blob();
952 })
953 .then(blob => {
962 blob: blob
963 };
964
- console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`);
965 + console.log(`✓ Fully preloaded: ${track.artist} – ${track.title}`);
966
967 // Store in Cache API for offline access
968 return storeBlobInCache(filename, blob).then(() => {
970 cachedTracks.add(filename);
971 updateTrackCachedStatus(filename);
972
- // Move to next song
973 + // Move to next track
974 currentPreloadIndex++;
- setTimeout(() => preloadNextSong(), 100);
975 + setTimeout(() => preloadNextTrack(), 100);
976 });
977 })
978 .catch(error => {
979 console.error(`Failed to preload ${filename}:`, error);
980 currentPreloadIndex++;
- preloadNextSong();
981 + preloadNextTrack();
982 });
983 }
984
988 const cache = await caches.open(CACHE_NAME);
989 const cachedRequests = await cache.keys();
990
- // Check each song to see if it's cached
- for (const song of songs) {
991 + // Check each track to see if it's cached
992 + for (const track of tracks) {
993 // Build the same absolute URL that storeBlobInCache uses for consistency
- const absoluteUrl = new URL(`mix/${song.filename}`, window.location.href).href;
994 + const absoluteUrl = new URL(`mix/${track.filename}`, window.location.href).href;
995 const isInCache = cachedRequests.some(request => request.url === absoluteUrl);
996 if (isInCache) {
- cachedTracks.add(song.filename);
997 + cachedTracks.add(track.filename);
998 }
999 }
1000
- console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`);
1001 + console.log(`Found ${cachedTracks.size}/${tracks.length} tracks already cached`);
1002 console.log('Cached tracks:', Array.from(cachedTracks));
1003 } catch (error) {
1004 console.error('Failed to check cached tracks:', error);
1010 console.log('=== Audio State Debug ===');
1011 console.log('Player ready:', playerReady);
1012 console.log('Is playing:', isPlaying);
- console.log('Current song index:', currentSongIndex);
- console.log('Total songs:', songs.length);
1013 + console.log('Current track index:', currentTrackIndex);
1014 + console.log('Total tracks:', tracks.length);
1015 console.log('Cached tracks count:', cachedTracks.size);
1016 console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
1017 console.log('Current audio src:', audio.src);
1018 console.log('Audio paused:', audio.paused);
1019 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));
1020 + if (tracks[currentTrackIndex]) {
1021 + console.log('Current track:', tracks[currentTrackIndex].filename);
1022 + console.log('Is preloaded:', !!preloadedAudio[tracks[currentTrackIndex].filename]);
1023 + console.log('Is cached:', cachedTracks.has(tracks[currentTrackIndex].filename));
1024 }
1025 console.log('======================');
1026 };
1097
1098 // Update UI to show track is cached
1099 function updateTrackCachedStatus(filename) {
- const songIndex = songs.findIndex(s => s.filename === filename);
- if (songIndex === -1) return;
1100 + const trackIndex = tracks.findIndex(s => s.filename === filename);
1101 + if (trackIndex === -1) return;
1102
1103 // Find the playlist item and remove uncached class
1104 const playlistItems = playlist.querySelectorAll('.playlist-item');
- if (playlistItems[songIndex]) {
- const contentDiv = playlistItems[songIndex].querySelector('.playlist-item-content');
1105 + if (playlistItems[trackIndex]) {
1106 + const contentDiv = playlistItems[trackIndex].querySelector('.playlist-item-content');
1107 if (contentDiv) {
1108 contentDiv.classList.remove('uncached');
1109 }
1141 return;
1142 }
1143
- // Find the song info
- const song = songs.find(s => s.filename === filename);
- if (!song) {
1144 + // Find the track info
1145 + const track = tracks.find(s => s.filename === filename);
1146 + if (!track) {
1147 processPriorityPreload();
1148 return;
1149 }
1150
1151 // If already cached, load from cache
1152 if (cachedTracks.has(filename)) {
- console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`);
1153 + console.log(`🔥 Priority loading from cache: ${track.artist} – ${track.title}`);
1154 loadFromCache(filename).then(() => {
1155 processPriorityPreload();
1156 }).catch(err => {
1157 console.error(`Failed to load from cache, fetching instead:`, err);
- priorityFetchAndPreloadSong(song, filename);
1158 + priorityFetchAndPreloadTrack(track, filename);
1159 });
1160 return;
1161 }
1162
- console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`);
- priorityFetchAndPreloadSong(song, filename);
1163 + console.log(`🔥 Priority preloading: ${track.artist} – ${track.title}`);
1164 + priorityFetchAndPreloadTrack(track, filename);
1165 }
1166
-function priorityFetchAndPreloadSong(song, filename) {
- // Use fetch to force full download of the entire file
- fetch(`mix/${filename}`)
1167 +function priorityFetchAndPreloadTrack(track, filename) {
1168 + fetchWithRetry(`mix/${filename}`)
1169 .then(response => {
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
1170 return response.blob();
1171 })
1172 .then(blob => {
1178 blob: blob
1179 };
1180
- // Next time this song plays, it will use the cached version
- console.log(`✓ Priority preloaded: ${song.artist} – ${song.title}`);
1181 + // Next time this track plays, it will use the cached version
1182 + console.log(`✓ Priority preloaded: ${track.artist} – ${track.title}`);
1183
1184 // Store in Cache API for offline access
1185 return storeBlobInCache(filename, blob).then(() => {
198 }
199 }
200
-.current-song {
201 +.current-track {
202 text-align: left;
203 padding-left: 15px;
204 padding-bottom: 10px;
212 position: relative;
213 }
214
-.current-song span {
215 +.current-track span {
216 display: inline-block;
217 white-space: nowrap;
218 will-change: transform;
219 }
220
-.current-song.no-overflow span {
221 +.current-track.no-overflow span {
222 overflow: hidden;
223 text-overflow: hidden;
224 max-width: 100%;