From 6fdca50f17b1e814bbf6ba1fc1ffcb18001c54fd Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 13 Oct 2026 05:10:27 +0000 Subject: 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). 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 --- src/features/messages.js | 45 +++++++++++-- tools/feed-media-views-test.js | 17 +++-- tools/pager-eager-test.js | 142 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 tools/pager-eager-test.js diff --git a/src/features/messages.js b/src/features/messages.js index 9cba810..61cdc18 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6467,6 +6467,8 @@ export function messagesFeature(ctx) { } catch {} return 25e6; } + // Data Saver on, or a slow link: the connection to be careful with + const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } }; const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs function videoHeavy(url) { if (tappedClips.has(url) || largeVideosOk()) return false; @@ -12120,18 +12122,32 @@ export function messagesFeature(ctx) { const m = slide.querySelector('video.mp-media'); if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} } } - // Only the clip on screen loads in full. The next one gets its opening - // (preload metadata: the index and first frames) if it's within budget; - // every other clip holds nothing. Sizes are asked for a few slides ahead, - // so the verdict is in before the reader gets there. + // Flipping through clips is asking to watch them: whatever they cost, the + // one on screen plays at once (streaming the rest as it goes), and the + // next two get a head start: the opening half megabyte of each fetched + // ahead into the browser's cache, where the player finds it — but no + // more, so a big file isn't pulled down for a clip that may be swiped + // past. (Not a player of their own yet: one given its file, even told to + // preload only the metadata, takes the whole of it on a fast link.) Unless the connection + // is one to be careful with (Data Saver, a slow link). Then only the clip on screen + // loads in full, the next gets its opening if it's within budget, and a + // clip over budget waits for a tap; sizes are asked for a few slides + // ahead, so the verdict is in before the reader gets there. Every other + // clip holds nothing. function pagerNeighbours(slide) { if (!pager || pager.kind !== 'videos') return; const slides = [...pager.scroller.children]; const i = slides.indexOf(slide); + const eager = !frugalData(); slides.forEach((s, j) => { if (Math.abs(j - i) <= 2) slidePoster(s); if (j === i) return; const v = s.querySelector('video.mp-media'); + if (eager && (j === i + 1 || j === i + 2)) { + pagerRelease(s); + headStart(playSrc(v.getAttribute('data-src'))); + return; + } if (j === i + 1) { const url = v.getAttribute('data-src'); probeVideo(url).then(() => { @@ -12143,11 +12159,32 @@ export function messagesFeature(ctx) { }); for (const s of slides.slice(i + 2, i + 4)) probeVideo(s.querySelector('video.mp-media').getAttribute('data-src')); } + // The opening of a clip about to be reached, fetched into the browser's + // HTTP cache with a range request: the player's own request for the file + // 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; + const headStarted = new Set(); + function headStart(url) { + 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 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(() => {}); + }).catch(() => {}); + } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); if (!v) return; pagerNeighbours(slide); const url = v.getAttribute('data-src'); + if (!frugalData()) { startSlide(slide); return; } // no waiting on its size: it plays probeVideo(url).then(() => { if (!pager || pager.cur !== slide) return; if (videoHeavy(url)) { 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); -- cgit v1.2.3