commit 621e762baeac78a1e4ad4873317b70ab202a1573
parent de9ac74004f68318c682f0d87361fe32e96d84aa
Author: Hunter
Date: Mon, 3 Nov 2025 00:30:29 -0500
fade in tracks when cached
Diffstat:
| M | generate_manifests.py | | | 68 | ++++++++++++++++++++++++++++++++++++++++++++++++++------------------ |
| M | script.js | | | 277 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- |
| M | styles.css | | | 5 | +++++ |
3 files changed, 317 insertions(+), 33 deletions(-)
diff --git a/generate_manifests.py b/generate_manifests.py
@@ -71,10 +71,10 @@ def generate_pwa_manifests():
print("✓ Generated resource-manifest.json")
# Generate service-worker.js
- all_files = resource_manifest["static_files"] + resource_manifest["tracks"]
+ static_files = resource_manifest["static_files"]
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)};
+const CACHE_NAME = 'vibe-capsule';
+const staticFilesToCache = {json.dumps(static_files, indent=2)};
// Get the base path from the service worker location
const getBasePath = () => {{
@@ -84,7 +84,8 @@ const getBasePath = () => {{
const basePath = getBasePath();
-// Install event - cache all resources
+// Install event - cache only static resources (not MP3s)
+// MP3s will be cached by the main app's blob preloading system
self.addEventListener('install', (event) => {{
console.log('Service Worker installing...', 'Base path:', basePath);
event.waitUntil(
@@ -92,24 +93,41 @@ self.addEventListener('install', (event) => {{
.then((cache) => {{
console.log('Opened cache');
// Make URLs absolute relative to service worker location
- const absoluteUrls = urlsToCache.map(url => {{
+ const absoluteUrls = staticFilesToCache.map(url => {{
if (url === './') return basePath;
return new URL(url, basePath + 'index.html').href;
}});
- console.log('Caching', absoluteUrls.length, 'resources');
+ console.log('Caching', absoluteUrls.length, 'static resources');
console.log('URLs to cache:', absoluteUrls);
- // Cache files one by one with better error handling
- return Promise.all(
+ // Cache files individually with better error handling
+ // Using Promise.allSettled to continue even if some fail
+ return Promise.allSettled(
absoluteUrls.map(url =>
- cache.add(url)
+ fetch(url)
+ .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))
+ .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('All resources cached successfully');
+ console.log('Service Worker installation complete');
return self.skipWaiting();
}})
.catch(error => {{
@@ -137,36 +155,50 @@ self.addEventListener('activate', (event) => {{
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {{
+ // Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
+ if (!event.request.url.startsWith('http')) {{
+ return;
+ }}
+
event.respondWith(
caches.match(event.request)
.then((response) => {{
// Cache hit - return response
if (response) {{
- console.log('Serving from cache:', event.request.url);
+ console.log('✓ Serving from cache:', event.request.url);
return response;
}}
// Cache miss - try network
- console.log('Fetching from network:', event.request.url);
+ console.log('⟳ Fetching from network:', event.request.url);
return fetch(event.request).then((response) => {{
// Check if valid response
- if (!response || response.status !== 200) {{
+ if (!response || response.status !== 200 || response.type === 'error') {{
return response;
}}
// Clone the response for caching
const responseToCache = response.clone();
+ // Cache the fetched resource
caches.open(CACHE_NAME)
.then((cache) => {{
cache.put(event.request, responseToCache);
- }});
+ console.log('✓ Cached from network:', event.request.url);
+ }})
+ .catch(err => console.error('Failed to cache:', err));
return response;
}}).catch((error) => {{
- console.error('Fetch failed; returning offline page if available:', error);
+ console.error('✗ Fetch failed for:', event.request.url, error);
// If fetch fails, try to return from cache one more time
- return caches.match(event.request);
+ return caches.match(event.request).then(cachedResponse => {{
+ if (cachedResponse) {{
+ console.log('✓ Serving from cache (after fetch failed):', event.request.url);
+ return cachedResponse;
+ }}
+ throw error;
+ }});
}});
}})
);
diff --git a/script.js b/script.js
@@ -44,6 +44,7 @@ let currentPreloadIndex = 0;
let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
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
// Load tracks from tracks.json
fetch('tracks.json')
@@ -58,10 +59,13 @@ fetch('tracks.json')
if (songs.length > 0) {
playerReady = true;
updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`);
- renderPlaylist();
- // Pre-cache resources first, then songs
- preloadResources().then(() => {
- startPreloadingSongs();
+ // Check which tracks are already cached before rendering
+ return checkCachedTracks().then(() => {
+ renderPlaylist();
+ // Pre-cache resources first, then songs
+ return preloadResources().then(() => {
+ startPreloadingSongs();
+ });
});
} else {
updateCurrentSongDisplay('No tracks found');
@@ -190,6 +194,12 @@ function renderPlaylist() {
}
artistDiv.textContent = song.artist;
+ // Set cached status for visual indication
+ const isCached = cachedTracks.has(song.filename);
+ if (!isCached) {
+ contentDiv.classList.add('uncached');
+ }
+
contentDiv.appendChild(titleDiv);
contentDiv.appendChild(artistDiv);
@@ -208,7 +218,9 @@ function toggleLooping(index) {
if (!playerReady) return;
if (index === currentSongIndex) {
if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
- audio.play();
+ audio.play().catch(err => {
+ console.error('Failed to play audio:', err);
+ });
isPlaying = true;
updatePlayPauseButton();
return;
@@ -222,7 +234,11 @@ function toggleLooping(index) {
}
function playSong(index) {
- if (!playerReady) return;
+ console.log(`playSong called with index: ${index}`);
+ if (!playerReady) {
+ console.log('Player not ready');
+ return;
+ }
// 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);
@@ -232,17 +248,57 @@ function playSong(index) {
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]}`);
// Use preloaded blob if available, otherwise load from server
if (preloadedAudio[song.filename]) {
- audio.src = preloadedAudio[song.filename].blobUrl;
+ const blobUrl = preloadedAudio[song.filename].blobUrl;
+ console.log(`Playing from preloaded blob: ${song.filename}`);
+ console.log(`Blob URL: ${blobUrl}`);
+ audio.src = blobUrl;
} else {
+ console.log(`Song not preloaded, loading: ${song.filename}`);
audio.src = `tracks/${song.filename}`;
// Request priority preloading for this song
requestPriorityPreload(song.filename);
}
- audio.play();
+ console.log(`Audio src set to: ${audio.src}`);
+
+ // For iOS PWA: We need to call load() and play() synchronously
+ // Reset any previous state first
+ try {
+ audio.pause();
+ audio.currentTime = 0;
+ } catch (e) {
+ // Ignore errors from resetting
+ }
+
+ // Load the audio to ensure it's ready (important for iOS PWA)
+ audio.load();
+
+ // Small delay to let load() initialize, then play
+ // This needs to be synchronous enough that iOS considers it part of the user gesture
+ const playAttempt = audio.play();
+
+ if (playAttempt !== undefined) {
+ playAttempt.then(() => {
+ console.log('Audio playback started successfully');
+ isPlaying = true;
+ updatePlayPauseButton();
+ }).catch(err => {
+ console.error('Failed to play audio:', err);
+ console.error('Error name:', err.name);
+ console.error('Error message:', err.message);
+ isPlaying = false;
+ updatePlayPauseButton();
+ updateCurrentSongDisplay(`Error playing: ${song.title}`);
+ });
+ }
+
+ // Optimistically set playing state
isPlaying = true;
updatePlayPauseButton();
renderPlaylist();
@@ -432,7 +488,11 @@ function togglePlayPause() {
if (!audio.src || audio.src === '') {
playSong(currentSongIndex);
} else {
- audio.play();
+ audio.play().catch(err => {
+ console.error('Failed to play audio:', err);
+ const song = songs[currentSongIndex];
+ updateCurrentSongDisplay(`Error playing: ${song.title}`);
+ });
isPlaying = true;
}
}
@@ -737,15 +797,32 @@ function preloadNextSong() {
const song = songs[currentPreloadIndex];
const filename = song.filename;
- // Skip if already preloaded
+ // Skip if already preloaded in memory
if (preloadedAudio[filename]) {
currentPreloadIndex++;
preloadNextSong();
return;
}
+ // If already cached, load from cache into memory
+ if (cachedTracks.has(filename)) {
+ console.log(`Loading from cache: ${song.artist} – ${song.title}`);
+ loadFromCache(filename).then(() => {
+ currentPreloadIndex++;
+ setTimeout(() => preloadNextSong(), 100);
+ }).catch(err => {
+ console.error(`Failed to load from cache, fetching instead:`, err);
+ // If cache load fails, fetch from network
+ fetchAndPreloadSong(song, filename);
+ });
+ return;
+ }
+
console.log(`Preloading: ${song.artist} – ${song.title}`);
+ fetchAndPreloadSong(song, filename);
+}
+function fetchAndPreloadSong(song, filename) {
// Use fetch to force full download of the entire file
fetch(`tracks/${filename}`)
.then(response => {
@@ -806,9 +883,16 @@ function preloadNextSong() {
console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`);
- // Move to next song
- currentPreloadIndex++;
- setTimeout(() => preloadNextSong(), 100);
+ // Store in Cache API for offline access
+ return storeBlobInCache(filename, blob).then(() => {
+ // Mark as cached and update UI
+ cachedTracks.add(filename);
+ updateTrackCachedStatus(filename);
+
+ // Move to next song
+ currentPreloadIndex++;
+ setTimeout(() => preloadNextSong(), 100);
+ });
})
.catch(error => {
console.error(`Failed to preload ${filename}:`, error);
@@ -817,6 +901,147 @@ function preloadNextSong() {
});
}
+// Check which tracks are already cached on app load
+async function checkCachedTracks() {
+ try {
+ const cache = await caches.open('vibe-capsule');
+ const cachedRequests = await cache.keys();
+
+ // Check each song to see if it's cached
+ for (const song of songs) {
+ const trackUrl = `tracks/${song.filename}`;
+ const isInCache = cachedRequests.some(request => request.url.endsWith(trackUrl));
+ if (isInCache) {
+ cachedTracks.add(song.filename);
+ }
+ }
+
+ console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`);
+ console.log('Cached tracks:', Array.from(cachedTracks));
+ } catch (error) {
+ console.error('Failed to check cached tracks:', error);
+ }
+}
+
+// Debug function to check preloaded state
+window.debugAudioState = function() {
+ console.log('=== Audio State Debug ===');
+ console.log('Player ready:', playerReady);
+ console.log('Is playing:', isPlaying);
+ console.log('Current song index:', currentSongIndex);
+ console.log('Total songs:', songs.length);
+ console.log('Cached tracks count:', cachedTracks.size);
+ console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
+ console.log('Current audio src:', audio.src);
+ console.log('Audio paused:', audio.paused);
+ 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));
+ }
+ console.log('======================');
+};
+
+// Load a track from cache into memory
+async function loadFromCache(filename) {
+ try {
+ const cache = await caches.open('vibe-capsule');
+ // Try both relative and absolute URLs
+ let response = await cache.match(`tracks/${filename}`);
+ if (!response) {
+ // Try with absolute URL
+ const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
+ response = await cache.match(absoluteUrl);
+ }
+
+ if (!response) {
+ throw new Error('Not in cache');
+ }
+
+ const blob = await response.blob();
+
+ // Add blob size to total
+ totalBytesLoaded += blob.size;
+
+ // Create a blob URL that will persist in memory
+ const blobUrl = URL.createObjectURL(blob);
+
+ // Create audio element with the cached blob
+ const preloadAudio = new Audio();
+ preloadAudio.preload = 'auto';
+ preloadAudio.src = blobUrl;
+
+ // Wait for the audio element to fully buffer the blob
+ return new Promise((resolve) => {
+ const checkBuffered = () => {
+ // Check if the entire duration is buffered
+ if (preloadAudio.duration > 0 && preloadAudio.buffered.length > 0) {
+ const bufferedEnd = preloadAudio.buffered.end(preloadAudio.buffered.length - 1);
+ if (bufferedEnd >= preloadAudio.duration - 0.1) {
+ // Fully buffered!
+ preloadedAudio[filename] = {
+ audio: preloadAudio,
+ blobUrl: blobUrl,
+ blob: blob
+ };
+ console.log(`✓ Loaded from cache: ${filename}`);
+ resolve();
+ return;
+ }
+ }
+ // Not fully buffered yet, check again soon
+ setTimeout(checkBuffered, 100);
+ };
+
+ // Start checking once metadata is loaded
+ preloadAudio.addEventListener('loadedmetadata', () => {
+ checkBuffered();
+ }, { once: true });
+
+ // Trigger the loading
+ preloadAudio.load();
+ });
+ } catch (error) {
+ console.error(`Failed to load from cache: ${filename}`, error);
+ throw error;
+ }
+}
+
+// Store blob in Cache API for offline access
+async function storeBlobInCache(filename, blob) {
+ try {
+ const cache = await caches.open('vibe-capsule');
+ const response = new Response(blob, {
+ headers: {
+ 'Content-Type': 'audio/mpeg',
+ 'Content-Length': blob.size
+ }
+ });
+ // Use absolute URL for consistency
+ const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
+ await cache.put(absoluteUrl, response);
+ console.log(`✓ Cached for offline: ${filename}`);
+ } catch (error) {
+ console.error(`Failed to cache ${filename}:`, error);
+ }
+}
+
+// Update UI to show track is cached
+function updateTrackCachedStatus(filename) {
+ const songIndex = songs.findIndex(s => s.filename === filename);
+ if (songIndex === -1) return;
+
+ // Find the playlist item and remove uncached class
+ const playlistItems = playlist.querySelectorAll('.playlist-item');
+ if (playlistItems[songIndex]) {
+ const contentDiv = playlistItems[songIndex].querySelector('.playlist-item-content');
+ if (contentDiv) {
+ contentDiv.classList.remove('uncached');
+ }
+ }
+}
+
// Priority preloading system
function requestPriorityPreload(filename) {
// Skip if already preloaded or already in priority queue
@@ -855,8 +1080,23 @@ function processPriorityPreload() {
return;
}
+ // If already cached, load from cache
+ if (cachedTracks.has(filename)) {
+ console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`);
+ loadFromCache(filename).then(() => {
+ processPriorityPreload();
+ }).catch(err => {
+ console.error(`Failed to load from cache, fetching instead:`, err);
+ priorityFetchAndPreloadSong(song, filename);
+ });
+ return;
+ }
+
console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`);
+ priorityFetchAndPreloadSong(song, filename);
+}
+function priorityFetchAndPreloadSong(song, filename) {
// Use fetch to force full download of the entire file
fetch(`tracks/${filename}`)
.then(response => {
@@ -923,8 +1163,15 @@ function processPriorityPreload() {
}
}
- // Process next priority request
- processPriorityPreload();
+ // Store in Cache API for offline access
+ return storeBlobInCache(filename, blob).then(() => {
+ // Mark as cached and update UI
+ cachedTracks.add(filename);
+ updateTrackCachedStatus(filename);
+
+ // Process next priority request
+ processPriorityPreload();
+ });
})
.catch(error => {
// Ignore abort errors (happens when normal preload finishes first)
diff --git a/styles.css b/styles.css
@@ -145,6 +145,11 @@ body {
display: flex;
flex-direction: column;
gap: 2px;
+ transition: opacity 0.3s ease;
+}
+
+.playlist-item-content.uncached {
+ opacity: 0.4;
}
.playlist-item-title {