// Service Worker for VELIN Podcast Player // Handles offline audio caching and playback const CACHE_NAME = 'velin-audio-cache-v2'; const STATIC_CACHE_NAME = 'velin-static-v2'; const THUMBNAIL_CACHE_NAME = 'velin-thumbnails-v2'; // Static files to cache for offline functionality const STATIC_FILES = [ '/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/icon-192.svg', '/icon-512.svg' ]; // Install event - cache static files and discover dynamic assets self.addEventListener('install', event => { console.log('VELIN Service Worker installed'); event.waitUntil( cacheStaticFiles() .then(() => { console.log('All static files cached successfully'); }) .catch(error => { console.error('Failed to cache static files:', error); }) ); self.skipWaiting(); }); // Cache static files and discover dynamic assets async function cacheStaticFiles() { try { const cache = await caches.open(STATIC_CACHE_NAME); // First, cache the main HTML page to discover dynamic assets const indexResponse = await fetch('/'); if (indexResponse.ok) { await cache.put('/', indexResponse.clone()); // Parse HTML to find JS and CSS files const htmlText = await indexResponse.text(); const dynamicAssets = extractAssetsFromHTML(htmlText); console.log('Found dynamic assets:', dynamicAssets); // Cache all discovered assets along with static files const allFilesToCache = [...STATIC_FILES.filter(file => file !== '/'), ...dynamicAssets]; // Cache each file individually to handle failures gracefully await Promise.allSettled( allFilesToCache.map(async (file) => { try { const response = await fetch(file); if (response.ok) { await cache.put(file, response); console.log('Cached:', file); } else { console.warn('Failed to fetch:', file, response.status); } } catch (error) { console.warn('Error caching:', file, error); } }) ); } } catch (error) { console.error('Error in cacheStaticFiles:', error); // Fallback: try to cache just the static files const cache = await caches.open(STATIC_CACHE_NAME); await Promise.allSettled( STATIC_FILES.map(async (file) => { try { const response = await fetch(file); if (response.ok) { await cache.put(file, response); } } catch (error) { console.warn('Fallback cache error for:', file); } }) ); } } // Extract JS and CSS files from HTML function extractAssetsFromHTML(html) { const assets = []; // Find script tags with src const scriptMatches = html.matchAll(/]+src="([^"]+)"/g); for (const match of scriptMatches) { if (match[1] && !match[1].startsWith('http') && !match[1].includes('replit')) { assets.push(match[1]); } } // Find link tags with stylesheet const linkMatches = html.matchAll(/]+href="([^"]+)"[^>]*rel="stylesheet"/g); for (const match of linkMatches) { if (match[1] && !match[1].startsWith('http')) { assets.push(match[1]); } } return assets; } // Activate event - claim all clients and clean old caches self.addEventListener('activate', event => { console.log('VELIN Service Worker activated'); event.waitUntil( Promise.all([ self.clients.claim(), caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { // Keep current caches, remove old versions if (cacheName !== CACHE_NAME && cacheName !== STATIC_CACHE_NAME && cacheName !== THUMBNAIL_CACHE_NAME) { console.log('Deleting old cache:', cacheName); return caches.delete(cacheName); } }) ); }) ]) ); }); // Fetch event - handle caching and serving self.addEventListener('fetch', event => { const url = new URL(event.request.url); // Handle audio files (.mp3, .m4a, .webm, .ogg) if (isAudioFile(url.pathname) || isAudioStream(url.href)) { event.respondWith(handleAudioRequest(event.request)); return; } // Handle API requests for audio downloads if (url.pathname.startsWith('/api/download-audio/')) { event.respondWith(handleAudioDownload(event.request)); return; } // Handle podcast stream requests if (url.hostname === 'backendmix.vercel.app' && url.pathname.startsWith('/streams/')) { event.respondWith(handleStreamRequest(event.request)); return; } // Handle thumbnail images if (isThumbnailImage(url.href)) { event.respondWith(handleThumbnailRequest(event.request)); return; } // Handle static files and navigation if (event.request.method === 'GET') { event.respondWith(handleStaticRequest(event.request)); return; } // Default handling for other requests event.respondWith(fetch(event.request)); }); // Check if URL is an audio file function isAudioFile(pathname) { const audioExtensions = ['.mp3', '.m4a', '.webm', '.ogg', '.wav', '.aac']; return audioExtensions.some(ext => pathname.endsWith(ext)); } // Check if URL is an audio stream function isAudioStream(url) { // YouTube audio streams and other streaming services return ( url.includes('googlevideo.com') || url.includes('youtube.com') || url.includes('ytimg.com') || (url.includes('mime=audio') || url.includes('type=audio')) ); } // Check if URL is a thumbnail image function isThumbnailImage(url) { return ( url.includes('ytimg.com') || url.includes('ggpht.com') || url.includes('youtube.com/vi/') || (url.includes('thumbnail') && (url.includes('.jpg') || url.includes('.webp') || url.includes('.png'))) ); } // Handle thumbnail requests async function handleThumbnailRequest(request) { try { const cache = await caches.open(THUMBNAIL_CACHE_NAME); const cachedResponse = await cache.match(request); if (cachedResponse) { console.log('Serving thumbnail from cache:', request.url); return cachedResponse; } // Fetch from network and cache const networkResponse = await fetch(request); if (networkResponse.ok) { const responseClone = networkResponse.clone(); await cache.put(request, responseClone); console.log('Thumbnail cached successfully:', request.url); return networkResponse; } return networkResponse; } catch (error) { console.error('Error handling thumbnail request:', error); // Return a fallback thumbnail when offline const fallbackSvg = ` VELIN `; return new Response(fallbackSvg, { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache' } }); } } // Handle static file requests (for offline app functionality) async function handleStaticRequest(request) { const url = new URL(request.url); try { // For navigation requests (page loads), handle specially for SPA if (request.mode === 'navigate' || (request.destination === 'document' && request.method === 'GET')) { // Try network first for navigation try { const networkResponse = await fetch(request); if (networkResponse.ok) { // Cache successful page loads const cache = await caches.open(STATIC_CACHE_NAME); const responseClone = networkResponse.clone(); await cache.put(request, responseClone); console.log('Cached page from network:', request.url); return networkResponse; } } catch (networkError) { console.log('Network failed for navigation, using cache fallback'); } // Network failed or offline - serve from cache const cache = await caches.open(STATIC_CACHE_NAME); // For SPA routing, always serve the main index.html for any route const cachedResponse = await cache.match('/'); if (cachedResponse) { console.log('Serving SPA fallback from cache for:', request.url); return cachedResponse; } // No cached page available console.error('No cached page available for offline access'); return new Response(` VELIN - Offline
📱

