aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js80
1 files 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
+ // <img> 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' }),