From 8a084e81534cc94418883600ba78292a16d04ffa Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 23:37:09 -0700 Subject: The video pager never lands on a black slide: a post joins the carousel once its clip is ready to play; and the carousel shows the clips the screen's way round, with a toggle for all shapes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ready to play: the clip's first frame on its slide and its opening in the cache (the head start done). Until then the post waits out of the carousel — parked off screen, no height, no snap point — and a swipe or a flight passes over it to the next post that is ready; it joins when ready, and a post joining above the clip on screen moves the scroll by its height in the same frame (snapping off for it, and never while the pager is in motion: then once it has settled), so the reader doesn't feel it. The post tapped to open the pager is in whatever its state. Seen on Adam's phone before this: 17 of 18 landings had their frame, the 18th was black for a second while the server was still making its frame. (Adam, 2026-10-16: "i never want to land on a black screen i'd rather just not have a video in carousel yet if it's not ready to play".) A local http clip, which can have no server frame, waits only for its opening. Data Saver is unchanged (nothing waits: heavy clips sit under their tap-to-play cover). The screen's way round: the Videos view used to show tall clips only, everywhere. Now it and the pager show tall clips on a screen taller than wide and wide clips on one wider than tall — a square either way — unless the reader asks for all shapes: a toggle over the Videos grid and in the pager's top bar ("Tall only" / "Wide only" / "All shapes"), remembered on the device. A post the wrong way round is kept out of the carousel the same way (not the one opened on, and never the clip on screen: a turn of the phone doesn't pull it away; shapes not yet known are let in, and a shape is learned from the server frame as it loads). Turning the phone re-sorts them. (Adam: "only show vertical 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".) The posts prepared ahead (frames for five ahead and three behind, head starts for the next two and the one before) skip the ones kept out for shape. tools/pager-ready-gate-test.js: the grid shows four of five clips (not the wide one) with the toggle, and all five after a tap; in the carousel the next clip, its frame delayed, waits while the one after (ready) has joined; a step lands past it on a clip with its frame; the waiting post joins above the clip on screen without moving it, the counter grows, a step back lands on it; the pager's toggle lets the wide post in and out. pager-tap-slop-test keeps all shapes on (its path has a wide clip before a tall one in a portrait window). Every other pager test passes; feed-pager-ahead and feed-video-pager fail the same checks on a clean checkout. Co-Authored-By: Claude Fable 5.1 --- src/features/messages.js | 136 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 119 insertions(+), 17 deletions(-) (limited to 'src/features/messages.js') diff --git a/src/features/messages.js b/src/features/messages.js index ce578e4..3789f93 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12169,11 +12169,41 @@ export function messagesFeature(ctx) { // stand taller than they're wide. A clip of unknown shape is measured // (the header probe, or a player when the index sits at the end of a // file a server serves in ranges) and joins once it's known to qualify. - function verticalClip(url) { + // ---- which clips fit the screen ----------------------------------------- + // The Videos view and the pager show the clips the screen's way round: tall + // ones on a phone held upright, wide ones held sideways (or on a desktop + // window), a square either way — unless the reader asks for all shapes + // (remembered on this device). (Adam, 2026-10-16: "only show vertical + // 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 {} }; + const screenTall = () => window.innerHeight >= window.innerWidth; + const clipShape = (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); - if (w && hh) return hh > w; + return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null; + }; + // 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'); + // the toggle: in the pager's top bar and over the Videos grid + function clipsToggle(cls, after) { + return h('button', { class: cls, type: 'button', title: t('clipsToggleTitle'), onClick: (e) => { + e.stopPropagation(); + setClipsAll(!clipsAll()); + for (const b of document.querySelectorAll('.mp-fit, .media-fit-btn')) b.textContent = clipsLabel(); + if (after) after(); + if (mediaMode === 'videos') scheduleRepaint(); + } }, clipsLabel()); + } + function fitClip(url) { + const f = clipFits(url); + if (f !== null) return f; + const d = videoInfo.get(url) || {}; if (!probeFailedLately(d) && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url); return false; } @@ -12196,7 +12226,7 @@ export function messagesFeature(ctx) { // a grid shows the picture itself — no warning to fold it behind if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; if (kind === 'videos') learnVideoMeta(ev); - const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip) + const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip) : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); for (const url of urls) { const it = { ev, url }; @@ -12408,6 +12438,7 @@ export function messagesFeature(ctx) { if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); } return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, + mediaMode === 'videos' ? h('div', { class: 'media-fit', 'data-key': 'media-fit' }, clipsToggle('media-fit-btn')) : null, ...items.map((it) => { const k = mediaKey(it); // a video tile shows a still at once: kept, given, or the server's @@ -12754,7 +12785,11 @@ export function messagesFeature(ctx) { // post), each a row of its pictures or clips — its quotes' too — side by // side (left and right go within it). Its slides, in reading order: const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []); - const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []); + // the posts in the carousel: those on offer (a clip's post waits out of it + // until it is ready to play, and a clip the wrong way round for the screen + // is kept out — see pagerReveal, pagerFit), or every one built (all) + const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)'; + const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []); // the slide a post's row rests on const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; }; // bring a slide to the screen: its post, and within it, the slide @@ -12768,13 +12803,17 @@ export function messagesFeature(ctx) { // along (the next in this post), and the post after next function pagerAhead(slide) { const out = []; - const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; }; + // (a post kept out for its shape is passed over: nothing to ready there) + const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.nextElementSibling; return n; }; const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1); const down = g1 && rowSlide(g1); if (down) out.push(down); if (slide.nextElementSibling) out.push(slide.nextElementSibling); const down2 = g2 && rowSlide(g2); if (down2) out.push(down2); + const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; }; + const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up); + if (back) out.push(back); // (the one before: ready again for the swipe back) return out; } function pagerAttach(slide) { @@ -12823,7 +12862,7 @@ export function messagesFeature(ctx) { const k = ahead.indexOf(s); if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s); // the slides around the reader (and the one landed on) hold their clip's first frame - if (eager && (Math.abs(j - i) <= 3 || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k); + if (eager && ((j > i && j - i <= 5) || (j < i && i - j <= 3) || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k); if (j === i) return; const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { @@ -12865,6 +12904,7 @@ export function messagesFeature(ctx) { if (!p || !pager || !s.isConnected || v._frame === 'first') return false; if (kind === 'soft' && (v._frame || pager.cur === s)) return false; // never a soft still under the clip on screen v.setAttribute('poster', p); v._frame = kind; + pagerReveal(); return true; }; const soft = () => put(videoThumbs.get(url) || posterOf(s._ev, url), 'soft'); @@ -12873,7 +12913,11 @@ export function messagesFeature(ctx) { v._frameBusy = true; const im = new Image(); im.decoding = 'async'; - const ok = () => { v._frameBusy = false; put(first, 'first'); }; + const ok = () => { + v._frameBusy = false; + if (im.naturalWidth && im.naturalHeight && !clipShape(url)) { rememberVideo(url, im.naturalWidth, im.naturalHeight); pagerFit(); } // its shape, from the frame + put(first, 'first'); + }; im.onload = () => (im.decode ? im.decode().then(ok, ok) : ok()); im.onerror = () => { v._frameBusy = false; serverFirstFailed.add(url); slideFrame(s, k); }; im.src = first; @@ -12919,14 +12963,14 @@ export function messagesFeature(ctx) { if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size; return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity)); } - const headStarted = new Set(); + const headStarted = new Map(); // url -> its promise + const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read) function headStart(url, share = 1) { - if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return Promise.resolve(); - headStarted.add(url); - if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value); + if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); + if (headStarted.has(url)) return headStarted.get(url); const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); - return fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { + const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { const reader = r.ok && r.body ? r.body.getReader() : null; if (!reader) return; let got = 0; @@ -12938,7 +12982,10 @@ export function messagesFeature(ctx) { } const secs = (performance.now() - t0) / 1000; if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; } - }).catch(() => {}); + }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); } + return task; } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); @@ -13163,8 +13210,10 @@ export function messagesFeature(ctx) { h('div', { class: 'mp-position', role: 'status' }, '0 / 0'), h('div', { class: 'mp-top' }, h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), - video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, - h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null), + video ? h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => pagerFit()), + h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, + h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute')))) : null), // a mouse's way through (shown where there is a pointer to hover with, // style.css): up and down the posts at the right edge, left and right // along a post's pictures when it has more than one (has-left/right) @@ -13264,7 +13313,7 @@ export function messagesFeature(ctx) { // let go of a drag: on to the next (or the one before), or back function settle(d, step, vel = 0) { const box = d.box, y = d.axis === 'y'; - const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y)); + const stops = [...box.children].filter((c) => c.matches(y ? PAGER_ON : '.mp-slide')).map((c) => pagerAt(box, c, y)); if (!stops.length) return; // the stop it set off from, then one along (or back) — or, flung, several const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0); @@ -13413,6 +13462,7 @@ export function messagesFeature(ctx) { if (bar) bar._paint(); const r = p.cur && p.cur.getBoundingClientRect(); p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2)); + if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); } p.raf = requestAnimationFrame(frame); }; p.raf = requestAnimationFrame(frame); @@ -13430,6 +13480,8 @@ export function messagesFeature(ctx) { if (!pager) return; pager.screenH = pager.scroller.clientHeight; if (pager.anchor) pagerShow(pager.anchor); + for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel(); + pagerFit(); }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move @@ -13459,7 +13511,7 @@ export function messagesFeature(ctx) { async function pagerLoadAhead() { const p = pager; if (!p) return; - const groups = pagerGroups(); + const groups = pagerGroups(true); const slide = p.cur || p.anchor; p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group'))); if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return; @@ -13500,16 +13552,66 @@ export function messagesFeature(ctx) { let g = pager.groups.get(gk); if (!g) { g = pagerGroup(); + if (pager.kind === 'videos' && !frugalData()) g.classList.add('mp-wait'); // out of the carousel until its clip is ready to play pager.groups.set(gk, g); pager.scroller.append(g); } + if (k === pager.start) { g.classList.remove('mp-wait'); g._opened = true; } // the clip tapped: in, whatever its state g._row.append(s); pagerDots(g); pager.near.observe(s); pager.io.observe(s); } + pagerFit(); + pagerPosition(); + } + // A post's clip is ready to play when its first frame is on the slide and + // its opening is in the cache (the head start done): only then does the + // post join the carousel, so the reader never lands on a black slide + // (Adam, 2026-10-16: "i never want to land on a black screen i'd rather + // just not have a video in carousel yet if it's not ready to play"). A + // post joining above the one on screen would push it down: the scroll + // moves by the post's height in the same frame, snapping off meanwhile, + // and not while the pager is in motion (then, once it has settled). + function pagerReveal() { + if (!pager || pager.kind !== 'videos') return; + const eager = !frugalData(); + for (const g of pager.scroller.querySelectorAll(':scope > .mp-group.mp-wait')) { + const v = g._row.firstElementChild && g._row.firstElementChild.querySelector('video.mp-media'); + if (!v) continue; + const url = v.getAttribute('data-src'); + // the frame is asked of a clip that can have one (the server's: an https + // clip); a local http clip (a dev setup) waits only for its opening + if ((serverFirst(url) && !v._frame) || (eager && !headDone.has(playSrc(url)))) continue; + pagerToggleGroup(g, 'mp-wait', false); + } + } + // the posts whose clips are the wrong way round for the screen stay out + // (not the one opened on; a shape not yet known is let in) + function pagerFit() { + if (!pager || pager.kind !== 'videos') return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + for (const g of pagerGroups(true)) { + const fits = g._opened || g === cg || [...g._row.children].some((s) => { const v = s.querySelector('video.mp-media'); return !v || clipFits(v.getAttribute('data-src')) !== false; }); + pagerToggleGroup(g, 'mp-off', !fits); + } + } + function pagerToggleGroup(g, cls, on) { + if (!pager || g.classList.contains(cls) === on) return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + const above = !!cg && cg !== g && !!(g.compareDocumentPosition(cg) & Node.DOCUMENT_POSITION_FOLLOWING); + if (above && pagerMoving()) { (pager.later ||= []).push([g, cls, on]); return; } // once it has settled (the frame loop) + const box = pager.scroller; + const shown = g.offsetParent !== null; // (a group hidden the other way stays hidden: no height to count) + const h0 = shown ? g.offsetHeight : 0; + box.style.scrollSnapType = 'none'; + g.classList.toggle(cls, on); + if (above) box.scrollTop += (g.offsetParent !== null ? g.offsetHeight : 0) - h0; + requestAnimationFrame(() => { if (pager && !box._glide) box.style.scrollSnapType = ''; }); pagerPosition(); + if (cg === g && on) { const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); } // the one on screen went out (turned the phone): whoever is there now } + const pagerMoving = () => !!pager && (pager.flying || !!pager.scroller._glide || pager.el.classList.contains('moving')); // A post in the pager: its row of slides, side by side, and — with more // than one — dots saying how many and which. function pagerGroup() { -- cgit v1.2.3