/* ============================================================================ * sw.js — YT Player Service Worker * * Strategy: * App shell (HTML/CSS/JS) → cache-first, versioned cache * /api/* requests → network-only (never cache yt-dlp results) * /api/download/* → network-only (streamed binary, never cache) * YouTube thumbnails (i.ytimg.com) → stale-while-revalidate * Everything else → network, fallback to cache * * Auto-update flow: * 1. New SW installs alongside the old one. * 2. activate: broadcast SW_UPDATE_AVAILABLE to all clients. * 3. Client shows "Update ready" banner. * 4. User clicks → client sends { type: 'SKIP_WAITING' }. * 5. SW calls skipWaiting() → takes over → client reloads. * ========================================================================== */ const VERSION = 'v1.0.2'; // ← bump this on every deploy to bust the cache const CACHE = 'ytplayer-' + VERSION; // Files that form the installable app shell. const SHELL = [ '/', '/index.html', '/styles.css', '/async-guard.js', '/fingerprint.js', '/opfs.js', '/app.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', ]; // ---- Install: pre-cache the app shell ---- self.addEventListener('install', (e) => { e.waitUntil( caches.open(CACHE).then((cache) => cache.addAll(SHELL)) // skipWaiting() is NOT called here — we wait for the client to confirm // before activating, so the update banner can appear first. ); }); // ---- Activate: evict old caches, claim clients, notify about update ---- self.addEventListener('activate', (e) => { e.waitUntil((async () => { // Delete every cache that isn't the current version const keys = await caches.keys(); await Promise.all( keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)) ); // Claim all open clients immediately (new installs) await self.clients.claim(); // Broadcast to every open window so the app can show an update banner const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION })); })()); }); // ---- Fetch: routing logic ---- self.addEventListener('fetch', (e) => { const { request } = e; const url = new URL(request.url); // Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified if (request.method !== 'GET' && request.method !== 'HEAD') return; // API calls and binary downloads → network only, no caching if (url.pathname.startsWith('/api/')) { e.respondWith(fetch(request)); return; } // YouTube thumbnails → stale-while-revalidate (fast load, fresh in background) if (url.hostname === 'i.ytimg.com') { e.respondWith(staleWhileRevalidate(request, 'ytplayer-thumbs')); return; } // Google Fonts CSS — stale-while-revalidate so offline doesn't break type if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') { e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts')); return; } // App shell → cache-first, then network, then generic offline fallback e.respondWith(cacheFirst(request)); }); // ---- Message: handle SKIP_WAITING from the client ---- self.addEventListener('message', (e) => { if (e.data && e.data.type === 'SKIP_WAITING') { self.skipWaiting(); } }); // ============================================================================ // Fetch helpers // ============================================================================ // Cache-first: serve from cache; if missing, fetch, cache, return. async function cacheFirst(request) { const cache = await caches.open(CACHE); const cached = await cache.match(request); if (cached) return cached; try { const response = await fetch(request); // Only cache successful, non-opaque responses if (response && response.status === 200 && response.type !== 'opaque') { cache.put(request, response.clone()); } return response; } catch { // Network failed and nothing in cache — return a minimal offline page // for navigation requests; let sub-resources fail naturally. if (request.mode === 'navigate') { const nav = await cache.match('/index.html'); if (nav) return nav; } return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); } } // Stale-while-revalidate: return cached immediately, update in background. async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); // Start a background revalidation — don't await it before responding const networkFetch = fetch(request).then((r) => { if (r && r.status === 200) cache.put(request, r.clone()); return r; }).catch(() => null); return cached || networkFetch; }