/* eslint-disable no-restricted-globals */ const CACHE_NAME = 'soundwave-v1'; const API_CACHE_NAME = 'soundwave-api-v1'; const AUDIO_CACHE_NAME = 'soundwave-audio-v1'; const IMAGE_CACHE_NAME = 'soundwave-images-v1'; // Assets to cache on install const STATIC_ASSETS = [ '/', '/index.html', '/manifest.json', '/favicon.ico', ]; // Install event - cache static assets self.addEventListener('install', (event) => { console.log('[Service Worker] Installing...'); event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log('[Service Worker] Caching static assets'); return cache.addAll(STATIC_ASSETS).catch((err) => { console.error('[Service Worker] Failed to cache static assets:', err); }); }).then(() => { console.log('[Service Worker] Installed'); return self.skipWaiting(); // Activate immediately }) ); }); // Activate event - clean up old caches self.addEventListener('activate', (event) => { console.log('[Service Worker] Activating...'); event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if ( cacheName !== CACHE_NAME && cacheName !== API_CACHE_NAME && cacheName !== AUDIO_CACHE_NAME && cacheName !== IMAGE_CACHE_NAME ) { console.log('[Service Worker] Deleting old cache:', cacheName); return caches.delete(cacheName); } }) ); }).then(() => { console.log('[Service Worker] Activated'); return self.clients.claim(); // Take control immediately }) ); }); // Fetch event - implement caching strategies self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip chrome extensions and non-http(s) requests if (!url.protocol.startsWith('http')) { return; } // API requests - Network first, fallback to cache if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirstStrategy(request, API_CACHE_NAME)); return; } // Audio files - Cache first, fallback to network if ( url.pathname.includes('/audio/') || url.pathname.includes('/media/local_audio/') || request.destination === 'audio' ) { event.respondWith(cacheFirstStrategy(request, AUDIO_CACHE_NAME)); return; } // Images - Cache first, fallback to network if ( url.pathname.includes('/img/') || url.pathname.includes('/media/') || url.pathname.includes('thumbnail') || url.pathname.includes('cover') || request.destination === 'image' ) { event.respondWith(cacheFirstStrategy(request, IMAGE_CACHE_NAME)); return; } // Static assets (JS, CSS) - Stale while revalidate if ( request.destination === 'script' || request.destination === 'style' || url.pathname.endsWith('.js') || url.pathname.endsWith('.css') ) { event.respondWith(staleWhileRevalidateStrategy(request, CACHE_NAME)); return; } // HTML pages - Network first, fallback to cache if (request.mode === 'navigate' || request.destination === 'document') { event.respondWith(networkFirstStrategy(request, CACHE_NAME)); return; } // Default - Network first event.respondWith(networkFirstStrategy(request, CACHE_NAME)); }); // Network first strategy - try network, fallback to cache async function networkFirstStrategy(request, cacheName) { try { const networkResponse = await fetch(request); // Cache successful responses if (networkResponse && networkResponse.status === 200) { const cache = await caches.open(cacheName); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { console.log('[Service Worker] Network request failed, trying cache:', request.url); const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // Return offline page for navigation requests if (request.mode === 'navigate') { const cache = await caches.open(CACHE_NAME); const offlinePage = await cache.match('/index.html'); if (offlinePage) { return offlinePage; } } throw error; } } // Cache first strategy - try cache, fallback to network async function cacheFirstStrategy(request, cacheName) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } try { const networkResponse = await fetch(request); // Cache successful responses if (networkResponse && networkResponse.status === 200) { const cache = await caches.open(cacheName); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { console.error('[Service Worker] Cache and network failed:', error); throw error; } } // Stale while revalidate - return cache immediately, update in background async function staleWhileRevalidateStrategy(request, cacheName) { const cachedResponse = await caches.match(request); const fetchPromise = fetch(request).then((networkResponse) => { if (networkResponse && networkResponse.status === 200) { const cache = caches.open(cacheName); cache.then((c) => c.put(request, networkResponse.clone())); } return networkResponse; }).catch((error) => { console.log('[Service Worker] Background fetch failed:', error); }); return cachedResponse || fetchPromise; } // Background sync for offline uploads self.addEventListener('sync', (event) => { console.log('[Service Worker] Background sync:', event.tag); if (event.tag === 'sync-audio-uploads') { event.waitUntil(syncAudioUploads()); } if (event.tag === 'sync-favorites') { event.waitUntil(syncFavorites()); } }); async function syncAudioUploads() { console.log('[Service Worker] Syncing audio uploads...'); // Implementation for syncing pending uploads when back online // This would read from IndexedDB and upload pending files } async function syncFavorites() { console.log('[Service Worker] Syncing favorites...'); // Implementation for syncing favorite changes when back online } // Push notifications self.addEventListener('push', (event) => { console.log('[Service Worker] Push notification received'); const data = event.data ? event.data.json() : {}; const title = data.title || 'SoundWave'; const options = { body: data.body || 'New content available', icon: '/img/icon-192x192.png', badge: '/img/icon-72x72.png', vibrate: [200, 100, 200], data: data.url || '/', actions: [ { action: 'open', title: 'Open' }, { action: 'close', title: 'Close' }, ], }; event.waitUntil( self.registration.showNotification(title, options) ); }); // Notification click self.addEventListener('notificationclick', (event) => { console.log('[Service Worker] Notification clicked'); event.notification.close(); if (event.action === 'open' || !event.action) { const urlToOpen = event.notification.data || '/'; event.waitUntil( clients.openWindow(urlToOpen) ); } }); // Message handling for cache management self.addEventListener('message', (event) => { console.log('[Service Worker] Message received:', event.data); if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } if (event.data && event.data.type === 'CLEAR_CACHE') { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => caches.delete(cacheName)) ); }).then(() => { event.ports[0].postMessage({ success: true }); }) ); } if (event.data && event.data.type === 'CACHE_AUDIO') { const { url } = event.data; event.waitUntil( caches.open(AUDIO_CACHE_NAME).then((cache) => { return cache.add(url); }).then(() => { event.ports[0].postMessage({ success: true }); }).catch((error) => { event.ports[0].postMessage({ success: false, error: error.message }); }) ); } // Cache playlist for offline access with authentication if (event.data && event.data.type === 'CACHE_PLAYLIST') { const { playlistId, audioUrls } = event.data; event.waitUntil( (async () => { try { console.log('[Service Worker] Caching playlist:', playlistId, 'with', audioUrls.length, 'tracks'); const results = { metadata: false, audioFiles: [], failed: [] }; // Cache playlist metadata API response (includes items) try { const apiCache = await caches.open(API_CACHE_NAME); const metadataUrl = `/api/playlist/${playlistId}/?include_items=true`; await apiCache.add(metadataUrl); results.metadata = true; console.log('[Service Worker] Cached playlist metadata'); } catch (err) { console.warn('[Service Worker] Failed to cache playlist metadata:', err); } // Cache all audio files in playlist with authentication const audioCache = await caches.open(AUDIO_CACHE_NAME); for (const url of audioUrls) { try { // Create authenticated request const authRequest = new Request(url, { credentials: 'include', headers: { 'Accept': 'audio/*', } }); const response = await fetch(authRequest); if (response.ok) { // Clone and cache the response await audioCache.put(url, response.clone()); results.audioFiles.push(url); console.log('[Service Worker] Cached audio:', url); } else { results.failed.push(url); console.warn('[Service Worker] Failed to cache audio (status ' + response.status + '):', url); } } catch (err) { results.failed.push(url); console.warn('[Service Worker] Failed to cache audio:', url, err); } } console.log('[Service Worker] Playlist caching complete:', results); event.ports[0].postMessage({ success: results.audioFiles.length > 0, metadata: results.metadata, cached: results.audioFiles.length, failed: results.failed.length, details: results }); } catch (error) { console.error('[Service Worker] Playlist caching error:', error); event.ports[0].postMessage({ success: false, error: error.message }); } })() ); } // Remove cached playlist if (event.data && event.data.type === 'REMOVE_PLAYLIST_CACHE') { const { playlistId, audioUrls } = event.data; event.waitUntil( Promise.all([ // Remove playlist metadata from cache caches.open(API_CACHE_NAME).then((cache) => { return cache.delete(`/api/playlist/${playlistId}/`); }), // Remove audio files from cache (only if not used by other playlists) caches.open(AUDIO_CACHE_NAME).then((cache) => { return Promise.all( audioUrls.map(url => cache.delete(url)) ); }) ]).then(() => { event.ports[0].postMessage({ success: true }); }).catch((error) => { event.ports[0].postMessage({ success: false, error: error.message }); }) ); } }); console.log('[Service Worker] Loaded');