aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-22 10:07:34 -0700
committerAdam Soltys <[email protected]>2026-10-22 10:07:34 -0700
commite7979230cdddde45113a7bf282f00b4192d2ab0a (patch)
tree9267ab2dc9605bdfac582e54013a469dcd9faf95
parent2c1acf85ac24f78207e0c667a7fae8dfdfa54ac0 (diff)
downloadcoinosv3-e7979230cdddde45113a7bf282f00b4192d2ab0a.tar.gz
coinosv3-e7979230cdddde45113a7bf282f00b4192d2ab0a.zip
Videos grid, "All shapes": justified rows (each tile its clip's own proportions, every row one height filling the width) instead of squares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/features/messages.js17
-rw-r--r--src/style.css10
-rw-r--r--tools/media-grid-dups-test.js12
3 files changed, 31 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 350ad7e..b209e35 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -13917,12 +13917,16 @@ export function messagesFeature(ctx) {
const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } };
const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} };
const screenTall = () => window.innerHeight >= window.innerWidth;
- const clipShape = (url) => {
+ const clipSize = (url) => {
const d = videoInfo.get(url) || {};
const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url);
const w = k && (k.w || k.width), hh = k && (k.h || k.height);
- return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null;
+ return w && hh ? { w, h: hh } : null;
};
+ const clipShape = (url) => { const z = clipSize(url); return z ? (z.h > z.w ? 'tall' : z.h < z.w ? 'wide' : 'square') : null; };
+ // width over height, kept between a phone's upright 9:16 and a cinema 2:1
+ // (a stray 32:9 or 1:4 would make a tile a sliver); unknown: a square
+ const clipRatio = (url) => { const z = clipSize(url); return z ? Math.min(2, Math.max(0.5, z.w / z.h)) : 1; };
// true/false, or null while the clip's shape isn't known
const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); };
const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide');
@@ -14207,9 +14211,11 @@ export function messagesFeature(ctx) {
if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url));
// The tiles take the clips' shape (Adam, 2026-10-22): wide clips only (a
// wide screen) go one to a row, their posters whole, not slivers cut to
- // portrait tiles; every shape ("All shapes") goes three across in
- // squares; tall ones only (a phone held upright) keep the tall tiles.
- const shape = mediaMode !== 'videos' ? '' : clipsAll() ? ' square' : screenTall() ? '' : ' wide';
+ // portrait tiles; every shape ("All shapes") is laid out in justified
+ // 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';
return h('div', { class: 'media-grid' + shape, 'data-key': 'media-grid:' + mediaMode },
...items.map((it) => {
const k = mediaKey(it);
@@ -14223,6 +14229,7 @@ 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,
'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k),
onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined,
},
diff --git a/src/style.css b/src/style.css
index 0403a88..3dcbf17 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1541,8 +1541,14 @@ body::after {
/* only wide clips in it: one to a row, the poster whole */
.media-grid.wide { grid-template-columns: 1fr; gap: 6px; }
.media-grid.wide .media-tile.video { aspect-ratio: 16 / 9; }
-/* every shape in it: squares, none of them favoured */
-.media-grid.square .media-tile.video { aspect-ratio: 1; }
+/* every shape in it: justified rows. A tile starts at its proportions at
+ the row height and grows in proportion to them, so a row's tiles share one
+ 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::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; }
.media-tile.broken { display: none; }
.media-tile-play {
diff --git a/tools/media-grid-dups-test.js b/tools/media-grid-dups-test.js
index 0c3783d..eeb105e 100644
--- a/tools/media-grid-dups-test.js
+++ b/tools/media-grid-dups-test.js
@@ -104,7 +104,17 @@ try {
check('…and "All shapes" brings it in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')); return !!s && !s.closest('.mp-group').classList.contains('mp-off'); }, null, 3000));
await page.keyboard.press('Escape');
check('the grid has it too', await waitFor(() => [...document.querySelectorAll('.media-grid .media-tile')].some((t) => t.getAttribute('data-key').endsWith('/tall.webm')), null, 5000));
- check('…in a 3-across grid of squares', await page.evaluate(() => { const g = document.querySelector('.media-grid'), t = g.querySelector('.media-tile').getBoundingClientRect(); return g.classList.contains('square') && Math.abs(t.width - t.height) < 2 && t.width < g.getBoundingClientRect().width / 2.5; }));
+ const rows = () => page.evaluate(() => {
+ const g = document.querySelector('.media-grid'), gw = g.getBoundingClientRect().width;
+ const ts = [...g.querySelectorAll('.media-tile')].map((t) => ({ k: t.getAttribute('data-key').split('/').pop(), r: t.getBoundingClientRect() }));
+ const by = new Map(); for (const t of ts) { const y = Math.round(t.r.top); (by.get(y) || by.set(y, []).get(y)).push(t); }
+ return { justified: g.classList.contains('justified'), gw, rows: [...by.values()].map((row) => ({ ks: row.map((t) => t.k), hs: row.map((t) => Math.round(t.r.height)), ars: row.map((t) => +(t.r.width / t.r.height).toFixed(2)), w: Math.round(row.reduce((a, t) => a + t.r.width, 0) + 3 * (row.length - 1)) })) };
+ });
+ await waitFor(() => { const t = [...document.querySelectorAll('.media-grid .media-tile')].find((x) => x.getAttribute('data-key').endsWith('/tall.webm')); return t && t.getBoundingClientRect().width < t.getBoundingClientRect().height; }, null, 5000);
+ const R = await rows();
+ const tallT = R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).find((x) => x.k === 'tall.webm');
+ check('…in justified rows: every tile its clip\'s shape (the tall one tall, the wide ones wide)', R.justified && tallT && Math.abs(tallT.ar - 0.5625) < 0.03 && R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).filter((x) => x.k !== 'tall.webm' && x.k !== 'last.webm').every((x) => Math.abs(x.ar - 16 / 9) < 0.03), JSON.stringify(R));
+ check('…one height across each row', R.rows.every((r) => Math.max(...r.hs) - Math.min(...r.hs) <= 1), JSON.stringify(R.rows.map((r) => r.hs)));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally {
await browser.close();