- Move native shells (Zig/src, Tauri/src-tauri, app.zon, releases) to legacy/
- Add Bun + Hono server with yt-dlp proxy endpoints (search, channel, streams,
download), libsql (concurrent SQLite fork) for fingerprint-keyed playlist/
history sync, and static file serving for the frontend
- Add Dockerfile + docker-compose.yml (single container, volume-mounted DB)
- Add frontend/sw.js: app-shell cache-first, /api/* network-only,
thumbnails stale-while-revalidate, SW_UPDATE_AVAILABLE broadcast,
SKIP_WAITING message handler for seamless auto-update
- Add frontend/manifest.webmanifest: standalone PWA, vermilion theme,
search/history shortcuts
- Add frontend/icons/icon-{192,512}.png: generated PWA icons
- Add frontend/fingerprint.js: canvas+UA djb2 fingerprint, localStorage-cached,
exposes window.getFingerprint() for server-side playlist keying
- Add frontend/opfs.js: full OPFS video store (writeFromResponse streams
directly without full-file buffering), exposes window.OPFS
- Add scripts/make-pwa-icons.js: regenerate icons without external deps
- Patch frontend/app.js: WEB mode detection, webFetch + opfs* bridge wrappers,
API object routes to WEB helpers when no native bridge present,
Player.loadVideo handles OPFS blob URLs + revokes them on next load,
SW registration + update banner in boot()
- Patch frontend/index.html: manifest link, theme-color, Apple PWA meta,
CSP blob:/worker-src, fingerprint.js + opfs.js script tags
- Patch frontend/styles.css: .toast-update + .toast-reload-btn for update banner
- Native Tauri/Zig builds unchanged — all new code is additive via WEB flag
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.0'; // ← 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;
|
|
}
|