fade in tracks when cached

Hunter
Monday, November 3rd, 2025 at 12:30 AM

generate_manifests.py+50-18
script.js+262-15
styles.css+5 

generate_manifests.py
 71  	print("✓ Generated resource-manifest.json")
 72  
 73  	# Generate service-worker.js
    -	all_files = resource_manifest["static_files"] + resource_manifest["tracks"]
 74 +	static_files = resource_manifest["static_files"]
 75  	service_worker_content = f'''// Auto-generated service worker for vibe capsule PWA
    -const CACHE_NAME = 'vibe-capsule-v3';
    -const urlsToCache = {json.dumps(all_files, indent=2)};
 76 +const CACHE_NAME = 'vibe-capsule';
 77 +const staticFilesToCache = {json.dumps(static_files, indent=2)};
 78  
 79  // Get the base path from the service worker location
 80  const getBasePath = () => {{

 84  
 85  const basePath = getBasePath();
 86  
    -// Install event - cache all resources
 87 +// Install event - cache only static resources (not MP3s)
 88 +// MP3s will be cached by the main app's blob preloading system
 89  self.addEventListener('install', (event) => {{
 90  	console.log('Service Worker installing...', 'Base path:', basePath);
 91  	event.waitUntil(

 93  			.then((cache) => {{
 94  				console.log('Opened cache');
 95  				// Make URLs absolute relative to service worker location
    -				const absoluteUrls = urlsToCache.map(url => {{
 96 +				const absoluteUrls = staticFilesToCache.map(url => {{
 97  					if (url === './') return basePath;
 98  					return new URL(url, basePath + 'index.html').href;
 99  				}});
    -				console.log('Caching', absoluteUrls.length, 'resources');
100 +				console.log('Caching', absoluteUrls.length, 'static resources');
101  				console.log('URLs to cache:', absoluteUrls);
102  
    -				// Cache files one by one with better error handling
    -				return Promise.all(
103 +				// Cache files individually with better error handling
104 +				// Using Promise.allSettled to continue even if some fail
105 +				return Promise.allSettled(
106  					absoluteUrls.map(url =>
    -						cache.add(url)
107 +						fetch(url)
108 +							.then(response => {{
109 +								if (!response.ok) {{
110 +									throw new Error(`HTTP error! status: ${{response.status}}`);
111 +								}}
112 +								return cache.put(url, response);
113 +							}})
114  							.then(() => console.log('✓ Cached:', url))
    -							.catch(err => console.error('✗ Failed to cache:', url, err))
115 +							.catch(err => {{
116 +								console.error('✗ Failed to cache:', url, err);
117 +								throw err;
118 +							}})
119  					)
    -				);
120 +				).then(results => {{
121 +					const failed = results.filter(r => r.status === 'rejected');
122 +					const succeeded = results.filter(r => r.status === 'fulfilled');
123 +					console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`);
124 +					if (failed.length > 0) {{
125 +						console.warn(`Failed to cache ${{failed.length}} resources`);
126 +					}}
127 +				}});
128  			}})
129  			.then(() => {{
    -				console.log('All resources cached successfully');
130 +				console.log('Service Worker installation complete');
131  				return self.skipWaiting();
132  			}})
133  			.catch(error => {{

155  
156  // Fetch event - serve from cache, fallback to network
157  self.addEventListener('fetch', (event) => {{
158 +	// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
159 +	if (!event.request.url.startsWith('http')) {{
160 +		return;
161 +	}}
162 +
163  	event.respondWith(
164  		caches.match(event.request)
165  			.then((response) => {{
166  				// Cache hit - return response
167  				if (response) {{
    -					console.log('Serving from cache:', event.request.url);
168 +					console.log('✓ Serving from cache:', event.request.url);
169  					return response;
170  				}}
171  
172  				// Cache miss - try network
    -				console.log('Fetching from network:', event.request.url);
173 +				console.log('⟳ Fetching from network:', event.request.url);
174  				return fetch(event.request).then((response) => {{
175  					// Check if valid response
    -					if (!response || response.status !== 200) {{
176 +					if (!response || response.status !== 200 || response.type === 'error') {{
177  						return response;
178  					}}
179  
180  					// Clone the response for caching
181  					const responseToCache = response.clone();
182  
183 +					// Cache the fetched resource
184  					caches.open(CACHE_NAME)
185  						.then((cache) => {{
186  							cache.put(event.request, responseToCache);
    -						}});
187 +							console.log('✓ Cached from network:', event.request.url);
188 +						}})
189 +						.catch(err => console.error('Failed to cache:', err));
190  
191  					return response;
192  				}}).catch((error) => {{
    -					console.error('Fetch failed; returning offline page if available:', error);
193 +					console.error('✗ Fetch failed for:', event.request.url, error);
194  					// If fetch fails, try to return from cache one more time
    -					return caches.match(event.request);
195 +					return caches.match(event.request).then(cachedResponse => {{
196 +						if (cachedResponse) {{
197 +							console.log('✓ Serving from cache (after fetch failed):', event.request.url);
198 +							return cachedResponse;
199 +						}}
200 +						throw error;
201 +					}});
202  				}});
203  			}})
204  	);
script.js
  44  let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
  45  let isPreloadingPriority = false;
  46  let totalBytesLoaded = 0; // Track total filesize of all preloaded songs
  47 +let cachedTracks = new Set(); // Track which songs are cached for offline use
  48  
  49  // Load tracks from tracks.json
  50  fetch('tracks.json')

  59  		if (songs.length > 0) {
  60  			playerReady = true;
  61  			updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`);
     -			renderPlaylist();
     -			// Pre-cache resources first, then songs
     -			preloadResources().then(() => {
     -				startPreloadingSongs();
  62 +			// Check which tracks are already cached before rendering
  63 +			return checkCachedTracks().then(() => {
  64 +				renderPlaylist();
  65 +				// Pre-cache resources first, then songs
  66 +				return preloadResources().then(() => {
  67 +					startPreloadingSongs();
  68 +				});
  69  			});
  70  		} else {
  71  			updateCurrentSongDisplay('No tracks found');

 194  		}
 195  		artistDiv.textContent = song.artist;
 196  
 197 +		// Set cached status for visual indication
 198 +		const isCached = cachedTracks.has(song.filename);
 199 +		if (!isCached) {
 200 +			contentDiv.classList.add('uncached');
 201 +		}
 202 +
 203  		contentDiv.appendChild(titleDiv);
 204  		contentDiv.appendChild(artistDiv);
 205  

 218  	if (!playerReady) return;
 219  	if (index === currentSongIndex) {
 220  		if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
     -			audio.play();
 221 +			audio.play().catch(err => {
 222 +				console.error('Failed to play audio:', err);
 223 +			});
 224  			isPlaying = true;
 225  			updatePlayPauseButton();
 226  			return;

 234  }
 235  
 236  function playSong(index) {
     -	if (!playerReady) return;
 237 +	console.log(`playSong called with index: ${index}`);
 238 +	if (!playerReady) {
 239 +		console.log('Player not ready');
 240 +		return;
 241 +	}
 242  	// Clear looping from all songs except the new one if it was already looping
 243  	const wasLooping = songs[index].looping || false;
 244  	songs.forEach(song => song.looping = false);

 248  
 249  	currentSongIndex = index;
 250  	const song = songs[currentSongIndex];
 251 +	console.log(`Attempting to play: ${song.artist} – ${song.title}`);
 252 +	console.log(`Filename: ${song.filename}`);
 253 +	console.log(`Is in preloadedAudio: ${!!preloadedAudio[song.filename]}`);
 254  
 255  	// Use preloaded blob if available, otherwise load from server
 256  	if (preloadedAudio[song.filename]) {
     -		audio.src = preloadedAudio[song.filename].blobUrl;
 257 +		const blobUrl = preloadedAudio[song.filename].blobUrl;
 258 +		console.log(`Playing from preloaded blob: ${song.filename}`);
 259 +		console.log(`Blob URL: ${blobUrl}`);
 260 +		audio.src = blobUrl;
 261  	} else {
 262 +		console.log(`Song not preloaded, loading: ${song.filename}`);
 263  		audio.src = `tracks/${song.filename}`;
 264  		// Request priority preloading for this song
 265  		requestPriorityPreload(song.filename);
 266  	}
 267  
     -	audio.play();
 268 +	console.log(`Audio src set to: ${audio.src}`);
 269 +
 270 +	// For iOS PWA: We need to call load() and play() synchronously
 271 +	// Reset any previous state first
 272 +	try {
 273 +		audio.pause();
 274 +		audio.currentTime = 0;
 275 +	} catch (e) {
 276 +		// Ignore errors from resetting
 277 +	}
 278 +
 279 +	// Load the audio to ensure it's ready (important for iOS PWA)
 280 +	audio.load();
 281 +
 282 +	// Small delay to let load() initialize, then play
 283 +	// This needs to be synchronous enough that iOS considers it part of the user gesture
 284 +	const playAttempt = audio.play();
 285 +
 286 +	if (playAttempt !== undefined) {
 287 +		playAttempt.then(() => {
 288 +			console.log('Audio playback started successfully');
 289 +			isPlaying = true;
 290 +			updatePlayPauseButton();
 291 +		}).catch(err => {
 292 +			console.error('Failed to play audio:', err);
 293 +			console.error('Error name:', err.name);
 294 +			console.error('Error message:', err.message);
 295 +			isPlaying = false;
 296 +			updatePlayPauseButton();
 297 +			updateCurrentSongDisplay(`Error playing: ${song.title}`);
 298 +		});
 299 +	}
 300 +
 301 +	// Optimistically set playing state
 302  	isPlaying = true;
 303  	updatePlayPauseButton();
 304  	renderPlaylist();

 488  		if (!audio.src || audio.src === '') {
 489  			playSong(currentSongIndex);
 490  		} else {
     -			audio.play();
 491 +			audio.play().catch(err => {
 492 +				console.error('Failed to play audio:', err);
 493 +				const song = songs[currentSongIndex];
 494 +				updateCurrentSongDisplay(`Error playing: ${song.title}`);
 495 +			});
 496  			isPlaying = true;
 497  		}
 498  	}

 797  	const song = songs[currentPreloadIndex];
 798  	const filename = song.filename;
 799  
     -	// Skip if already preloaded
 800 +	// Skip if already preloaded in memory
 801  	if (preloadedAudio[filename]) {
 802  		currentPreloadIndex++;
 803  		preloadNextSong();
 804  		return;
 805  	}
 806  
 807 +	// If already cached, load from cache into memory
 808 +	if (cachedTracks.has(filename)) {
 809 +		console.log(`Loading from cache: ${song.artist} – ${song.title}`);
 810 +		loadFromCache(filename).then(() => {
 811 +			currentPreloadIndex++;
 812 +			setTimeout(() => preloadNextSong(), 100);
 813 +		}).catch(err => {
 814 +			console.error(`Failed to load from cache, fetching instead:`, err);
 815 +			// If cache load fails, fetch from network
 816 +			fetchAndPreloadSong(song, filename);
 817 +		});
 818 +		return;
 819 +	}
 820 +
 821  	console.log(`Preloading: ${song.artist} – ${song.title}`);
 822 +	fetchAndPreloadSong(song, filename);
 823 +}
 824  
 825 +function fetchAndPreloadSong(song, filename) {
 826  	// Use fetch to force full download of the entire file
 827  	fetch(`tracks/${filename}`)
 828  		.then(response => {

 883  
 884  			console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`);
 885  
     -			// Move to next song
     -			currentPreloadIndex++;
     -			setTimeout(() => preloadNextSong(), 100);
 886 +			// Store in Cache API for offline access
 887 +			return storeBlobInCache(filename, blob).then(() => {
 888 +				// Mark as cached and update UI
 889 +				cachedTracks.add(filename);
 890 +				updateTrackCachedStatus(filename);
 891 +
 892 +				// Move to next song
 893 +				currentPreloadIndex++;
 894 +				setTimeout(() => preloadNextSong(), 100);
 895 +			});
 896  		})
 897  		.catch(error => {
 898  			console.error(`Failed to preload ${filename}:`, error);

 901  		});
 902  }
 903  
 904 +// Check which tracks are already cached on app load
 905 +async function checkCachedTracks() {
 906 +	try {
 907 +		const cache = await caches.open('vibe-capsule');
 908 +		const cachedRequests = await cache.keys();
 909 +
 910 +		// Check each song to see if it's cached
 911 +		for (const song of songs) {
 912 +			const trackUrl = `tracks/${song.filename}`;
 913 +			const isInCache = cachedRequests.some(request => request.url.endsWith(trackUrl));
 914 +			if (isInCache) {
 915 +				cachedTracks.add(song.filename);
 916 +			}
 917 +		}
 918 +
 919 +		console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`);
 920 +		console.log('Cached tracks:', Array.from(cachedTracks));
 921 +	} catch (error) {
 922 +		console.error('Failed to check cached tracks:', error);
 923 +	}
 924 +}
 925 +
 926 +// Debug function to check preloaded state
 927 +window.debugAudioState = function() {
 928 +	console.log('=== Audio State Debug ===');
 929 +	console.log('Player ready:', playerReady);
 930 +	console.log('Is playing:', isPlaying);
 931 +	console.log('Current song index:', currentSongIndex);
 932 +	console.log('Total songs:', songs.length);
 933 +	console.log('Cached tracks count:', cachedTracks.size);
 934 +	console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
 935 +	console.log('Current audio src:', audio.src);
 936 +	console.log('Audio paused:', audio.paused);
 937 +	console.log('Audio error:', audio.error);
 938 +	if (songs[currentSongIndex]) {
 939 +		console.log('Current song:', songs[currentSongIndex].filename);
 940 +		console.log('Is preloaded:', !!preloadedAudio[songs[currentSongIndex].filename]);
 941 +		console.log('Is cached:', cachedTracks.has(songs[currentSongIndex].filename));
 942 +	}
 943 +	console.log('======================');
 944 +};
 945 +
 946 +// Load a track from cache into memory
 947 +async function loadFromCache(filename) {
 948 +	try {
 949 +		const cache = await caches.open('vibe-capsule');
 950 +		// Try both relative and absolute URLs
 951 +		let response = await cache.match(`tracks/${filename}`);
 952 +		if (!response) {
 953 +			// Try with absolute URL
 954 +			const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
 955 +			response = await cache.match(absoluteUrl);
 956 +		}
 957 +
 958 +		if (!response) {
 959 +			throw new Error('Not in cache');
 960 +		}
 961 +
 962 +		const blob = await response.blob();
 963 +
 964 +		// Add blob size to total
 965 +		totalBytesLoaded += blob.size;
 966 +
 967 +		// Create a blob URL that will persist in memory
 968 +		const blobUrl = URL.createObjectURL(blob);
 969 +
 970 +		// Create audio element with the cached blob
 971 +		const preloadAudio = new Audio();
 972 +		preloadAudio.preload = 'auto';
 973 +		preloadAudio.src = blobUrl;
 974 +
 975 +		// Wait for the audio element to fully buffer the blob
 976 +		return new Promise((resolve) => {
 977 +			const checkBuffered = () => {
 978 +				// Check if the entire duration is buffered
 979 +				if (preloadAudio.duration > 0 && preloadAudio.buffered.length > 0) {
 980 +					const bufferedEnd = preloadAudio.buffered.end(preloadAudio.buffered.length - 1);
 981 +					if (bufferedEnd >= preloadAudio.duration - 0.1) {
 982 +						// Fully buffered!
 983 +						preloadedAudio[filename] = {
 984 +							audio: preloadAudio,
 985 +							blobUrl: blobUrl,
 986 +							blob: blob
 987 +						};
 988 +						console.log(`✓ Loaded from cache: ${filename}`);
 989 +						resolve();
 990 +						return;
 991 +					}
 992 +				}
 993 +				// Not fully buffered yet, check again soon
 994 +				setTimeout(checkBuffered, 100);
 995 +			};
 996 +
 997 +			// Start checking once metadata is loaded
 998 +			preloadAudio.addEventListener('loadedmetadata', () => {
 999 +				checkBuffered();
1000 +			}, { once: true });
1001 +
1002 +			// Trigger the loading
1003 +			preloadAudio.load();
1004 +		});
1005 +	} catch (error) {
1006 +		console.error(`Failed to load from cache: ${filename}`, error);
1007 +		throw error;
1008 +	}
1009 +}
1010 +
1011 +// Store blob in Cache API for offline access
1012 +async function storeBlobInCache(filename, blob) {
1013 +	try {
1014 +		const cache = await caches.open('vibe-capsule');
1015 +		const response = new Response(blob, {
1016 +			headers: {
1017 +				'Content-Type': 'audio/mpeg',
1018 +				'Content-Length': blob.size
1019 +			}
1020 +		});
1021 +		// Use absolute URL for consistency
1022 +		const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
1023 +		await cache.put(absoluteUrl, response);
1024 +		console.log(`✓ Cached for offline: ${filename}`);
1025 +	} catch (error) {
1026 +		console.error(`Failed to cache ${filename}:`, error);
1027 +	}
1028 +}
1029 +
1030 +// Update UI to show track is cached
1031 +function updateTrackCachedStatus(filename) {
1032 +	const songIndex = songs.findIndex(s => s.filename === filename);
1033 +	if (songIndex === -1) return;
1034 +
1035 +	// Find the playlist item and remove uncached class
1036 +	const playlistItems = playlist.querySelectorAll('.playlist-item');
1037 +	if (playlistItems[songIndex]) {
1038 +		const contentDiv = playlistItems[songIndex].querySelector('.playlist-item-content');
1039 +		if (contentDiv) {
1040 +			contentDiv.classList.remove('uncached');
1041 +		}
1042 +	}
1043 +}
1044 +
1045  // Priority preloading system
1046  function requestPriorityPreload(filename) {
1047  	// Skip if already preloaded or already in priority queue

1080  		return;
1081  	}
1082  
1083 +	// If already cached, load from cache
1084 +	if (cachedTracks.has(filename)) {
1085 +		console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`);
1086 +		loadFromCache(filename).then(() => {
1087 +			processPriorityPreload();
1088 +		}).catch(err => {
1089 +			console.error(`Failed to load from cache, fetching instead:`, err);
1090 +			priorityFetchAndPreloadSong(song, filename);
1091 +		});
1092 +		return;
1093 +	}
1094 +
1095  	console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`);
1096 +	priorityFetchAndPreloadSong(song, filename);
1097 +}
1098  
1099 +function priorityFetchAndPreloadSong(song, filename) {
1100  	// Use fetch to force full download of the entire file
1101  	fetch(`tracks/${filename}`)
1102  		.then(response => {

1163  				}
1164  			}
1165  
     -			// Process next priority request
     -			processPriorityPreload();
1166 +			// Store in Cache API for offline access
1167 +			return storeBlobInCache(filename, blob).then(() => {
1168 +				// Mark as cached and update UI
1169 +				cachedTracks.add(filename);
1170 +				updateTrackCachedStatus(filename);
1171 +
1172 +				// Process next priority request
1173 +				processPriorityPreload();
1174 +			});
1175  		})
1176  		.catch(error => {
1177  			// Ignore abort errors (happens when normal preload finishes first)
styles.css
145  	display: flex;
146  	flex-direction: column;
147  	gap: 2px;
148 +	transition: opacity 0.3s ease;
149 +}
150 +
151 +.playlist-item-content.uncached {
152 +	opacity: 0.4;
153  }
154  
155  .playlist-item-title {