diff options
| author | Adam Soltys <[email protected]> | 2026-10-13 05:58:33 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-13 05:58:33 +0000 |
| commit | 72705992c3d40f87052ade605c50f60a18f80453 (patch) | |
| tree | aa79f9f44bc10e73a0630ed63213030da5c412a1 | |
| parent | 4e1cd666a06c4ee852586986d0301278d680a614 (diff) | |
| download | coinosv3-72705992c3d40f87052ade605c50f60a18f80453.tar.gz coinosv3-72705992c3d40f87052ade605c50f60a18f80453.zip | |
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.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
| -rw-r--r-- | src/features/messages.js | 67 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 7 | ||||
| -rw-r--r-- | tools/pager-eager-test.js | 48 |
3 files changed, 94 insertions, 28 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 42e2839..598ff39 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6388,6 +6388,7 @@ export function messagesFeature(ctx) { if (!have.size && +f.size > 0) d.size = +f.size; // no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8); + if (!have.dur && +f.duration > 0) d.dur = +f.duration; const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || ''); if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; } if (!have.image && /^https?:\/\//.test(f.image || '')) d.image = f.image; @@ -6819,15 +6820,18 @@ export function messagesFeature(ctx) { async function mergeFeed(evs, opts = {}, c = feedNow()) { let staged = feedStaged.get(c); if (!staged) feedStaged.set(c, staged = new Set()); - const known = new Set([...c.notes, ...(c.deferred || [])].map((e) => e.id)); + // (with those waiting a moment to go in together — admitQueues — or a + // copy from a second relay, or another version of a clip, slips in) + const held = [...c.notes, ...(c.deferred || []), ...((admitQueues.get(c) || {}).evs || [])]; + const known = new Set(held.map((e) => e.id)); for (const e of evs || []) noteForSpam(e); // A note shows once: as itself, or under the first repost of it to // arrive — counting the ones still warming at the door. // (`staged` holds the note's id too, for a repost) const noteOf = (e) => (e.kind === REPOST ? repostInner(e) : e); - const notesIn = new Set([...c.notes, ...(c.deferred || [])].map((e) => noteOf(e)?.id).filter(Boolean)); + const notesIn = new Set(held.map((e) => noteOf(e)?.id).filter(Boolean)); // an addressable post (a 34235/34236 clip) once, however many versions of it come - for (const e of [...c.notes, ...(c.deferred || [])]) { const a = noteOf(e) && addrOf(noteOf(e)); if (a) notesIn.add(a); } + for (const e of held) { const a = noteOf(e) && addrOf(noteOf(e)); if (a) notesIn.add(a); } const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); }; const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); }; const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } }; @@ -12237,7 +12241,7 @@ export function messagesFeature(ctx) { const v = s.querySelector('video.mp-media'); if (eager && (j === i + 1 || j === i + 2)) { pagerRelease(s); - headStart(playSrc(v.getAttribute('data-src'))); + headStart(playSrc(v.getAttribute('data-src')), j === i + 1 ? 1 : 0.5); return; } if (j === i + 1) { @@ -12256,19 +12260,52 @@ export function messagesFeature(ctx) { // is answered from there as far as it goes, and streams on from the host. // A host that ignores the range sends the whole file: the opening is // read, the rest cancelled. A host that doesn't allow the request (no - // CORS) gets none — its player still has its metadata and first frame. - const HEAD_START = 512 * 1024; + // CORS) gets none. + // + // How much: enough seconds of the clip that it starts at once and the + // stream stays ahead of it — few when the link is well ahead of the clip's + // bitrate, more when it's only just, and when it's behind, enough to cover + // the shortfall over the clip's length. The bitrate is the file's size over + // its length when a post says both (else guessed from its picture size, or + // a phone clip's); the link's speed is measured off these fetches + // themselves — until there's a measure, a couple of seconds' worth (the + // browser's own guess at the speed runs low, and would have had every + // first head start at the cap). A reader who swipes on within seconds gets + // just the opening; a small clip comes whole. + const HEAD_MIN = 256 * 1024, HEAD_MAX = 4 * 1024 * 1024; + let netRate = 0; // bytes/s, from the head starts (a moving average) + let dwell = 0; // seconds a clip is watched before the swipe on (a moving average) + function headStartBytes(url) { + const d = videoInfo.get(url) || {}; + const dur = d.dur > 0 ? d.dur : 0; + const B = d.size && dur ? d.size / dur // bytes/s + : d.w && d.h ? Math.max(150e3, d.w * d.h * 30 * 0.1 / 8) : 500e3; + const D = netRate; + let lead = !D || D >= 1.5 * B ? 2 : D >= B ? 4 : 2 + (dur || 20) * (1 - D / B); + if (dwell && dwell < 4) lead = Math.min(lead, 2); + let n = B * lead; + 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(); - function headStart(url) { + function headStart(url, share = 1) { if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return; headStarted.add(url); if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value); - fetch(url, { headers: { Range: 'bytes=0-' + (HEAD_START - 1) } }).then(async (r) => { + const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); + const t0 = performance.now(); + 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; - while (got < HEAD_START) { const { done, value } = await reader.read(); if (done) return; got += value.length; } - reader.cancel().catch(() => {}); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + got += value.length; + if (got >= want) { reader.cancel().catch(() => {}); break; } + } + 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(() => {}); } function playSlide(slide) { @@ -12359,9 +12396,10 @@ export function messagesFeature(ctx) { el.addEventListener('click', (e) => { if (e.target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it - if (pager && pager.fromFeed && video) { - // A clip opened from the feed: a tap pauses it and brings up who - // posted it, the ×, the seek bar to grab; another plays it on. + if (pager && video) { + // A clip (from the feed or the media grid): a tap anywhere but the + // seek bar and the buttons pauses it and brings up who posted it, + // the ×, the seek bar to grab; another plays it on. const v = pager.cur && pager.cur.querySelector('video.mp-media'); if (v && !v.paused) { v.pause(); el.classList.add('show'); pagerCounts(pager.cur); return; } el.classList.remove('show'); @@ -12421,11 +12459,14 @@ export function messagesFeature(ctx) { if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue; if (pager.cur && pager.cur !== e.target) { const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause(); + // how long a clip held the reader (the head start's measure of them) + if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; } // past the clip it opened on, the screen follows the phone again if (pagerFs || pagerLock) { pagerLock = false; try { screen.orientation?.unlock?.(); } catch {} } if (pager.fromFeed && video) el.classList.remove('show'); } pager.cur = e.target; + pager.curAt = Date.now(); if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === e.target._rowId); markSeen(row || e.target._ev); } playSlide(e.target); pagerCounts(e.target); 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); |
