fade in tracks when cached
Hunter
Monday, November 3rd, 2025 at 12:30 AM
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 );
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)
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 {