aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js31
-rw-r--r--src/image-thumbnails.js19
-rw-r--r--src/style.css2
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; }