From b01ec800019bc0ac74926a4366f045b71bd7c29b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 13 Oct 2026 04:04:32 +0000 Subject: Feed: a clip opens in the media pager, full screen, with the feed's other clips a swipe away and a seek bar to drag. Adam full-screened a video in the feed and swiped up for the next one, and nothing moved: a tap on a feed clip handed it to the player's own full screen (video.requestFullscreen), where every touch is the player's and there is no next. Now, as pictures do since 6eda018, the tap opens the media pager on that clip over the feed's clips in the feed's order (every shape, not only the tall ones the Videos view flips through), carrying on from where it was. What the player's full screen gave is kept: the pager puts the page itself full screen (the tap's own gesture), and a wide clip locks the screen to landscape — for that clip; past it the screen follows the phone. Each clip in the pager (the media grid's too) gets its own seek bar, a thin line along the bottom that thickens with the details up; dragging it scrubs with the time shown, and the drag is never the pager's swipe or tap. A tap pauses the clip and shows the post's details, the × and the bar; another plays on. Leaving full screen another way (Back on Android, Escape) leaves the pager in the same step; × or Escape after moving on lands the feed on the post last watched. Threads, profiles and chats keep the player's full screen. tools/feed-video-pager-test.js (open, resume, landscape lock and release, seek drag, tap pause/play, swipe and arrows, Escape onto the post, exit full screen); video-tap-test now checks the feed opens the pager and runs its native full-screen checks in a thread. tools/puppeteer.js takes CHROME=/path for a machine without /usr/bin/google-chrome. feed-media-views-test's range-less 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 --- tools/feed-video-pager-test.js | 170 +++++++++++++++++++++++++++++++++++++++++ tools/puppeteer.js | 2 + tools/video-tap-test.js | 35 ++++++--- 3 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 tools/feed-video-pager-test.js (limited to 'tools') diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js new file mode 100644 index 0000000..6d34cf1 --- /dev/null +++ b/tools/feed-video-pager-test.js @@ -0,0 +1,170 @@ +// A clip tapped in the feed opens in the media pager, over the feed's clips +// (every shape, a post's own first), carrying on from where it was. The +// pager takes the whole screen and a wide clip turns the phone on its side — +// for that clip only. Each clip has a seek bar to drag; a tap pauses (and +// shows the details), another plays on. Leaving full screen leaves the pager. +// Run: bun tools/feed-video-pager-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 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 post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +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) return new Response(Bun.file(CLIPS[m[1]]), { headers: { 'content-type': 'video/webm' } }); + 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 = [ + post('a wide one ' + U('wide'), 100), + post('a tall one ' + U('tall'), 200), + post('only words here, nothing to watch', 300), + post('the last clip ' + U('last'), 400), + ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60)), +]; + +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); + // a desktop can't turn: note what the page asks of the screen instead + window.__orient = []; + try { + screen.orientation.lock = (o) => { window.__orient.push('lock:' + o); return Promise.resolve(); }; + screen.orientation.unlock = () => { window.__orient.push('unlock'); }; + } catch {} +}); +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 CUR = () => { const sc = document.querySelector('.mp-scroll'); return sc && [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); }; +const curClip = () => page.evaluate((cur) => { const s = eval(cur)(); return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : null; }, '(' + CUR + ')'); +const resting = (name) => waitFor(([cur, n]) => { const s = eval(cur)(); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, ['(' + CUR + ')', name], 5000); +const curVideo = () => page.evaluate((cur) => { const s = eval(cur)(); const v = s && s.querySelector('video.mp-media'); return v ? { t: v.currentTime, d: v.duration, paused: v.paused, src: !!v.getAttribute('src') } : null; }, '(' + CUR + ')'); +const pagerState = () => page.evaluate(() => ({ open: !!document.querySelector('.media-pager.videos'), fs: document.fullscreenElement === document.documentElement, show: !!document.querySelector('.media-pager.show'), orient: window.__orient.slice() })); +const feedClip = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const v = r && r.querySelector('video.note-video'); return v ? { t: v.currentTime, paused: v.paused } : null; }, word); +const openClip = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); + await sleep(400); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); +}; +const tapPager = async () => { await page.mouse.click(195, 300); await sleep(400); }; +const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +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: '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)); + + console.log('\n[a tap opens the pager, full screen, where the clip was]'); + await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')).querySelector('video.note-video').scrollIntoView({ block: 'center' })); + for (let i = 0; i < 40; i++) { const c = await feedClip('a wide one'); if (c && c.t > 2) break; await sleep(250); } + const before = (await feedClip('a wide one'))?.t || 0; + check('the wide clip plays in the feed first', before > 1.5, 't=' + before.toFixed(2)); + await openClip('a wide one'); + let s = await pagerState(); + check('the tap opens the video pager', s.open); + check('…on the clip tapped', await resting('wide.webm'), await curClip()); + check('…full screen', await waitFor(() => document.fullscreenElement === document.documentElement, null, 3000)); + check('…turned on its side, for a wide clip', await waitFor(() => window.__orient.includes('lock:landscape'), null, 3000), JSON.stringify((await pagerState()).orient)); + check('the clip in the feed holds still underneath', (await feedClip('a wide one'))?.paused); + await waitFor((cur) => { const v = eval(cur)()?.querySelector('video.mp-media'); return v && !v.paused && v.currentTime > 0; }, '(' + CUR + ')', 6000); + let v = await curVideo(); + check('the clip plays on in the pager', v && !v.paused, JSON.stringify(v)); + check('…from where it was in the feed', v && v.t >= before - 0.3, `feed ${before.toFixed(2)}, pager ${v && v.t.toFixed(2)}`); + + console.log('\n[the seek bar]'); + check('the seek bar is up once the clip knows its length', await waitFor((cur) => !!eval(cur)()?.querySelector('.mp-seek.ready'), '(' + CUR + ')', 4000)); + const bar = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: r.x, y: r.y + r.height - 4, w: r.width }; }, '(' + CUR + ')'); + await page.mouse.move(bar.x + bar.w * 0.1, bar.y); + await page.mouse.down(); + await page.mouse.move(bar.x + bar.w * 0.4, bar.y, { steps: 4 }); + check('dragging shows the time', await page.evaluate((cur) => { const b = eval(cur)().querySelector('.mp-seek'); return b.classList.contains('drag') && /\d:\d\d \/ 0:30/.test(b.textContent); }, '(' + CUR + ')')); + await page.mouse.move(bar.x + bar.w * 0.7, bar.y, { steps: 4 }); + await page.mouse.up(); + await sleep(500); + v = await curVideo(); + check('the drag scrubs the clip', v && Math.abs(v.t - 0.7 * v.d) < 1.5, `t=${v && v.t.toFixed(2)} of ${v && v.d}`); + check('…and it plays on after', v && !v.paused); + check('…without paging', await resting('wide.webm'), await curClip()); + check('…or bringing up the details', !(await pagerState()).show); + + console.log('\n[a tap pauses and shows the details; another plays on]'); + await tapPager(); + v = await curVideo(); s = await pagerState(); + check('a tap pauses', v && v.paused, JSON.stringify(v)); + check('…and shows the details', s.show); + check('…with the author and the words', await page.evaluate((cur) => { const x = eval(cur)(); return /Clip Poster/.test(x.textContent) && /a wide one/.test(x.textContent); }, '(' + CUR + ')')); + check('…still in the pager', s.open && s.fs); + await tapPager(); + v = await curVideo(); s = await pagerState(); + check('another tap plays on', v && !v.paused, JSON.stringify(v)); + check('…and hides the details', !s.show); + + console.log('\n[the next clip: a swipe away, the screen free to turn]'); + await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); sc.scrollTo({ top: sc.clientHeight }); }); // what a swipe up does + check('a swipe lands on the next clip (a tall one)', await resting('tall.webm'), await curClip()); + check('…and lets the screen follow the phone', await waitFor(() => window.__orient.lastIndexOf('unlock') > window.__orient.indexOf('lock:landscape'), null, 3000), JSON.stringify((await pagerState()).orient)); + check('…playing', await waitFor((cur) => { const v = eval(cur)()?.querySelector('video.mp-media'); return v && !v.paused; }, '(' + CUR + ')', 6000)); + await page.keyboard.press('ArrowDown'); + check('down: the last clip, past a post with none', await resting('last.webm'), await curClip()); + await page.keyboard.press('ArrowUp'); + check('up: back to the tall one', await resting('tall.webm'), await curClip()); + + console.log('\n[Escape: out of the pager and full screen, onto the post]'); + await page.keyboard.press('Escape'); + check('the pager closes', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + check('…and full screen with it', await waitFor(() => !document.fullscreenElement, null, 3000)); + await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r && r.getBoundingClientRect().top < 120 && r.getBoundingClientRect().top > -10; }, 'a tall one', 3000); + check('…the feed on the tall clip\'s post', (await rowTop('a tall one')) !== null && (await rowTop('a tall one')) < 120 && (await rowTop('a tall one')) > -10, 'row top ' + (await rowTop('a tall one'))); + + console.log('\n[leaving full screen another way (Back on a phone) leaves the pager]'); + await openClip('a tall one'); + check('the pager is open, full screen', await waitFor(() => !!document.querySelector('.media-pager.videos') && document.fullscreenElement === document.documentElement, null, 3000)); + check('a tall clip leaves the screen as it is', !(await pagerState()).orient.slice(1).includes('lock:landscape'), JSON.stringify((await pagerState()).orient)); + await page.evaluate(() => document.exitFullscreen()); + check('out of full screen, out of the pager', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ feed clips page full screen, with a seek bar' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/puppeteer.js b/tools/puppeteer.js index 4e0f121..2609981 100644 --- a/tools/puppeteer.js +++ b/tools/puppeteer.js @@ -112,6 +112,8 @@ const guardBrowser = async (browser, ro) => { }; const launch = async ({ relays, ...opts } = {}) => { + // a machine without /usr/bin/google-chrome names its browser: CHROME=/path/to/chrome + if (process.env.CHROME) opts.executablePath = process.env.CHROME; const browser = await real.launch(opts); if (live !== '1' && relays !== 'live') await guardBrowser(browser, live === 'read' || relays === 'read'); return browser; diff --git a/tools/video-tap-test.js b/tools/video-tap-test.js index afa014e..f82238b 100644 --- a/tools/video-tap-test.js +++ b/tools/video-tap-test.js @@ -1,7 +1,8 @@ -// Tapping a feed video: anywhere on the picture (not the player's own play -// button or control bar) goes full screen — the corner button is a reach on -// a phone. Full screen, a tap pauses a playing clip; a tap on a paused one -// comes back out. Taps on the controls stay the player's. +// Tapping a video: in the feed it opens the media pager (feed-video-pager- +// test.js has the rest). In a thread, anywhere on the picture (not the +// player's own play button or control bar) goes full screen — the corner +// button is a reach on a phone. Full screen, a tap pauses a playing clip; a +// tap on a paused one comes back out. Taps on the controls stay the player's. // Run: bun tools/video-tap-test.js import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; @@ -34,12 +35,12 @@ 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 st = () => page.evaluate(() => { const v = document.querySelector('video.note-video'); return { fs: document.fullscreenElement === v, paused: v.paused, controls: v.controls, thread: !document.querySelector('video.note-video')?.isConnected }; }); +const st = () => page.evaluate(() => { const v = document.querySelector('.thread-card video.note-video') || document.querySelector('video.note-video'); return { fs: document.fullscreenElement === v, paused: v.paused, controls: v.controls, thread: !document.querySelector('video.note-video')?.isConnected }; }); // tap the clip at a fraction of its box (or of the screen, full screen) const tap = async (fx, fy) => { const fs = await page.evaluate(() => !!document.fullscreenElement); const r = fs ? { x: 0, y: 0, width: await page.evaluate(() => innerWidth), height: await page.evaluate(() => innerHeight) } - : await page.evaluate(() => { const b = document.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; }); + : await page.evaluate(() => { const b = (document.querySelector('.thread-card video.note-video') || document.querySelector('video.note-video')).getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; }); await page.mouse.click(r.x + r.width * fx, r.y + r.height * fy); await sleep(700); }; @@ -68,17 +69,33 @@ try { for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250); check('the clip autoplays in the feed', !(await st()).paused); + await tap(0.2, 0.3); + check('in the feed, a tap opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + await page.keyboard.press('Escape'); await sleep(700); + check('...which Escape closes', await page.evaluate(() => !document.querySelector('.media-pager') && !document.fullscreenElement)); + // the thread: the post's words open it + const words = await page.evaluate(() => { + const w = document.createTreeWalker(document.querySelector('.notes-feed'), NodeFilter.SHOW_TEXT); + for (let n; (n = w.nextNode());) if (n.textContent.includes('watch this')) { const r = document.createRange(); r.selectNodeContents(n); const b = r.getBoundingClientRect(); return { x: b.x + 10, y: b.y + b.height / 2 }; } + return null; + }); + await page.mouse.click(words.x, words.y); + for (let i = 0; i < 40 && !(await page.evaluate(() => !!document.querySelector('.thread-card video.note-video'))); i++) await sleep(250); + check('the thread is open', await page.evaluate(() => !!document.querySelector('.thread-card video.note-video'))); + await page.evaluate(() => document.querySelector('.thread-card video.note-video').scrollIntoView({ block: 'center' })); await sleep(300); + await page.evaluate(() => document.querySelector('.thread-card video.note-video').play().catch(() => {})); + for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250); await tap(0.2, 0.3); let s = await st(); - check('a tap on the picture goes full screen', s.fs, JSON.stringify(s)); - check('...without opening the thread', !s.thread); + check('in a thread, a tap on the picture goes full screen', s.fs, JSON.stringify(s)); + check('...and not the pager', await page.evaluate(() => !document.querySelector('.media-pager'))); await tap(0.25, 0.3); s = await st(); check('full screen, a tap pauses the playing clip', s.fs && s.paused, JSON.stringify(s)); await tap(0.5, 0.5); s = await st(); check('a tap on the paused clip\'s play button is left to the player', s.fs, JSON.stringify(s)); - await page.evaluate(() => document.querySelector('video.note-video').pause()); await sleep(300); + await page.evaluate(() => document.querySelector('.thread-card video.note-video').pause()); await sleep(300); await tap(0.2, 0.3); s = await st(); check('a tap elsewhere on the paused clip comes out of full screen', !s.fs, JSON.stringify(s)); -- cgit v1.2.3