diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 31 | ||||
| -rw-r--r-- | src/image-thumbnails.js | 19 | ||||
| -rw-r--r-- | src/style.css | 2 |
3 files changed, 37 insertions, 15 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 34bc815..c8b5683 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -13723,12 +13723,17 @@ export function messagesFeature(ctx) { // <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 TILE_CACHE = 'coinos-tile-thumbs-v2', 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); + let tileOldGone = false; + const tileStore = () => { + // the square tiles of before (v1) go: the grid's rows want each shape + if (!tileOldGone) { tileOldGone = true; caches.delete('coinos-tile-thumbs-v1').catch(() => {}); } + return caches.open(TILE_CACHE); + }; async function warmTileThumbs(urls) { if (typeof caches === 'undefined') return; let found = 0; @@ -13748,7 +13753,7 @@ export function messagesFeature(ctx) { const kept = mediaReady.get(url)?.thumb; if (!kept && (noCorsHost(url) || corsRefused.has(url))) { // the host keeps its bytes: the server cuts the tile - const tile = await serverPicBlob(url, 'tile'); + const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); return; } @@ -13758,7 +13763,7 @@ export function messagesFeature(ctx) { if (!res.ok) return; blob = await res.blob(); } catch (e) { - if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); } + if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); } return; } if (/gif/i.test(blob.type)) return; // animated: the tile shows the original @@ -13931,6 +13936,17 @@ export function messagesFeature(ctx) { const posterAr = new Map(); const clampAr = (r) => Math.min(2, Math.max(0.5, r)); const clipRatio = (url) => { const z = clipSize(url); return z ? clampAr(z.w / z.h) : posterAr.get(url) || 1; }; + // a grid item's: the file's measured size, the post's word for it (NIP-92 + // dim), or its still once that has loaded + function itemRatio(it) { + if (clipSize(it.url)) return clipRatio(it.url); + for (const tag of it.ev.tags || []) { + if (tag[0] !== 'imeta' || !tag.includes('url ' + it.url)) continue; + const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(imetaFields(tag).dim || ''); + if (m && +m[1] && +m[2]) return clampAr(+m[1] / +m[2]); + } + return posterAr.get(it.url) || 1; + } function arFromStill(img, url) { if (clipSize(url) || !img.naturalWidth || !img.naturalHeight) return; const r = clampAr(img.naturalWidth / img.naturalHeight); @@ -14226,7 +14242,8 @@ export function messagesFeature(ctx) { // rows, each tile its clip's own proportions and each row as tall as it // takes to fill the width; tall ones only (a phone held upright) keep // the tall tiles three across. - const shape = mediaMode !== 'videos' ? '' : clipsAll() ? ' justified' : screenTall() ? '' : ' wide'; + // Pictures go in justified rows too, in their own shapes. + const shape = mediaMode === 'images' ? ' justified' : mediaMode !== 'videos' ? '' : clipsAll() ? ' justified' : screenTall() ? '' : ' wide'; return h('div', { class: 'media-grid' + shape, 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); @@ -14240,14 +14257,14 @@ export function messagesFeature(ctx) { const fromServer = mediaMode === 'videos' && poster && poster === serverThumb(it.url); return h('button', { class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, - style: shape === ' justified' ? '--ar:' + clipRatio(it.url).toFixed(3) : undefined, + style: shape === ' justified' ? '--ar:' + itemRatio(it).toFixed(3) : undefined, 'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k), onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined, }, mediaMode === 'images' || poster ? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false', loading: poster || tileThumbs.has(it.url) ? 'eager' : 'lazy', decoding: 'async', - onLoad: mediaMode === 'videos' ? (e) => arFromStill(e.currentTarget, it.url) : undefined, + onLoad: (e) => arFromStill(e.currentTarget, it.url), onError: (e) => { if (fromServer) { serverThumbFailed.add(it.url); render(); return; } // the frame grab's turn const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken'); diff --git a/src/image-thumbnails.js b/src/image-thumbnails.js index ffac4b9..2686dac 100644 --- a/src/image-thumbnails.js +++ b/src/image-thumbnails.js @@ -14,16 +14,21 @@ function thumbnailWorker() { const result = { width: bitmap.width, height: bitmap.height, media: null, tile: null }; // GIFs must keep animating; a canvas copy would freeze their first frame. if (!/gif/i.test(blob.type)) { + // The grid's tile: the picture in its own shape (justified rows), + // tileSize tall, its middle cut to between 1:2 and 2:1 — the most a + // tile stretches either way. if (tileSize) { - const side = Math.min(bitmap.width, bitmap.height), px = Math.min(tileSize, side); - let square; + const W = bitmap.width, H = bitmap.height; + const cw = Math.min(W, H * 2), ch = Math.min(H, W * 2); + const th = Math.min(tileSize, ch), tw = Math.max(1, Math.round(th * cw / ch)); + let cut; try { - square = await createImageBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side, - { resizeWidth: px, resizeHeight: px, resizeQuality: 'high' }); - const canvas = new OffscreenCanvas(px, px); - canvas.getContext('2d').drawImage(square, 0, 0); + cut = await createImageBitmap(bitmap, (W - cw) / 2, (H - ch) / 2, cw, ch, + { resizeWidth: tw, resizeHeight: th, resizeQuality: 'high' }); + const canvas = new OffscreenCanvas(tw, th); + canvas.getContext('2d').drawImage(cut, 0, 0); result.tile = await encode(canvas, 0.8); - } catch {} finally { square?.close(); } + } catch {} finally { cut?.close(); } } if (width && bitmap.width > width * 1.25) { try { diff --git a/src/style.css b/src/style.css index 3dcbf17..61d6f01 100644 --- a/src/style.css +++ b/src/style.css @@ -1546,7 +1546,7 @@ body::after { height and fill the width; the filler takes the last row's slack instead of stretching its few tiles. */ .media-grid.justified { display: flex; flex-wrap: wrap; --row-h: 140px; } -.media-grid.justified .media-tile.video { aspect-ratio: var(--ar); flex: var(--ar) 1 calc(var(--ar) * var(--row-h)); min-width: 0; } +.media-grid.justified .media-tile { aspect-ratio: var(--ar); flex: var(--ar) 1 calc(var(--ar) * var(--row-h)); min-width: 0; } .media-grid.justified::after { content: ''; flex: 1000 1 0; } @media (max-width: 600px) { .media-grid.justified { --row-h: 100px; } } .media-tile img, .media-tile video { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } |
