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(); }