From bfcdc0beb9b0016aa0c58043c993eaad73825fe9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 17 Oct 2026 05:50:08 -0700 Subject: The picture pager works like the video pager: pictures join once decoded, the screen's way round with the shape toggle, and it flies (and counts) once ten are ready; flights end without the crawl, and the feed beneath doesn't repaint mid-flight. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam, 2026-10-17: "image pager should work similar. we need the tall only chip there too. i'm noticing some stutter/choppiness when flying images. especially towards the end of the fly as it's snapping to the one it's landing on." Measured on his phone (Pixel, 60 Hz) over synthesized touch flings in the live image pager: frames of 33–183 ms mid-flight, some with no script running (pictures fetched and decoded as the flight came near them: the near observer attached every post within 250% of the screen, a few of them originals — GIFs, hosts the server copies can't reach); the feed under the pager re-rendering (renderThreadStable, 40–55 ms each) during flights; and the flight's ease-out cube spending its last ~400 ms under 3 px a frame, the landing picture stepping a pixel at a time. - A picture post waits out of the carousel until its first picture is decoded (img.decode(), the copy falling back to the original as before). pagerWarmPics keeps four decoding at a time, up to sixteen ready ahead of the reader and three behind; a post whose copy and original both fail stays out. The picture's size, learned from the decode, places it for the shape rule. - The shape rule and its toggle ("Tall only" / "Wide only" / "All shapes", one setting with the videos) are in the picture pager's top bar too. - Flying needs ten posts ready, for pictures as for clips; the counter is hidden until then while posts are still warming (a pager with fewer than ten, all of them ready, counts them). The counter fade on settling applies to every pager (the frame loop's motion flag now runs for pictures too). - A flight eases out on a square instead of a cube: no long crawl at the end, and it sets off nearer the finger's speed. - scheduleRepaint holds off while the pager is moving. - pagerLoadAhead fills to the posts found (waiting ones too), not just the ready ones, so a gated pager doesn't crawl page after page. Tests: new tools/picture-pager-ready-test.js (a picture held back by the server waits out and a step passes over it; it joins above the one on screen without moving it; the wide one is out; no counter while one is warming, 3 / 6 once all are in; the toggle lets the wide one in and out and is as tall as the close button). pager-gone-picture now checks the dead picture is passed over. Picture tests not about shape turn all shapes on; pager-swipe-fly has twelve posts (ten ready before it flies) and waits out the longer flights; pager-loading's fixture gains pagerFound; pager-ready-gate: four clips all ready show their count. All pager tests pass except feed-pager-ahead and feed-video-pager, which fail the same checks as on a clean checkout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/features/messages.js | 110 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 21 deletions(-) (limited to 'src/features/messages.js') diff --git a/src/features/messages.js b/src/features/messages.js index 1cdbf58..82810c3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -747,11 +747,16 @@ export function messagesFeature(ctx) { let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; - repaintTimer = setTimeout(() => { + const run = () => { + // not under a pager in motion: the page beneath is out of sight, and + // its repaint (40–55 ms on Adam's phone) dropped frames from flights + // through the pictures (2026-10-17) — it waits for the landing + if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); - }, 80); + }; + repaintTimer = setTimeout(run, 80); }; // Profiles persist across sessions (capped) so known faces paint right @@ -12790,6 +12795,8 @@ export function messagesFeature(ctx) { // 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 posts found: the ready ones and those still warming, not those kept out for their shape + const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []); // 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 @@ -13073,7 +13080,7 @@ export function messagesFeature(ctx) { // still after a fast flick; snapping stays off until it lands. // A longer glide (a flight past several posts) says how long it takes; // onDone hears the landing, or the interruption (true) that caught it. - function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) { + function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) { pagerHold(box); // a glide under way is caught where it is const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from; box.style.scrollSnapType = 'none'; @@ -13086,7 +13093,7 @@ export function messagesFeature(ctx) { let t0 = 0; const step = (now) => { if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still) - const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3); + const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease); if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e; if (k < 1) box._glide = requestAnimationFrame(step); else done(); }; @@ -13139,13 +13146,15 @@ export function messagesFeature(ctx) { // it is passing and steps back from there; a touch or a key catches it // too (pagerHold → onDone). A notch from rest is the single step it was. let fly = null; // { to: the post flown to, n: notches in this run, at, dir } - // A video pager flies only once ten clips are ready to play (in the - // carousel): before that a fling outran the warming, landing on whatever - // was ready and fighting the clips joining (Adam, 2026-10-17: "let's not - // allow flying/coasting until we have at least 10 videos fully warmed - // up"). Until then a flick or a spin of the wheel is one post at a time. + // A pager flies only once ten posts are ready (in the carousel: clips + // ready to play, pictures decoded): before that a fling outran the + // warming, landing on whatever was ready and fighting the posts joining + // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at + // least 10 videos fully warmed up"; the picture pager the same: "image + // pager should work similar"). Until then a flick or a spin of the wheel + // is one post at a time. const PAGER_FLY_READY = 10; - const pagerCanFly = () => !pager || pager.kind !== 'videos' || frugalData() || pagerGroups().length >= PAGER_FLY_READY; + const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY; function pagerFly(dir, now) { if (!pager) return; const stops = pagerGroups(); @@ -13177,7 +13186,14 @@ export function messagesFeature(ctx) { pager.flying = true; const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way if (land) slideFrame(land, -1); + // A flight eases out on a square, not a cube: on a cube a long one + // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a + // nine-post flight), and the picture coming to rest stepped a pixel + // at a time — the stutter "towards the end of the fly as it's + // snapping" (Adam, 2026-10-17); and it set off at three times its + // average speed, faster than the finger that flung it. pagerGlide(pager.scroller, 'y', dest, vel, { + ease: screens > 1.05 ? 2 : 3, ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0, onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); }, }); @@ -13222,7 +13238,8 @@ export function messagesFeature(ctx) { if (el.textContent !== text) el.textContent = text; // a video pager shows no count until it can fly: ten clips ready (Adam, // 2026-10-17: "let's also not show the position/total counter until we have 10") - pager.el.classList.toggle('few', !pagerCanFly()); + // (one with fewer posts than that, every one of them ready, counts them) + pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)')); } function buildPager(want) { @@ -13234,10 +13251,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('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), + h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }), + 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)), // 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) @@ -13470,6 +13487,7 @@ export function messagesFeature(ctx) { playSlide(slide); pagerCounts(slide); pagerLoadAhead(); + pagerWarmPics(); }; pager.io = new IntersectionObserver((entries) => { for (const e of entries) { @@ -13494,8 +13512,9 @@ export function messagesFeature(ctx) { // clip's slide is off its mark, and the bar rode along with it, a // white line sliding up the screen (Adam, 2026-10-16: "i don't want to // see the seek bar while i'm transitioning to the next video"). It - // fades back in once the clip is square on the screen. - if (video) { + // fades back in once the clip is square on the screen. (The motion flag + // is a picture pager's too: its counter shows while it moves.) + { const p = pager; const frame = () => { if (pager !== p) return; @@ -13561,7 +13580,8 @@ export function messagesFeature(ctx) { try { // Bound each run; the timer resumes if sparse media needs more pages. for (let pages = 0; pages < 8 && pager === p; pages++) { - if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; + // (the posts found, waiting ones too: ready ones come only as fast as they warm) + if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; const src = pagerLoadSource(p); if (src.done()) break; const before = src.progress(); @@ -13593,7 +13613,8 @@ 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 + // out of the carousel until it's ready: a clip to play, a picture decoded + if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait'); pager.groups.set(gk, g); pager.scroller.append(g); } @@ -13605,6 +13626,53 @@ export function messagesFeature(ctx) { } pagerFit(); pagerPosition(); + pagerWarmPics(); + } + // A picture post is ready once its picture is decoded: only then does it + // join the carousel, so a flight passes over pictures already in hand. + // They were fetched and decoded as the flight came near them, and the + // flight stuttered — dropped frames with no script running, the decoding + // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen + // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a + // time are decoded ahead of the reader (and a few behind, for the way + // back), out of sight; its size learned, a picture the wrong way round + // for the screen stays out like a clip (pagerFit). One that can't be had + // at all — copy and original both gone — stays out. + const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3; + function pagerWarmPics() { + if (!pager || pager.kind !== 'images') return; + const all = pagerFound(); + const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group'))); + let busy = all.filter((g) => g._warming).length; + const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed; + for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) { + if (!all[j].classList.contains('mp-wait')) { ready++; continue; } + if (want(all[j])) { warmPic(all[j]); busy++; } + } + for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; } + } + function warmPic(g) { + const p = pager; + g._warming = true; + for (const x of g._row.children) pagerAttach(x); + const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media'); + // the copy failing falls back to the original (its onError): decoded again + const decode = (n) => { + const src = m.getAttribute('src'); + return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() => + n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false)); + }; + const timeout = new Promise((r) => setTimeout(() => r(false), 20000)); + Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => { + g._warming = false; + if (pager !== p || !g.isConnected) return; + if (!ok) { g._failed = true; pagerWarmPics(); return; } + const url = m && m.getAttribute('data-full'); + if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape + pagerFit(); + pagerToggleGroup(g, 'mp-wait', false); + pagerWarmPics(); + }); } // 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 @@ -13630,10 +13698,10 @@ export function messagesFeature(ctx) { // 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; + if (!pager) 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; }); + 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; }); pagerToggleGroup(g, 'mp-off', !fits); } } -- cgit v1.2.3