diff --git a/frontend/app.js b/frontend/app.js
index b83a922..ac91b06 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -697,6 +697,44 @@ async function refreshCachedIds() {
} catch { /* shell without cache support — leave empty */ }
}
+// ---------- Offline thumbnail warm-up ----------
+// Saving a video stores only its media; its artwork lives on i.ytimg.com and
+// is only cached once something has actually requested it. Pull it through the
+// service worker (which caches thumbnail responses) so a video saved for
+// offline still has its art when the network is gone.
+function warmThumb(url) {
+ if (!WEB || !url || !/^https?:/i.test(url)) return;
+ fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {});
+}
+
+function thumbUrlFor(id, known) {
+ if (known && known.thumbnail) return known.thumbnail;
+ if (String(id).startsWith('edit_')) return ''; // edited copies have no YouTube art
+ return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
+}
+
+// One-shot backfill on launch: everything already saved offline, plus every
+// playlist video (the home screen and sidebar render those). Cached thumbs are
+// served by the SW without touching the network, so only the first run costs
+// anything. Bounded and lightly throttled so a big library can't burst.
+const THUMB_WARM_MAX = 400;
+async function warmOfflineThumbs() {
+ if (!WEB || navigator.onLine === false) return;
+ const urls = new Set();
+ for (const id of cachedIds) {
+ const u = thumbUrlFor(id, videoById(id));
+ if (u) urls.add(u);
+ }
+ for (const pl of data.playlists || []) {
+ for (const v of pl.videos || []) if (v && v.thumbnail) urls.add(v.thumbnail);
+ }
+ const list = [...urls].slice(0, THUMB_WARM_MAX);
+ for (let i = 0; i < list.length; i += 4) {
+ await Promise.all(list.slice(i, i + 4).map((u) =>
+ fetch(u, { mode: 'cors', credentials: 'omit' }).catch(() => {})));
+ }
+}
+
// Download a video into the permanent offline cache. Safe to call repeatedly.
async function preload(video, { quiet = false, mux = false } = {}) {
const id = video.id;
@@ -719,6 +757,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
const res = await API.cacheDownload(id, { mux });
if (res && res.ok && res.cached) {
cachedIds.add(id);
+ warmThumb(thumbUrlFor(id, video));
if (!quiet) toast(`Saved “${video.title}” ✓`);
} else if (!quiet) {
toast('⚠ ' + ((res && res.error) || 'Could not save video'));
@@ -802,6 +841,9 @@ async function downloadEdited(customVideo) {
const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep);
if (res && res.ok && res.cached) {
cachedIds.add(id);
+ // An edited copy has no art of its own — warm its source's thumbnail,
+ // which is what the Saved list falls back to.
+ warmThumb(thumbUrlFor(customVideo.sourceId, videoById(customVideo.sourceId)));
// Only persist the custom video once its media is actually stored, so a
// failed render never leaves a dangling entry the user can't play.
if (!(data.customVideos || []).some((v) => v.id === id)) {
@@ -4156,6 +4198,8 @@ async function boot() {
// Learn what's already cached, then top up any playlist videos that aren't.
await refreshCachedIds();
data.playlists.forEach(preloadPlaylist);
+ // Not awaited — artwork backfill must never delay first paint.
+ warmOfflineThumbs();
renderSmartSidebar();
checkAutoBackup();
diff --git a/frontend/sw.js b/frontend/sw.js
index 153a5f6..1d1c0cd 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -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 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 =
+ '';
+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();
}