From b04c055773eae2615fcc4e07f416ad3e509c5d49 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 3 Oct 2026 22:31:43 -0700 Subject: Media grid: tiles from 360px square copies kept on disk, loaded at once A fast flick down the Images grid was a blank screen: every tile was a lazy of the original (or the 1100px column copy) to fetch and decode. Tile copies are made once (from the column copy when there is one), kept in the Cache API and painted eagerly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bvzKaZosXGHkigcGcSzAR --- src/features/messages.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/src/features/messages.js b/src/features/messages.js index 8744b5c..dc311d8 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -9641,7 +9641,8 @@ export function messagesFeature(ctx) { if (rec.end && rec.endSure && Date.now() - rec.end < 86_400_000) r.end = r.end || rec.end; } // kept frames first, so the first paint of the grid is pictures - await warmVideoThumbs([...r.videos.values()]); + await Promise.all([warmVideoThumbs([...r.videos.values()]), + warmTileThumbs([...r.images.values()].flatMap((e) => noteMediaUrls(e.content)))]); r.loaded = true; if (mediaMode !== 'all') scheduleRepaint(); }); @@ -9748,6 +9749,75 @@ export function messagesFeature(ctx) { } catch { resolve(false); } }); } + // Grid tiles from tile-sized copies: a square of the picture's middle, + // 360px (a 120-180px tile at 2-3x), made once and kept in the Cache API. + // A flick down the grid used to be a blank screen: every tile was a lazy + // of the original (or the 1100px column copy) to fetch and decode, + // and the browser's lazy margin is a screen or two. These are local and a + // few KB, so every tile loads at once and decodes in no time. + const TILE_CACHE = 'coinos-tile-thumbs-v1', TILE_PX = 360, TILE_MAX = 1200; + const tileThumbs = new Map(); // url -> object URL + const tileKey = (url) => 'https://tile-thumb.invalid/?u=' + encodeURIComponent(url); + const tileQueue = [], tileTried = new Set(), tileLooked = new Set(); + let tileBusy = 0, tileRepaint = null; + const tileStore = () => caches.open(TILE_CACHE); + async function warmTileThumbs(urls) { + if (typeof caches === 'undefined') return; + let found = 0; + try { + const store = await tileStore(); + await Promise.all(urls.map(async (url) => { + if (tileThumbs.has(url)) return; + const res = await store.match(tileKey(url)); + if (res) { tileThumbs.set(url, URL.createObjectURL(await res.blob())); found++; } + })); + } catch {} + if (found && mediaMode === 'images') scheduleRepaint(); + } + async function makeTileThumb(url) { + if (typeof createImageBitmap === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof caches === 'undefined') return; + // the column copy is already here and smaller than the original + const kept = mediaReady.get(url)?.thumb; + if (!kept && noCorsHost(url)) return; + let blob; + try { + const res = kept ? await fetch(kept) : await corsFetch(url, AbortSignal.timeout(READY_MS)); + if (!res.ok) return; + blob = await res.blob(); + } catch { return; } + if (/gif/i.test(blob.type)) return; // animated: the tile shows the original + let bmp; + try { bmp = await createImageBitmap(blob); } catch { return; } + try { + const side = Math.min(bmp.width, bmp.height); + const px = Math.min(TILE_PX, side); + const sq = await createImageBitmap(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, + { resizeWidth: px, resizeHeight: px, resizeQuality: 'high' }); + const c = new OffscreenCanvas(px, px); + c.getContext('2d').drawImage(sq, 0, 0); + try { sq.close(); } catch {} + let out = await c.convertToBlob({ type: 'image/webp', quality: 0.8 }); + if (!/webp/.test(out.type)) out = await c.convertToBlob({ type: 'image/jpeg', quality: 0.8 }); + tileThumbs.set(url, URL.createObjectURL(out)); + const store = await tileStore(); + await store.put(tileKey(url), new Response(out, { headers: { 'content-type': out.type } })); + const keys = await store.keys(); + if (keys.length > TILE_MAX) for (const k of keys.slice(0, keys.length - TILE_MAX + 100)) store.delete(k); + } catch {} finally { try { bmp.close(); } catch {} } + } + // newest first, a few at a time; the grid repaints as a batch lands + function queueTileThumbs(urls) { + for (const url of urls) if (!tileThumbs.has(url) && !tileTried.has(url)) { tileTried.add(url); tileQueue.push(url); } + while (tileBusy < 4 && tileQueue.length) { + const url = tileQueue.shift(); + tileBusy++; + makeTileThumb(url).catch(() => {}).finally(() => { + tileBusy--; + if (tileThumbs.has(url) && !tileRepaint) tileRepaint = setTimeout(() => { tileRepaint = null; if (mediaMode === 'images') scheduleRepaint(); }, 400); + queueTileThumbs([]); + }); + } + } // A clip's first frame without a player on the clip itself: the opening // of the file (its index and about a megabyte after) is fetched as bytes, // past the browser's cache, and decoded from a blob — whose pixels are @@ -9872,6 +9942,11 @@ export function messagesFeature(ctx) { : h('div', { class: 'small faint', 'data-key': 'media-empty', style: 'text-align:center;padding:12px 0' }, t(mediaMode === 'videos' ? 'mediaNoVideos' : 'mediaNoImages')); } + if (mediaMode === 'images') { + const missing = items.filter((it) => !tileThumbs.has(it.url) && !tileTried.has(it.url) && !tileLooked.has(it.url)).map((it) => it.url); + for (const u of missing) tileLooked.add(u); + if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); + } return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); @@ -9881,7 +9956,8 @@ export function messagesFeature(ctx) { 'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k), }, mediaMode === 'images' || poster - ? h('img', { src: poster || mediaSrc(it.url), loading: 'lazy', alt: '', draggable: 'false', + ? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false', + loading: poster || tileThumbs.has(it.url) ? 'eager' : 'lazy', decoding: 'async', onError: (e) => { const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken'); } }) // no still to show: the clip's first frame, fetched once the tile is near : h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }), -- cgit v1.2.3