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

Hunter
Monday, April 6th, 2026 at 3:55 PM

.gitignore -1
buy.py+9-9
generate_manifests.py+47-57
index.html+1-1
readme.md+3-3
resources/script.js+245-183
resources/styles.css+3-3

.gitignore
21  
22  # auto-generated PWA files
23  manifest.json
   -resource-manifest.json
24  service-worker.js
25  
26  # Personal deployment scripts
buy.py
  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  
generate_manifests.py
  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  
index.html
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>
readme.md
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.)
resources/script.js
  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(() => {
resources/styles.css
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%;