aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 05:58:33 +0000
committerAdam Soltys <[email protected]>2026-10-13 05:58:33 +0000
commit72705992c3d40f87052ade605c50f60a18f80453 (patch)
treeaa79f9f44bc10e73a0630ed63213030da5c412a1
parent4e1cd666a06c4ee852586986d0301278d680a614 (diff)
downloadcoinosv3-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.js67
-rw-r--r--tools/feed-media-views-test.js7
-rw-r--r--tools/pager-eager-test.js48
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);