diff options
| author | Adam Soltys <[email protected]> | 2026-10-16 22:55:25 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-16 22:55:25 -0700 |
| commit | 8deede7162ae195934cdee6074d1443c5af3c803 (patch) | |
| tree | 1d1efddcff5f24ede16792d66724dcd563f52369 /tools | |
| parent | ebaec4226a49628ea0ce6e08e4b41480bb9327a7 (diff) | |
| download | coinosv3-8deede7162ae195934cdee6074d1443c5af3c803.tar.gz coinosv3-8deede7162ae195934cdee6074d1443c5af3c803.zip | |
The video pager's seek bar shows only on the clip square on the screen: none while the pager is in motion.
The bar sits in the clip's slide, so a glide to the next clip carried it up the screen, a white line sliding out while the next slid in (Adam, 2026-10-16: "i don't want to see the seek bar while i'm transitioning to the next video"). The pager's frame loop now marks the pager `moving` whenever the clip on screen's slide is off its mark by more than 2px — under a finger, in a glide or a flight, snapping back — and under that mark no seek bar shows at all; it fades back in (180 ms) once the clip has landed. Bars on slides other than the one on screen stay hidden too (a slide seen before kept its bar ready).
pager-seek-hide-test: at rest one bar, on the clip on screen; through a glide to the next clip every frame between the two has the pager marked moving and no bar to be seen; the landed clip's bar fades in and the one before shows none; a finger holding the slides 100px off their mark hides it, letting go brings it back. pager-controls, pager-no-poster and pager-swipe-fly still pass (feed-video-pager-test's two failures are on clean HEAD too).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/pager-seek-hide-test.js | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js new file mode 100644 index 0000000..6c073c6 --- /dev/null +++ b/tools/pager-seek-hide-test.js @@ -0,0 +1,121 @@ +// The video pager's seek bar shows only on the clip square on the screen: +// while the pager is in motion — a glide to the next clip, a finger +// dragging the slides, the snap back — no seek bar shows at all (it rode +// along with the slide, a white line sliding up the screen), and it fades +// back in once the clip has landed. Run: bun tools/pager-seek-hide-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 } 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; }; +const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const clip = (name, size) => { + const f = '/tmp/coinos-vpager-' + name + '.webm'; + if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]); + return f; +}; +const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') }; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + const m = /^\/(wide|tall|last)\.webm$/.exec(path); + if (m) { + const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || ''); + if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } }); + const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1; + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } }); + } + if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } }); + 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 post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK); +const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400)]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + 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); +}); +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; }; +// each slide's bar: its clip, whether it is the one on screen, whether the bar can be seen +const bars = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const b = s.querySelector('.mp-seek'), cs = getComputedStyle(b); return { clip: s.getAttribute('data-k').split('/').pop(), cur: s.classList.contains('mp-cur'), seen: cs.visibility === 'visible' && +cs.opacity > 0.01, opacity: cs.opacity, ready: b.classList.contains('ready') }; })); +const seenBars = async () => (await bars()).filter((x) => x.seen).map((x) => x.clip + '@' + x.opacity); +// a recorder, frame by frame: the scroll position, the pager's motion flag, and any bar that can be seen +const record = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'), el = document.querySelector('.media-pager'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push({ top: sc.scrollTop, moving: el.classList.contains('moving'), seen: [...document.querySelectorAll('.mp-seek')].filter((b) => { const cs = getComputedStyle(b); return cs.visibility === 'visible' && +cs.opacity > 0.01; }).length }); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); +const trace = () => page.evaluate(() => window.__tr); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + 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: 'Clip 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()); + check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000)); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); + await sleep(400); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await page.mouse.click(at.x, at.y); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); + + console.log('\n[at rest: the clip on screen has its seek bar]'); + check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000)); + check('…and its seek bar shows', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && b.classList.contains('ready') && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars())); + check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' ')); + check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving'))); + + console.log('\n[a glide to the next clip: no seek bar on the way]'); + await record(); + await page.keyboard.press('ArrowDown'); + await sleep(1600); + const tr = await trace(); + const between = tr.filter((f) => f.top > 2 && Math.abs(f.top - 844) > 2 && f.top < 844); + check('the glide was seen frame by frame', between.length >= 3, between.length + ' frames between the two clips'); + check('…every one of them with the pager marked moving', between.every((f) => f.moving), between.map((f) => (f.moving ? 'm' : '-')).join('')); + check('…and not one seek bar to be seen', between.every((f) => f.seen === 0), between.map((f) => f.seen).join('')); + const landedOn = await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000); + check('it lands on the next clip', landedOn); + check('…whose seek bar fades back in', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars())); + check('…the one before, now off screen, shows none', (await bars()).filter((x) => x.clip === 'wide.webm').every((x) => !x.seen), JSON.stringify(await bars())); + check('the pager is not marked moving once landed', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving'))); + + console.log('\n[a finger holding the slides off their mark: none either]'); + await page.touchscreen.touchStart(195, 600); + await page.touchscreen.touchMove(195, 560); await sleep(30); + await page.touchscreen.touchMove(195, 500); await sleep(250); + check('held 100px up, the pager is marked moving', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('moving'))); + check('…and no seek bar shows', (await seenBars()).length === 0, (await seenBars()).join(' ')); + await page.touchscreen.touchMove(195, 590); await sleep(30); + await page.touchscreen.touchEnd(); + check('let go, it snaps back and the bar returns', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && getComputedStyle(b).opacity === '1' && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars())); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ no seek bar while the pager moves; it fades in on landing' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
