aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 05:10:27 +0000
committerAdam Soltys <[email protected]>2026-10-13 05:10:27 +0000
commit6fdca50f17b1e814bbf6ba1fc1ffcb18001c54fd (patch)
tree6598fdb3a3f870cb3eb76e23eb630b07797d5b5e /tools
parent028dd56084b5a07bbff6d37e5f760ad344d58060 (diff)
downloadcoinosv3-6fdca50f17b1e814bbf6ba1fc1ffcb18001c54fd.tar.gz
coinosv3-6fdca50f17b1e814bbf6ba1fc1ffcb18001c54fd.zip
Media pager: every clip plays when it comes up, and the next two get a half-megabyte head start. Adam: flipping through clips should just play them, large ones too, and load a bit of what's next — not the whole of it. A clip over the size budget (or one whose size the host won't say: most, behind CORS) used to wait in the pager under a tap-to-play cover, and even a small one waited on its size probe (up to 5 s of HEAD) before playing. Now, on an ordinary connection, the pager plays the clip on screen at once, streaming as it goes, and fetches the opening 512 KB of the next two into the browser's HTTP cache with a range request (a host that ignores the range has the rest cancelled); the player, given the file when it's swiped to, takes its opening from the cache and streams on from there (the test's server sees its first request start at byte 524288; playing ~300 ms after the swipe). The next clips get no player of their own beforehand: one given its file, even with preload=metadata, took the whole 34 MB on a fast link. With Data Saver on or a slow link (2g/3g), the pager is careful as before: a clip over budget waits for a tap and nothing loads ahead. tools/pager-eager-test.js (35 MB clips: covered in the feed, played in the pager, 512 KB each ahead and nothing more, the cache reused; Data Saver: nothing ahead, the cover); feed-media-views-test moves its tap-to-play checks onto a slow link and checks the 50 MB clip plays on an ordinary one (its index-last probe check fails on HEAD before this too).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
Diffstat (limited to 'tools')
-rw-r--r--tools/feed-media-views-test.js17
-rw-r--r--tools/pager-eager-test.js142
2 files changed, 154 insertions, 5 deletions
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index 2f9d627..7f234a9 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -177,10 +177,11 @@ 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));
- await waitFor(() => { const v = document.querySelectorAll('.media-pager video')[1]; return v && !!v.getAttribute('src'); }, 5000);
+ await sleep(1000);
let ss = await srcs();
- check('only the clip on screen loads in full; the next gets its opening; the rest nothing',
- ss[0].src && ss[0].pre === 'auto' && ss[1].src && ss[1].pre === 'metadata' && !ss[2].src, JSON.stringify(ss));
+ // (the next two get a head start fetched into the cache — pager-eager-test — but no player of their own)
+ check('only the clip on screen has its file; the rest nothing yet',
+ ss[0].src && ss[0].pre === 'auto' && !ss[1].src && !ss[2].src, JSON.stringify(ss));
await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]')).length >= 3, 6000);
await sleep(1500);
const vi = await info();
@@ -193,10 +194,15 @@ try {
st = await pagerState();
check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st));
ss = await srcs();
- check('...and the window moves with it: the first lets go, the big one is not fetched as next', !ss[0].src && ss[1].pre === 'auto' && !ss[2].src, JSON.stringify(ss));
+ check('...and the window moves with it: the first lets go, the big one has no player yet', !ss[0].src && ss[1].pre === 'auto' && !ss[2].src, JSON.stringify(ss));
await page.keyboard.press('ArrowDown'); await sleep(1200);
st = await pagerState();
- check('the 50 MB clip waits for a tap, saying what it costs', st.src === 'c.mp4' && await page.evaluate(() => {
+ check('flipping is asking to watch: the 50 MB clip plays when it comes up', st.src === 'c.mp4' && st.playing[2] && await page.evaluate(() => !document.querySelectorAll('.mp-slide')[2].querySelector('.vid-heavy')), JSON.stringify(st));
+ // a connection to be careful with (a slow link here; Data Saver too): the big one waits for a tap
+ await page.evaluate(() => Object.defineProperty(navigator, 'connection', { configurable: true, get: () => ({ saveData: false, effectiveType: '3g', addEventListener() {}, removeEventListener() {} }) }));
+ await page.keyboard.press('ArrowUp'); await sleep(1200); await page.keyboard.press('ArrowDown'); await sleep(1500);
+ st = await pagerState();
+ check('on a slow link, the 50 MB clip waits for a tap, saying what it costs', st.src === 'c.mp4' && await page.evaluate(() => {
const s = document.querySelectorAll('.mp-slide')[2]; const b = s.querySelector('.vid-heavy');
return !!b && /50 MB/.test(b.textContent) && !/^[^#]*$/.test(s.querySelector('video').getAttribute('src') || '#'); }), JSON.stringify(st));
check('...over a picture of it (the kept frame, or the first frame itself)', await waitFor(() => {
@@ -210,6 +216,7 @@ try {
await page.evaluate(() => document.querySelectorAll('.mp-slide')[2].querySelector('.vid-heavy').click());
check('a tap plays it', await waitFor(() => { const v = document.querySelectorAll('.media-pager video')[2]; return !v.paused && v.currentTime > 0; }, 6000));
check('...without showing the overlay', !(await pagerState()).show);
+ await page.evaluate(() => { delete navigator.connection; });
await page.keyboard.press('Escape'); await sleep(600);
check('Escape closes it, leaving a grid of the clips', await page.evaluate(() => !document.querySelector('.media-pager') && document.querySelectorAll('.media-grid .media-tile.video').length === 3));
check('no clip goes on playing behind', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused)));
diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js
new file mode 100644
index 0000000..5d99b8f
--- /dev/null
+++ b/tools/pager-eager-test.js
@@ -0,0 +1,142 @@
+// 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
+// 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
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync, statSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// big clips: noise at a high bitrate, ~35 MB each — over the 25 MB budget
+const BIG = '/tmp/coinos-eager-big.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'nullsrc=s=180x320:r=15,geq=random(1)*255:128:128', '-t', '50', '-c:v', 'libvpx', '-b:v', '8M', '-minrate', '8M', '-maxrate', '8M', '-bufsize', '2M', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+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 = [];
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ 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) {
+ if (at > end) { ctl.close(); return; }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ }
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const seeded = [
+ ...NAMES.map((n, i) => post('clip ' + n + ' here ' + U(n), 100 + i * 100)),
+ ...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 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') + ']');
+ log = [];
+ const ctx = await browser.createBrowserContext(); // a cache of its own
+ const page = await ctx.newPage();
+ await page.evaluateOnNewDocument((sd) => {
+ if (sd) Object.defineProperty(navigator, 'connection', { configurable: true, get: () => ({ saveData: true, effectiveType: '4g', addEventListener() {}, removeEventListener() {} }) });
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ }, saveData);
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+ const slide = (k) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return s ? { cover: !!s.querySelector('.vid-heavy'), src: !!(v && v.getAttribute('src')), preload: v && v.preload, playing: !!(v && !v.paused && v.currentTime > 0) } : null; }, k);
+ 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 });
+ 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);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Eager Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
+
+ // in the feed the big clip waits under its cover; a tap on that plays it there, a tap on the clip opens the pager
+ await feedRow('clip c0 here'); await sleep(1500);
+ // (with Data Saver nothing autoplays in the feed: the player's own play button, not a cover)
+ if (!saveData) check('in the feed, a clip over budget waits under its cover', (await feedRow('clip c0 here')).cover);
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); const b = r.querySelector('.vid-heavy'); if (b) b.click(); else r.querySelector('video.note-video').play(); });
+ await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000);
+ const r = await feedRow('clip c0 here');
+ 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);
+ const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3');
+ 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`);
+ } else {
+ check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1')));
+ }
+ const before = log.length;
+ 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');
+ 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'));
+ await sleep(1500);
+ check('…the next one gets its head start', log.some((e) => e.clip === 'c3' && e.dest === 'empty'));
+ } else {
+ await sleep(3000);
+ const s = await slide('c1');
+ check('swiped to, it waits under its cover', s && s.cover && !s.playing, JSON.stringify(s));
+ }
+ 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); }
+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);