378 lines
12 KiB
JavaScript
378 lines
12 KiB
JavaScript
|
|
/* 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');
|