VELIN

You're offline and the app isn't cached yet.
Please connect to the internet and reload the page.

`, { headers: { 'Content-Type': 'text/html' }, status: 200 }); } // For static resources (JS, CSS, images, etc.) try { const networkResponse = await fetch(request); // Cache successful responses for static resources if (networkResponse.ok && ( request.destination === 'script' || request.destination === 'style' || request.destination === 'image' || url.pathname.includes('.js') || url.pathname.includes('.css') || url.pathname.includes('.png') || url.pathname.includes('.svg') || url.pathname.includes('/assets/') )) { const cache = await caches.open(STATIC_CACHE_NAME); const responseClone = networkResponse.clone(); await cache.put(request, responseClone); console.log('Cached static resource:', request.url); } return networkResponse; } catch (networkError) { console.log('Network failed for static resource, trying cache:', request.url); // Network failed, try cache as fallback const cache = await caches.open(STATIC_CACHE_NAME); const cachedResponse = await cache.match(request); if (cachedResponse) { console.log('Serving static resource from cache:', request.url); return cachedResponse; } // No cached version available throw networkError; } } catch (error) { console.error('Error handling static request:', error); return new Response('Resource not available offline', { status: 503, statusText: 'Service Unavailable' }); } } // Handle audio file requests async function handleAudioRequest(request) { try { const cache = await caches.open(CACHE_NAME); const cachedResponse = await cache.match(request); if (cachedResponse) { console.log('Serving audio from cache:', request.url); return cachedResponse; } // Not in cache, fetch from network console.log('Fetching audio from network:', request.url); const networkResponse = await fetch(request); if (networkResponse.ok) { // Cache the response for future use const responseClone = networkResponse.clone(); await cache.put(request, responseClone); console.log('Audio cached successfully:', request.url); } return networkResponse; } catch (error) { console.error('Error handling audio request:', error); // Return a fallback or error response return new Response('Audio not available offline', { status: 503, statusText: 'Service Unavailable' }); } } // Handle audio download API requests async function handleAudioDownload(request) { try { const cache = await caches.open(CACHE_NAME); const cachedResponse = await cache.match(request); if (cachedResponse) { console.log('Serving download from cache:', request.url); return cachedResponse; } // Fetch from network and cache const networkResponse = await fetch(request); if (networkResponse.ok) { const responseClone = networkResponse.clone(); await cache.put(request, responseClone); console.log('Download cached successfully:', request.url); } return networkResponse; } catch (error) { console.error('Error handling download request:', error); return new Response('Download not available offline', { status: 503, statusText: 'Service Unavailable' }); } } // Handle stream info requests async function handleStreamRequest(request) { try { const cache = await caches.open(STATIC_CACHE_NAME); const cachedResponse = await cache.match(request); // For stream info, we can cache for a short time if (cachedResponse) { const cacheDate = new Date(cachedResponse.headers.get('sw-cache-date') || 0); const now = new Date(); const maxAge = 30 * 60 * 1000; // 30 minutes if (now - cacheDate < maxAge) { console.log('Serving stream info from cache:', request.url); return cachedResponse; } } // Fetch fresh data const networkResponse = await fetch(request); if (networkResponse.ok) { const responseClone = networkResponse.clone(); const headers = new Headers(responseClone.headers); headers.set('sw-cache-date', new Date().toISOString()); const cachedResponse = new Response(responseClone.body, { status: responseClone.status, statusText: responseClone.statusText, headers: headers }); await cache.put(request, cachedResponse); } return networkResponse; } catch (error) { console.error('Error handling stream request:', error); return fetch(request); } } // Message handling for manual cache operations self.addEventListener('message', event => { if (event.data && event.data.type === 'CACHE_AUDIO') { event.waitUntil(cacheAudioFile(event.data.url)); } if (event.data && event.data.type === 'CLEAR_CACHE') { event.waitUntil(clearCache()); } if (event.data && event.data.type === 'GET_CACHE_SIZE') { event.waitUntil(getCacheSize().then(size => { event.ports[0].postMessage({ size }); })); } }); // Manually cache an audio file async function cacheAudioFile(url) { try { const cache = await caches.open(CACHE_NAME); const response = await fetch(url); if (response.ok) { await cache.put(url, response); console.log('Audio file cached manually:', url); return true; } return false; } catch (error) { console.error('Error caching audio file:', error); return false; } } // Clear all caches async function clearCache() { try { const cacheNames = await caches.keys(); await Promise.all( cacheNames.map(cacheName => caches.delete(cacheName)) ); console.log('All caches cleared'); } catch (error) { console.error('Error clearing cache:', error); } } // Get cache size async function getCacheSize() { try { const cache = await caches.open(CACHE_NAME); const requests = await cache.keys(); let totalSize = 0; for (const request of requests) { const response = await cache.match(request); if (response) { const blob = await response.blob(); totalSize += blob.size; } } return totalSize; } catch (error) { console.error('Error calculating cache size:', error); return 0; } }