Initial commit - SoundWave v1.0
- Full PWA support with offline capabilities - Comprehensive search across songs, playlists, and channels - Offline playlist manager with download tracking - Pre-built frontend for zero-build deployment - Docker-based deployment with docker compose - Material-UI dark theme interface - YouTube audio download and management - Multi-user authentication support
This commit is contained in:
commit
51679d1943
254 changed files with 37281 additions and 0 deletions
377
frontend/public/service-worker.js
Normal file
377
frontend/public/service-worker.js
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
/* 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');
|
||||
Loading…
Add table
Add a link
Reference in a new issue