declare album art in manifest with cache-busting hash

Hunter
Sunday, May 17th, 2026 at 5:27 PM

build.py+15-2
resources/script.js+23-18

build.py
115  	# Get background color from styles.css
116  	background_color = get_background_color()
117  
118 +	# mix/album_art.jpg overrides the default
119 +	custom_album_art = SCRIPT_DIR / "mix" / "album_art.jpg"
120 +	album_art_file = custom_album_art if custom_album_art.exists() else SCRIPT_DIR / "resources" / "album_art.jpg"
121 +	album_art_path = "mix/album_art.jpg" if custom_album_art.exists() else "resources/album_art.jpg"
122 +	# append a content hash so the URL changes when the image changes
123 +	if album_art_file.exists():
124 +		import hashlib
125 +		art_hash = hashlib.md5(album_art_file.read_bytes()).hexdigest()[:8]
126 +		album_art_path = f"{album_art_path}?v={art_hash}"
127 +	print(f"  Album art: {album_art_path}")
128 +
129  	# Generate manifest.json
130  	manifest = {
131  		"id": base_path,

138  		"background_color": background_color,
139  		"theme_color": background_color,
140  		"cache_name": cache_name,  # Custom field for script.js to use
141 +		"album_art": album_art_path,  # Custom field for script.js to use
142  		"icons": [
143  			{
144  				"src": f"{base_path}resources/icon.png",

162  		"resources/script.js",
163  		"mix/tracks.json",
164  		"resources/icon.png",
    -		"resources/album_art.jpg",
165 +		album_art_path,
166  		"resources/play.svg",
167  		"resources/pause.svg",
168  		"resources/prev.svg",

172  	]
173  
174  	AUDIO_EXTS = {".mp3", ".m4a", ".ogg", ".flac", ".wav"}
    -	SKIP_NAMES = {"tracks.json", "readme.md"}
175 +	# album_art.jpg added explicitly above with a hash; skip to avoid a bare dupe
176 +	SKIP_NAMES = {"tracks.json", "readme.md", "album_art.jpg"}
177  	for path in sorted((SCRIPT_DIR / "mix").iterdir()):
178  		if not path.is_file():
179  			continue
resources/script.js
 75  let totalBytesLoaded = 0; // Track total filesize of all preloaded tracks
 76  let cachedTracks = new Set(); // Track which tracks are cached for offline use
 77  let CACHE_NAME = 'my-mixapp'; // Default fallback
    -let hasCustomAlbumArt = false;
 78 +let albumArtPath = 'resources/album_art.jpg'; // Overridden by manifest.album_art
 79  const staticFiles = [
 80  	'./',
 81  	'index.html',

 83  	'resources/script.js',
 84  	'mix/tracks.json',
 85  	'resources/icon.png',
    -	'resources/album_art.jpg',
 86  	'resources/play.svg',
 87  	'resources/pause.svg',
 88  	'resources/prev.svg',

130  			if (manifest.name) {
131  				document.title = manifest.name;
132  			}
133 +
134 +			// build.py resolves this to the custom mix art when present
135 +			if (manifest.album_art) {
136 +				albumArtPath = manifest.album_art;
137 +			}
138  		}
139 +		staticFiles.push(albumArtPath);
140  
141  		// Probe for optional files and add them to staticFiles
    -		const optionalFiles = ['mix/album_art.jpg', 'mix/custom.css', 'mix/custom.js'];
142 +		const optionalFiles = ['mix/custom.css', 'mix/custom.js'];
143  		return Promise.all([
144  			...optionalFiles.map(f =>
145  				fetch(f, { method: 'HEAD' })
146  					.then(r => {
147  						if (r.ok) {
148  							staticFiles.push(f);
    -							if (f === 'mix/album_art.jpg') hasCustomAlbumArt = true;
149  						}
150  					})
151  					.catch(() => {})

187  		updateCurrentTrackDisplay('Unable to load tracks. Please check your connection.');
188  	});
189  
190 +// Set MediaMetadata for the given track. albumArtPath is resolved at build time
191 +// (custom mix art if present, else default) and read from the manifest on startup.
192 +function updateMediaSessionMetadata(track) {
193 +	if (!('mediaSession' in navigator) || !track) return;
194 +	const albumArtUrl = new URL(albumArtPath, document.baseURI).href;
195 +	navigator.mediaSession.metadata = new MediaMetadata({
196 +		title: track.title,
197 +		artist: track.artist,
198 +		artwork: [
199 +			{ src: albumArtUrl, sizes: 'any', type: 'image/jpeg' }
200 +		]
201 +	});
202 +}
203 +
204  // Audio event listeners
205  audio.addEventListener('play', () => {
206  	isPlaying = true;

225  
226  	// Update media session metadata
227  	if ('mediaSession' in navigator) {
    -		// Convert relative path to absolute URL for media session
    -		// Use document.baseURI to correctly resolve paths in subdirectories
    -		const albumArtPath = hasCustomAlbumArt ? 'mix/album_art.jpg' : 'resources/album_art.jpg';
    -		const albumArtUrl = new URL(albumArtPath, document.baseURI).href;
    -		navigator.mediaSession.metadata = new MediaMetadata({
    -			title: track.title,
    -			artist: track.artist,
    -			artwork: [
    -				{ src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' },
    -				{ src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' },
    -				{ src: albumArtUrl, sizes: '256x256', type: 'image/jpeg' },
    -				{ src: albumArtUrl, sizes: '128x128', type: 'image/jpeg' }
    -			]
    -		});
228 +		updateMediaSessionMetadata(track);
229  
230  		// Set action handlers after playback starts (required for iOS)
231  		// Explicitly set seek handlers to null so iOS shows next/prev instead