diff options
| author | Adam Soltys <[email protected]> | 2026-10-13 06:22:43 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-13 06:22:43 +0000 |
| commit | 4196c6ae2d1cd9bf10e2612140abc4124993e724 (patch) | |
| tree | c45651bd50222eae4903ad62dcb0a3fb3cadf283 | |
| parent | 8f80948eb174a273f3b1dea963bb91745bcf323b (diff) | |
| download | coinosv3-4196c6ae2d1cd9bf10e2612140abc4124993e724.tar.gz coinosv3-4196c6ae2d1cd9bf10e2612140abc4124993e724.zip | |
Media pager: one clip plays — nothing under it starts, and nothing beside it. Adam turned his phone while watching a clip and heard it start again from the beginning while the picture played on: a second copy playing unseen. Not reproduced here (a turn in the test browser, with the clip muted or unmuted in the feed first, on this build or the last, leaves one clip playing), but three ways to a second copy are now closed. The feed under the pager stays laid out, and its autoplay watched it: anything there coming into view — a turn re-lays the page out — started, the pager's own clip as often as not, with sound if it had been unmuted there (with the guard off, the test's scroll under the pager starts the feed's copy). Under the pager the feed's autoplay now starts nothing, and opening the pager pauses every player on the page (YouTube too). In the pager only the slide on screen may start (not one a half-way layout passed over, whose start was still on its way), and starting one pauses any other. tools/pager-rotate-test.js (installed app, no full screen: one clip through a turn and back, on the slide it was on; a feed clip brought into view under the pager stays still; muted and unmuted in the feed first).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
| -rw-r--r-- | src/features/messages.js | 13 | ||||
| -rw-r--r-- | tools/pager-rotate-test.js | 123 |
2 files changed, 136 insertions, 0 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 6548e99..1c74526 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7466,6 +7466,10 @@ export function messagesFeature(ctx) { for (const en of entries) { const el2 = en.target; if (!el2.isConnected) { playerWatch.unobserve(el2); continue; } + // under the media pager nothing starts: the page laid out again + // beneath it (a turn of the phone) "showed" a clip — the pager's own, + // as often as not — and a second copy played behind it + if (ui.mediaPager && en.isIntersecting) continue; if (en.isIntersecting && en.intersectionRatio >= 0.5) { if (el2._onIn) el2._onIn(); } else if (el2._onOut) el2._onOut(); } }, { threshold: [0, 0.5] }); @@ -12027,6 +12031,11 @@ export function messagesFeature(ctx) { // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there function openMediaPager(kind, start, { fromFeed = false, at = 0 } = {}) { + // the page's own players stop: the pager's is the one to hear + for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} } + for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) { + try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {} + } ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, at }; render(); } @@ -12398,7 +12407,11 @@ export function messagesFeature(ctx) { if (thumb && v.getAttribute('poster') !== thumb) v.setAttribute('poster', thumb); } function startSlide(slide) { + // only the slide on screen plays (not one a half-way layout — a turn of + // the phone — passed over), and it alone + if (!pager || pager.cur !== slide) return; const v = slide.querySelector('video.mp-media'); + for (const o of pager.el.querySelectorAll('video.mp-media')) if (o !== v && !o.paused) { try { o.pause(); } catch {} } if (/#t=[\d.]+$/.test(v.getAttribute('src') || '')) v.setAttribute('src', playSrc(v.getAttribute('data-src'))); // the still's src: from the start now v.preload = 'auto'; pagerAttach(slide); diff --git a/tools/pager-rotate-test.js b/tools/pager-rotate-test.js new file mode 100644 index 0000000..9912b7a --- /dev/null +++ b/tools/pager-rotate-test.js @@ -0,0 +1,123 @@ +// Turning the phone while a clip plays in the pager leaves that one clip +// playing, and nothing else: not the clip's own copy in the feed under the +// pager (paused when the pager opened; a turn re-lays the feed out, and its +// autoplay used to start it again, from the start, with sound if it had been +// unmuted there), and not the slide a turn's half-way layout passes over. +// Run: bun tools/pager-rotate-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)), +]; + + +async function run(unmuteFirst) { + console.log('\n[' + (unmuteFirst ? 'the clip unmuted in the feed first' : 'the clip as it autoplays (muted) in the feed') + ']'); + const ctx = await browser.createBrowserContext(); + const page = await ctx.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); + // the installed app (no full screen there: the feed stays laid out under the pager) + const on = () => { try { return localStorage.getItem('__standalone') === '1'; } catch { return false; } }; + const mm = window.matchMedia.bind(window); + window.matchMedia = (q) => (on() && /display-mode:\s*standalone/.test(q) ? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} } : mm(q)); + screen.orientation.lock = () => Promise.resolve(); + screen.orientation.unlock = () => {}; + }); + 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; }; + // every player on the page that's going: where it is, what, from when, heard or not + const playing = () => page.evaluate(() => [...document.querySelectorAll('video')].filter((v) => !v.paused && !v.ended).map((v) => ({ where: v.closest('.media-pager') ? 'pager' : 'feed', src: (v.getAttribute('src') || '').split('/').pop(), t: +v.currentTime.toFixed(1), heard: !v.muted }))); + 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'); + localStorage.setItem('__standalone', '1'); + }, [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 }); + const rowOf = () => page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a tall one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await rowOf(); await sleep(1500); + if (unmuteFirst) { + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a tall one')); r.querySelector('.vid-sound')?.click(); }); + await sleep(800); + } + const at = await rowOf(); await sleep(300); + await page.mouse.click(at.x, at.y); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); + await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 1.5; }, null, 8000); + let p = await playing(); + check('one clip plays: the pager\'s', p.length === 1 && p[0].where === 'pager', JSON.stringify(p)); + // turn the phone: landscape, a moment, and back + await page.setViewport({ width: 844, height: 390 }); await sleep(250); + await page.setViewport({ width: 600, height: 390 }); await sleep(250); // (a turn passes through sizes on its way) + await page.setViewport({ width: 844, height: 390 }); await sleep(2500); + p = await playing(); + check('turned on its side: still that one clip, and only it', p.length === 1 && p[0].where === 'pager' && p[0].src === 'tall.webm' && p[0].t > 2, JSON.stringify(p)); + await page.setViewport({ width: 390, height: 844 }); await sleep(2500); + p = await playing(); + check('turned back: still only it', p.length === 1 && p[0].where === 'pager' && p[0].src === 'tall.webm', JSON.stringify(p)); + check('…on the slide it was on', await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); })); + // the feed under the pager laid out so another clip is in view: it doesn't start + 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(2000); + p = await playing(); + check('a clip coming into view in the feed under the pager stays still', p.length === 1 && p[0].where === 'pager', JSON.stringify(p)); + await page.keyboard.press('Escape'); await sleep(1200); + p = await playing(); + check('closed, the feed\'s own copy may go on (muted autoplay), but not two', p.length <= 1, JSON.stringify(p)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); + } finally { await ctx.close(); } +} +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +try { await run(false); await run(true); } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a turn leaves one clip playing' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
