When auto-advancing through a queue or playlist — or stepping with next/prev — the next video previously jumped to its saved resume timestamp. A freshly selected track now starts at the beginning, or at the A marker when an A-B loop is set for it. Resuming still applies when you reopen a single video directly. A-B markers set while playing from a playlist are now stored on that playlist's own copy of the video (entry.ab), so each playlist keeps its own loop and the markers sync to the database alongside the playlist. Non-playlist playback keeps using the global per-video marker map. Bumps service worker to v1.0.3 to bust the client cache.
137 lines
4.9 KiB
JavaScript
137 lines
4.9 KiB
JavaScript
/* ============================================================================
|
|
* 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.3'; // ← 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;
|
|
}
|