aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js117
1 files changed, 98 insertions, 19 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 4900c40..71d3370 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -6768,6 +6768,7 @@ export function messagesFeature(ctx) {
const videoProbes = new Map();
let videoProbing = 0;
const videoProbeQueue = [];
+ const clipGone = (url) => { const d = videoInfo.get(url); return !!d && typeof d.goneAt === 'number' && Date.now() - d.goneAt < 86_400_000; };
const probeFailedLately = (d) => !!d && typeof d.failedAt === 'number' && Date.now() - d.failedAt < 3_600_000;
function probeVideo(url, layout = false) {
const have = videoInfo.get(url);
@@ -6801,7 +6802,7 @@ export function messagesFeature(ctx) {
// player's plain load answers this request without them, and it fails.
// Any clip that had played in the feed was "unmeasurable" that way.
const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, cache: 'no-store', signal: ctrl.signal });
- if (!r.ok) throw new Error('HTTP ' + r.status);
+ if (!r.ok) throw Object.assign(new Error('HTTP ' + r.status), { status: r.status });
const ranges = r.status === 206;
const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length');
// never more than the first bytes, even from a server that ignored the range
@@ -6818,7 +6819,7 @@ export function messagesFeature(ctx) {
let o = 0;
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
- const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
+ const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, goneAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
if (total > 0) d.size = total;
else {
// a server that hides Content-Range from scripts (Primal's) still
@@ -6832,8 +6833,13 @@ export function messagesFeature(ctx) {
if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; }
if (head.w) rememberVideo(url, head.w, head.h);
noteVideoInfo(url, d);
- } catch {
- noteVideoInfo(url, { failedAt: Date.now() }); // a host that won't say (no CORS, down, slow): asked again in an hour
+ } catch (e) {
+ // a host that won't say (no CORS, down, slow): asked again in an hour.
+ // One that says the file is gone (deleted: a 404 or 410) is believed
+ // for a day, and the grid leaves the clip out (clipGone).
+ const gone = e && (e.status === 404 || e.status === 410);
+ noteVideoInfo(url, { failedAt: Date.now(), ...(gone ? { goneAt: Date.now() } : {}) });
+ if (gone && mediaMode === 'videos') scheduleRepaint();
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
@@ -13717,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;
@@ -13742,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;
}
@@ -13752,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
@@ -13907,16 +13918,51 @@ export function messagesFeature(ctx) {
// videos by default on a phone that's in vertical orientation and only
// horizontal videos if the phone is in landscape but have some kind of
// control to toggle the ability to see all videos".)
- const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round
- 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 {} };
+ // On a desktop-sized window every shape is the default (Adam, 2026-10-22:
+ // the justified rows hold both); a phone keeps to its way round.
+ const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', 'fit' (the screen's way round), or unset: the device's default
+ const bigWindow = () => { try { return matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; } catch { return false; } };
+ const clipsAll = () => { try { const v = localStorage.getItem(CLIPS_FIT_KEY); return v === 'all' || (v !== 'fit' && bigWindow()); } catch { return bigWindow(); } };
+ const setClipsAll = (all) => { try { if (all === bigWindow()) localStorage.removeItem(CLIPS_FIT_KEY); else localStorage.setItem(CLIPS_FIT_KEY, all ? 'all' : 'fit'); } 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;
};
+ // (no size yet: the still's proportions, which a grid of every shape has
+ // loaded without measuring anything — so going back to one shape finds
+ // the clips' shapes known)
+ const clipShape = (url) => {
+ const z = clipSize(url), r = z ? z.w / z.h : posterAr.get(url);
+ return r ? (r < 0.99 ? 'tall' : r > 1.01 ? '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
+ // (until the clip's size is known, the still's own proportions: the
+ // server's and a frame grab keep the clip's shape)
+ 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);
+ posterAr.set(url, r);
+ const tile = img.closest('.media-grid.justified .media-tile');
+ if (tile) tile.style.setProperty('--ar', r.toFixed(3));
+ }
// 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');
@@ -13952,7 +13998,10 @@ export function messagesFeature(ctx) {
// or the same words from the same person posted again with another upload.
const sameFile = (url) => { const m = /\/([0-9a-f]{64})(?:\.\w+)?(?:[?#]|$)/i.exec(url); return m ? m[1].toLowerCase() : url.replace(/[?#].*$/, ''); };
const saidAgain = (ev) => { const w = String(ev.content || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim(); return w.length >= 8 ? ev.pubkey + '|' + w : null; };
- function mediaItems(c, kind) {
+ // (anyShape: the pager's list — every clip, the ones the wrong way round
+ // for the screen held out of sight by pagerFit, so "All shapes" there has
+ // them to show)
+ function mediaItems(c, kind, anyShape = false) {
const out = [], seen = new Set(), said = new Map();
const r = mediaIndex(c.id);
indexMedia(r, c.notes);
@@ -13965,7 +14014,8 @@ export function messagesFeature(ctx) {
const again = saidAgain(ev);
if (again && said.has(again) && said.get(again) !== ev.id) continue;
if (kind === 'videos') learnVideoMeta(ev);
- const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip)
+ // (a clip whose host says it's deleted has nothing to show: a blank tile)
+ const urls = kind === 'videos' ? clipUrlsOf(ev).filter((u) => !clipGone(u) && (anyShape || fitClip(u)))
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
// (the words are taken by a post that shows: one whose clip doesn't fit
// the screen leaves them to the next)
@@ -14195,7 +14245,15 @@ export function messagesFeature(ctx) {
if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u))));
}
if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url));
- return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode },
+ // 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") 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.
+ // 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);
// a video tile shows a still at once: kept, given, or the server's
@@ -14208,12 +14266,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:' + 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: (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');
@@ -14249,7 +14309,9 @@ export function messagesFeature(ctx) {
if (await grabFrame(url)) {
if (!v.isConnected) return;
const img = document.createElement('img');
- img.alt = ''; img.draggable = false; img.src = videoThumbs.get(url);
+ img.alt = ''; img.draggable = false;
+ img.addEventListener('load', () => arFromStill(img, url), { once: true });
+ img.src = videoThumbs.get(url);
v.replaceWith(img);
if (heavy && d.size) img.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
return;
@@ -14271,6 +14333,18 @@ export function messagesFeature(ctx) {
// (a plain load: shown, not kept — a player's pixels can only be
// read from a CORS load, and those fail on a cached plain copy)
v.muted = true;
+ // a clip the player can't load either is gone as far as the grid
+ // goes: a deleted file whose 404 came without CORS headers
+ // (Primal's blossom) only looked like a quiet host to the probe,
+ // and its tile sat blank. Left out for a day, like a 404 the
+ // probe could read.
+ v.addEventListener('error', () => {
+ if (!v.isConnected || !v.getAttribute('src')) return; // let go of by the eight-second cap
+ noteVideoInfo(url, { goneAt: Date.now() });
+ const b = v.closest('.media-tile');
+ if (b) b.classList.add('broken');
+ scheduleRepaint();
+ }, { once: true });
v.src = v.getAttribute('data-src');
if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
return;
@@ -15587,7 +15661,7 @@ export function messagesFeature(ctx) {
const c = feedNow();
pager.groups ||= new Map();
const src = pagerSource();
- for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) {
+ for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind, true))) {
const k = mediaKey(it);
if (pager.keys.has(k)) continue;
pager.keys.add(k);
@@ -15683,8 +15757,13 @@ export function messagesFeature(ctx) {
function pagerFit() {
if (!pager) return;
const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
+ // the grid's pager goes by the grid's rule: a clip of a shape not yet
+ // known stays out until it's measured (fitClip measures it); a page's
+ // pager shows one until it's known not to fit
+ const strict = pager.kind === 'videos' && !pagerSource();
+ const ok = (u) => (strict ? fitClip(u) : clipFits(u) !== false);
for (const g of pagerGroups(true)) {
- const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || clipFits(m.getAttribute('data-full') || m.getAttribute('data-src')) !== false; });
+ const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || ok(m.getAttribute('data-full') || m.getAttribute('data-src')); });
pagerToggleGroup(g, 'mp-off', !fits);
}
}