Cache video thumbnails for offline use

This commit is contained in:
Jonathan Sykes
2026-08-15 07:26:56 +00:00
parent fa9ef000b9
commit 49198ff239
2 changed files with 128 additions and 4 deletions

View File

@@ -34,6 +34,19 @@ const CACHE = 'ytplayer-' + VERSION;
// match this — they aren't versioned and must survive every activate.
const SHELL_CACHE_PREFIX = 'ytplayer-';
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
const THUMB_CACHE = 'ytplayer-thumbs';
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
// i.ytimg.com today, but the numbered mirrors and the ggpht/googleusercontent
// avatar hosts show up too — an unlisted host silently bypasses the cache and
// leaves a broken image offline, so cover them all.
const THUMB_HOSTS = new Set([
'i.ytimg.com', 'i1.ytimg.com', 'i2.ytimg.com', 'i3.ytimg.com', 'i4.ytimg.com', 'i9.ytimg.com',
'img.youtube.com', 'yt3.ggpht.com', 'yt3.googleusercontent.com', 'lh3.googleusercontent.com',
]);
// Thumbnails accumulate with every card ever scrolled past — keep it bounded.
const THUMB_CACHE_MAX = 800;
function isVersionedShellCache(key) {
return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key);
}
@@ -110,9 +123,9 @@ self.addEventListener('fetch', (e) => {
return;
}
// YouTube thumbnails → stale-while-revalidate (fast load, fresh in background)
if (url.hostname === 'i.ytimg.com') {
e.respondWith(staleWhileRevalidate(request, 'ytplayer-thumbs'));
// YouTube thumbnails → cache-first, kept across deploys
if (THUMB_HOSTS.has(url.hostname)) {
e.respondWith(thumbnail(request));
return;
}
@@ -160,6 +173,71 @@ async function cacheFirst(request) {
}
}
// Thumbnails: cache-first. A given thumbnail URL is immutable, so there is
// nothing to revalidate — cache-first also means an offline launch renders
// every card it has ever shown.
//
// The catch that made offline thumbnails disappear entirely: an <img> to
// another origin is a **no-cors** request, so `fetch(request)` resolves to an
// OPAQUE response with `status === 0`. The old `if (r.status === 200)` guard
// rejected every single one, so this cache was always empty. ytimg/ggpht all
// send `Access-Control-Allow-Origin: *`, so re-issue the request in cors mode
// and store a real, readable response; an opaque one is the last resort (it
// still renders, it just costs padded quota).
async function thumbnail(request) {
const cache = await caches.open(THUMB_CACHE);
const cached = await cache.match(request, { ignoreVary: true });
if (cached) return cached;
try {
const cors = await fetch(request.url, { mode: 'cors', credentials: 'omit' });
if (cors && cors.ok) {
await cache.put(request, cors.clone()).catch(() => {});
trimThumbCache();
return cors;
}
} catch { /* CORS refused or offline — fall through to the raw request */ }
try {
const raw = await fetch(request);
if (raw && (raw.status === 200 || raw.status === 0)) {
await cache.put(request, raw.clone()).catch(() => {});
trimThumbCache();
}
return raw;
} catch {
// Offline and never cached — a neutral tile beats a broken-image glyph.
return placeholderThumb();
}
}
// Cache.keys() is insertion-ordered, so the front of the list is the oldest.
let _trimmingThumbs = false;
async function trimThumbCache() {
if (_trimmingThumbs) return;
_trimmingThumbs = true;
try {
const cache = await caches.open(THUMB_CACHE);
const keys = await cache.keys();
if (keys.length > THUMB_CACHE_MAX) {
const excess = keys.slice(0, keys.length - THUMB_CACHE_MAX);
await Promise.all(excess.map((k) => cache.delete(k)));
}
} catch { /* trimming is best-effort */ }
finally { _trimmingThumbs = false; }
}
const PLACEHOLDER_THUMB =
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">' +
'<rect width="320" height="180" fill="#1d1d24"/>' +
'<path d="M134 66l56 24-56 24z" fill="#3c3c48"/></svg>';
function placeholderThumb() {
return new Response(PLACEHOLDER_THUMB, {
status: 200,
headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' },
});
}
// Stale-while-revalidate: return cached immediately, update in background.
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
@@ -170,5 +248,7 @@ async function staleWhileRevalidate(request, cacheName) {
return r;
}).catch(() => null);
return cached || networkFetch;
// networkFetch resolves to null when offline; respondWith(null) throws, so
// surface a real network error instead.
return cached || (await networkFetch) || Response.error();
}