Spaces:
Running
Running
// 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(/<script[^>]+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(/<link[^>]+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 = ` | |
<svg viewBox="0 0 320 180" xmlns="http://www.w3.org/2000/svg"> | |
<rect width="320" height="180" fill="#4B5563"/> | |
<g transform="translate(160, 90)"> | |
<circle r="24" fill="#6366F1" opacity="0.2"/> | |
<path d="M-8 -6 L8 0 L-8 6 Z" fill="#6366F1"/> | |
</g> | |
<text x="160" y="135" text-anchor="middle" fill="#D1D5DB" font-family="sans-serif" font-size="12" font-weight="500"> | |
VELIN | |
</text> | |
</svg> | |
`; | |
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(` | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>VELIN - Offline</title> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<style> | |
body { | |
font-family: system-ui, sans-serif; | |
text-align: center; | |
padding: 2rem; | |
background: #121212; | |
color: #ffffff; | |
margin: 0; | |
} | |
.container { max-width: 400px; margin: 0 auto; padding-top: 20vh; } | |
.icon { font-size: 4rem; margin-bottom: 1rem; } | |
h1 { color: #6366f1; margin-bottom: 1rem; } | |
p { color: #9ca3af; line-height: 1.5; } | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="icon">📱</div> | |
<h1>VELIN</h1> | |
<p>You're offline and the app isn't cached yet.<br>Please connect to the internet and reload the page.</p> | |
</div> | |
</body> | |
</html> | |
`, { | |
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; | |
} | |
} |