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