diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 80 |
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' }), |
