From 72705992c3d40f87052ade605c50f60a18f80453 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 13 Oct 2026 05:58:33 +0000 Subject: Media pager: the head start sizes itself to the clip and the link, and a tap pauses any playing clip. Adam: a fixed 512 KB head start isn't right for every clip; and a tap anywhere on a playing clip but the seek bar should pause it. The head start is now seconds of the clip, not bytes: two seconds' worth on a link well ahead of the clip's bitrate, four when only just ahead, and on a link slower than the clip enough to cover the shortfall over its length — at least 256 KB, at most 4 MB, the second clip ahead half. The bitrate is the file's size over its length when the post says both (imeta size and duration; duration is now kept), else guessed from its picture size, else a phone clip's; the link's speed is measured off the head starts themselves (a moving average), and until there is a measure it's two seconds' worth — Chrome's own downlink guess runs low (in the test browser it would have had every first head start at the cap). A reader who swipes on within seconds (a moving average of how long clips hold them) gets just the opening; a clip of 3 MB or less comes whole. A tap on a playing clip in the media grid's Videos pager now pauses it with its details up, as one opened from the feed does; another plays on (it only toggled the details). And mergeFeed's duplicate check now counts the posts waiting a moment to go in together: a copy from a second relay, or another version of an addressable clip, could slip in during that window (video-events-test caught the second, one run in a few). pager-eager-test: clips whose imeta states size and length; two seconds' worth ahead (half for the second) before the link is measured, still that on a fast link measured, the cap (half) on a 3 Mbps link the server paces (the player still takes the opening from the cache there — DevTools' throttling had defeated the cache, not the app); feed-media-views-test checks the tap to pause and play on in the Videos pager. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt --- tools/feed-media-views-test.js | 7 ++++++ tools/pager-eager-test.js | 48 +++++++++++++++++++++++++++++------------- 2 files changed, 40 insertions(+), 15 deletions(-) (limited to 'tools') diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index 14d766e..6649a93 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -177,6 +177,13 @@ try { st = await pagerState(); check('...the three vertical clips, nothing over them, sound on', st.n === 3 && st.src === 'a.mp4' && !st.show && st.meta === '0' && await page.evaluate(() => !document.querySelector('.media-pager video').muted), JSON.stringify(st)); + // a tap anywhere on the playing clip (not the seek bar or a button) pauses it, with its details up; another plays on + await page.mouse.click(195, 300); await sleep(400); + st = await pagerState(); + check('a tap on the playing clip pauses it, its details up', !st.playing[0] && st.show, JSON.stringify(st)); + await page.mouse.click(195, 300); await sleep(600); + st = await pagerState(); + check('...another plays it on, the details down', st.playing[0] && !st.show, JSON.stringify(st)); await sleep(1000); let ss = await srcs(); // (the next two get a head start fetched into the cache — pager-eager-test — but no player of their own) diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js index 5d99b8f..15a5076 100644 --- a/tools/pager-eager-test.js +++ b/tools/pager-eager-test.js @@ -1,8 +1,10 @@ // Flipping through clips in the pager is asking to watch them: a clip plays // at once whatever it costs — even one over the size budget, which in the // feed waits under a tap-to-play cover — and the next two get a head start: -// their players ready on their first frame and the opening half megabyte -// fetched into the cache, but no more of a big file than that. With Data +// their opening fetched into the cache, no more of a big file than that, and +// the player takes it from there. How much: a couple of seconds' worth of +// the clip on a link well ahead of its bitrate (the second clip ahead, half +// that); on a link slower than the clip, more — to the 4 MB cap. With Data // Saver on, the pager is careful again: such a clip waits for a tap and // nothing loads ahead. // Run: bun tools/pager-eager-test.js @@ -29,6 +31,7 @@ const html = await buildHtml({ minify: true, pwa: false }); // every request for a clip's bytes: who asked (the player, or a fetch), the // range, and the bytes actually sent (pulled by the reader, in 64 KB pieces) let log = []; +let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => { const path = new URL(req.url).pathname; const m = /^\/(c\d)\.webm$/.exec(path); @@ -40,8 +43,10 @@ const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => { const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 }; log.push(entry); let at = start; - const body = new ReadableStream({ pull(ctl) { + const t0 = Date.now(); + const body = new ReadableStream({ async pull(ctl) { if (at > end) { ctl.close(); return; } + if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); } const n = Math.min(65536, end - at + 1); ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n; } }, { highWaterMark: 0 }); @@ -52,15 +57,19 @@ const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => { } }); const U = (n) => server.url.origin + '/' + n + '.webm'; const seeded = [ - ...NAMES.map((n, i) => post('clip ' + n + ' here ' + U(n), 100 + i * 100)), + // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second + ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)), ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)), ]; const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0); const kb = (n) => Math.round(n / 1024) + ' KB'; +const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024; +const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty'); +const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1; const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); -async function run(saveData) { - console.log('\n[' + (saveData ? 'Data Saver on' : 'an ordinary connection') + ']'); +async function run(saveData, slow = false) { + console.log('\n[' + (saveData ? 'Data Saver on' : slow ? 'a link slower than the clips (3 Mbps)' : 'an ordinary connection') + ']'); log = []; const ctx = await browser.createBrowserContext(); // a cache of its own const page = await ctx.newPage(); @@ -79,6 +88,7 @@ async function run(saveData) { const feedRow = (w) => page.evaluate((x) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = r.querySelector('video.note-video').getBoundingClientRect(); return { cover: !!r.querySelector('.vid-heavy'), x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }, w); try { await page.setViewport({ width: 390, height: 844 }); + pace = slow ? 375000 : 0; await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); @@ -109,14 +119,18 @@ async function run(saveData) { log = log.filter((e) => e.clip === 'c0'); await page.mouse.click(r.x, r.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); - await sleep(3000); + await sleep(slow ? 8000 : 3000); const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3'); + let head1 = 0; if (!saveData) { check('the next clips wait on their pictures, no cover, no player yet', c1 && !c1.src && !c1.cover && c2 && !c2.src, JSON.stringify([c1, c2])); - const hs = log.filter((e) => e.clip === 'c1' && e.dest === 'empty'); - check('the next clip\'s opening is fetched ahead: half a megabyte', hs.length === 1 && hs[0].start === 0 && hs[0].end === 512 * 1024 - 1, JSON.stringify(hs.map(({ start, end, sent }) => ({ start, end, sent })))); - check('…and the one after\'s', log.some((e) => e.clip === 'c2' && e.dest === 'empty' && e.end === 512 * 1024 - 1)); - check('nothing more than that ahead of time', sent('c1') <= 512 * 1024 && sent('c2') <= 512 * 1024 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`); + const hs = heads('c1'), h2 = heads('c2'); + head1 = hs.length ? hs[0].end + 1 : 0; + const head2 = h2.length ? h2[0].end + 1 : 0; + // before any measure of the link: two seconds' worth (the second clip ahead, half) + check('the next clip\'s opening is fetched ahead: two seconds of it', hs.length === 1 && hs[0].start === 0 && near(head1, 2 * RATE), `${kb(head1)} (two seconds: ${kb(2 * RATE)})`); + check('…and half that of the one after', h2.length === 1 && near(head2, RATE), `${kb(head2)}`); + check('nothing more than that ahead of time', sent('c1') <= head1 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`); } else { check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1'))); } @@ -124,11 +138,15 @@ async function run(saveData) { await page.keyboard.press('ArrowDown'); const t0 = Date.now(); if (!saveData) { - check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000), (Date.now() - t0) + ' ms'); + check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000) && (Date.now() - t0) < (slow ? 3000 : 1500), (Date.now() - t0) + ' ms'); const after = log.slice(before).filter((e) => e.clip === 'c1' && e.dest === 'video'); - check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === 512 * 1024, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing')); + check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === head1, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing') + ', head start ' + head1); await sleep(1500); - check('…the next one gets its head start', log.some((e) => e.clip === 'c3' && e.dest === 'empty')); + const h3 = heads('c3'), head3 = h3.length ? h3[0].end + 1 : 0; + check('…the next one gets its head start', h3.length === 1); + // the link measured now: a fast one, still a couple of seconds' worth; one slower than the clip, the cap + if (!slow) check('…a fast link measured: still two seconds\' worth (half, as the second ahead)', near(head3, RATE), kb(head3)); + else check('…the link measured slower than the clip: it grows to the cap (half, as the second ahead)', head3 === HEAD_MAX / 2, kb(head3)); } else { await sleep(3000); const s = await slide('c1'); @@ -137,6 +155,6 @@ async function run(saveData) { check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await ctx.close(); } } -try { await run(false); await run(true); } finally { await browser.close(); server.stop(true); } +try { await run(false); await run(false, true); await run(true); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ the pager plays at once and gets a head start on what\'s next, unless saving data' : '\n❌ failed'); process.exit(ok ? 0 : 1); -- cgit v1.2.3