aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 04:04:32 +0000
committerAdam Soltys <[email protected]>2026-10-13 04:04:32 +0000
commitb01ec800019bc0ac74926a4366f045b71bd7c29b (patch)
tree7dee4b603ab0258a40ed3685344469bde18878aa
parent3ff588d439dbe86da3fd5b2f433f302078bb0222 (diff)
downloadcoinosv3-b01ec800019bc0ac74926a4366f045b71bd7c29b.tar.gz
coinosv3-b01ec800019bc0ac74926a4366f045b71bd7c29b.zip
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.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
-rw-r--r--src/features/messages.js178
-rw-r--r--src/style.css23
-rw-r--r--tools/feed-video-pager-test.js170
-rw-r--r--tools/puppeteer.js2
-rw-r--r--tools/video-tap-test.js35
5 files changed, 377 insertions, 31 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 7abbb93..648f22d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -7507,6 +7507,7 @@ export function messagesFeature(ctx) {
else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {});
return;
}
+ if (openClip(v, url)) return;
// a clip still idle (autoplaying, no controls) becomes the reader's
wokeClips.add(url);
const box = v.closest('.note-video-box');
@@ -7520,7 +7521,11 @@ export function messagesFeature(ctx) {
}).catch(() => {});
}
if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', () => {
- if (!fsElement()) { try { screen.orientation?.unlock?.(); } catch {} }
+ if (fsElement()) return;
+ try { screen.orientation?.unlock?.(); } catch {}
+ // out of the pager's full screen some other way (Back on Android, Escape
+ // on a computer): out of the pager too, in the one step
+ if (pagerFs) { pagerFs = false; if (pager) dismissPager(); }
});
function videoNode(url, { stable = false } = {}) {
const loud = unmutedClips.has(url);
@@ -11611,37 +11616,81 @@ export function messagesFeature(ctx) {
}
return out;
}
- // The feed's pictures in reading order — every one of a post before the
- // next post's — for the pager opened from a picture in the feed: the
- // feed's own list (its order, its moderation), not the media index.
- function feedImageItems(c) {
+ // The feed's pictures (or clips) in reading order — every one of a post
+ // before the next post's — for the pager opened from one in the feed: the
+ // feed's own list (its order, its moderation), not the media index. Clips
+ // of every shape, not only the tall ones the Videos view flips through.
+ function feedMediaItems(c, kind) {
const out = [];
const list = c.winList ? c.winList() : c.notes;
for (const row of list) {
const ev = row.kind === REPOST ? repostInner(row) || row : row;
if (modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue;
- for (const url of noteMediaUrls(ev.content)) {
- if (url.startsWith('https://i.ytimg.com/')) continue;
- out.push({ ev, url, row });
- }
+ if (kind === 'videos') learnVideoMeta(ev);
+ const urls = kind === 'videos' ? noteVideoUrls(ev.content)
+ : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
+ for (const url of urls) out.push({ ev, url, row });
}
return out;
}
+ // The feed row a tapped picture or clip sits in, when the feed itself is
+ // the page (not a thread, a profile or a chat).
+ function feedRowOf(el) {
+ const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList;
+ return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
+ }
// A picture tapped in the feed opens in the media pager, on that picture,
// with the feed's other pictures a swipe (or an arrow) away, the way the
// media grid's are. On any other page — a thread, a profile, a chat — the
// picture opens alone, as before.
function openPicture(url, e) {
try {
- const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList;
- const rowId = e && e.target && e.target.closest ? e.target.closest('.notes-feed > [data-key]')?.getAttribute('data-key') : null;
- if (inFeed && rowId) {
- const it = feedImageItems(feed).find((x) => x.row.id === rowId && x.url === url);
+ const rowId = feedRowOf(e && e.target);
+ if (rowId) {
+ const it = feedMediaItems(feed, 'images').find((x) => x.row.id === rowId && x.url === url);
if (it) { openMediaPager('images', mediaKey(it), { fromFeed: true }); return; }
}
} catch {}
ctx.openImage && ctx.openImage(url);
}
+ // A clip tapped in the feed opens in the pager the same way, over the
+ // feed's clips, picking up where it was. The pager takes the whole screen
+ // (what the player's own full screen did), and a wide clip turns the phone
+ // on its side — for that clip; past it the screen follows the phone.
+ function openClip(v, url) {
+ try {
+ const rowId = feedRowOf(v);
+ if (!rowId) return false;
+ const it = feedMediaItems(feed, 'videos').find((x) => x.row.id === rowId && x.url === url);
+ if (!it) return false;
+ tappedClips.add(url); // asked for by hand: no tap-to-play cover
+ const d = videoDims.get(url) || videoInfo.get(url) || {};
+ const w = v.videoWidth || d.w || d.width, hh = v.videoHeight || d.h || d.height;
+ const at = v.currentTime || 0;
+ v.pause();
+ openMediaPager('videos', mediaKey(it), { fromFeed: true, at });
+ pagerFullscreen(!!(w && hh && w > hh));
+ return true;
+ } catch { return false; }
+ }
+ let pagerFs = false; // the pager put the page full screen (and so takes it back out)
+ function pagerFullscreen(wide) {
+ const d = document.documentElement;
+ const go = d.requestFullscreen || d.webkitRequestFullscreen;
+ if (!go || fsElement()) return;
+ Promise.resolve(go.call(d, { navigationUI: 'hide' })).then(() => {
+ if (!ui.mediaPager) { exitFs(); return; }
+ pagerFs = true;
+ if (wide) screen.orientation?.lock?.('landscape').catch(() => {});
+ }).catch(() => {});
+ }
+ const exitFs = () => { try { (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {}); } catch {} };
+ function pagerUnFullscreen() {
+ if (!pagerFs) return;
+ pagerFs = false;
+ try { screen.orientation?.unlock?.(); } catch {}
+ if (fsElement() === document.documentElement) exitFs();
+ }
// The words of the post, without the links to the media it carries.
function mediaCaption(ev) {
let s = String(ev.content || '');
@@ -11767,10 +11816,11 @@ export function messagesFeature(ctx) {
}
}
- // `fromFeed`: opened from a picture in the feed (its pictures, in the
- // feed's order), rather than from the media grid
- function openMediaPager(kind, start, { fromFeed = false } = {}) {
- ui.mediaPager = { kind, start, feed: curFeedId, fromFeed };
+ // `fromFeed`: opened from a picture or clip in the feed (its pictures or
+ // 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 } = {}) {
+ ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, at };
render();
}
const closeMediaPager = () => ctx.goBack(() => { ui.mediaPager = null; });
@@ -11805,7 +11855,7 @@ export function messagesFeature(ctx) {
function syncMediaPager() {
const want = ui.mediaPager && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread ? ui.mediaPager : null;
if (!want) { if (pager) dropPager(); return; }
- if (pager && pager.kind !== want.kind) dropPager();
+ if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed)) dropPager();
if (!pager) buildPager(want);
else growPager();
}
@@ -11814,6 +11864,9 @@ export function messagesFeature(ctx) {
pager = null;
try { p.io.disconnect(); p.near.disconnect(); } catch {}
clearInterval(p.tick);
+ cancelAnimationFrame(p.raf);
+ window.removeEventListener('resize', p.onResize);
+ pagerUnFullscreen();
for (const v of p.el.querySelectorAll('video')) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} }
p.el.remove();
document.documentElement.classList.remove('no-scroll');
@@ -11876,9 +11929,59 @@ export function messagesFeature(ctx) {
guardPicture(media, url);
media.muted = !pagerLoud;
media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's
+ slide.append(seekBar(media));
}
return slide;
}
+ // A clip's own seek bar: a thin line along the bottom that fills as it
+ // plays, thicker with the details up or under a finger. Dragging it scrubs
+ // (the clip holds still meanwhile, and the time shows); the drag is the
+ // bar's alone, never the pager's swipe or its tap.
+ const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); };
+ function seekBar(v) {
+ const fill = h('div', { class: 'mp-seek-fill' });
+ const time = h('span', { class: 'mp-seek-time' });
+ const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time);
+ const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); };
+ const show = (f) => {
+ fill.style.width = (f * 100).toFixed(2) + '%';
+ time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration);
+ };
+ const to = (f) => { try { v.currentTime = f * v.duration; } catch {} };
+ bar.addEventListener('pointerdown', (e) => {
+ if (!(v.duration > 0 && isFinite(v.duration))) return;
+ e.stopPropagation(); e.preventDefault();
+ try { bar.setPointerCapture(e.pointerId); } catch {}
+ bar._drag = { playing: !v.paused, id: e.pointerId };
+ bar.classList.add('drag');
+ v.pause();
+ const f = frac(e); show(f); to(f);
+ });
+ bar.addEventListener('pointermove', (e) => {
+ if (!bar._drag || bar._drag.id !== e.pointerId) return;
+ e.stopPropagation();
+ const f = frac(e); show(f);
+ if (v.fastSeek) { try { v.fastSeek(f * v.duration); } catch { to(f); } } else to(f);
+ });
+ const end = (e) => {
+ const d = bar._drag;
+ if (!d || d.id !== e.pointerId) return;
+ bar._drag = null;
+ bar.classList.remove('drag');
+ if (e.type === 'pointerup') to(frac(e));
+ if (d.playing) v.play().catch(() => {});
+ };
+ bar.addEventListener('pointerup', end);
+ bar.addEventListener('pointercancel', end);
+ bar.addEventListener('click', (e) => e.stopPropagation());
+ bar._paint = () => {
+ if (bar._drag) return;
+ const ok = v.duration > 0 && isFinite(v.duration);
+ bar.classList.toggle('ready', ok);
+ if (ok) show(v.currentTime / v.duration);
+ };
+ return bar;
+ }
function pagerAttach(slide) {
const m = slide.querySelector('.mp-media');
if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src'));
@@ -11959,6 +12062,13 @@ export function messagesFeature(ctx) {
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);
+ // the clip the pager opened on carries on from where it was in the feed
+ if (pager && pager.at && slide.getAttribute('data-k') === pager.start) {
+ const at = pager.at;
+ pager.at = 0;
+ if (v.readyState >= 1) v.currentTime = at;
+ else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true });
+ }
v.muted = !pagerLoud;
v.play().catch((err) => {
// sound needs a tap the browser counted: play quietly, the button says so
@@ -11988,7 +12098,17 @@ export function messagesFeature(ctx) {
h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null));
// a tap shows (or hides) who posted it and what they said
el.addEventListener('click', (e) => {
- if (e.target.closest('button, a, .mp-caption')) return;
+ if (e.target.closest('button, a, .mp-caption, .mp-seek')) return;
+ if (pager && pager.fromFeed && video) {
+ // A clip opened from the feed: a tap pauses it and brings up who
+ // posted it, the ×, the seek bar to grab; another plays it on.
+ const v = pager.cur && pager.cur.querySelector('video.mp-media');
+ if (v && !v.paused) { v.pause(); el.classList.add('show'); pagerCounts(pager.cur); return; }
+ el.classList.remove('show');
+ if (v && v.getAttribute('src')) v.play().catch(() => {});
+ else if (pager.cur) playSlide(pager.cur);
+ return;
+ }
if (pager && pager.fromFeed) {
// Opened from a post in the feed: a tap on the picture that opened
// it closes it, as a full-screen picture always has. Once the reader
@@ -12002,7 +12122,7 @@ export function messagesFeature(ctx) {
el.classList.toggle('show');
if (el.classList.contains('show')) pagerCounts(pager && pager.cur);
});
- pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start };
+ pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start, at: want.at || 0 };
pager.near = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
@@ -12013,7 +12133,12 @@ export function messagesFeature(ctx) {
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue;
- if (pager.cur && pager.cur !== e.target) { const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause(); }
+ if (pager.cur && pager.cur !== e.target) {
+ const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause();
+ // past the clip it opened on, the screen follows the phone again
+ if (pagerFs) { try { screen.orientation?.unlock?.(); } catch {} }
+ if (pager.fromFeed && video) el.classList.remove('show');
+ }
pager.cur = e.target;
playSlide(e.target);
pagerCounts(e.target);
@@ -12027,6 +12152,15 @@ export function messagesFeature(ctx) {
document.documentElement.classList.add('no-scroll');
const first = [...scroller.children].find((s) => s.getAttribute('data-k') === want.start) || scroller.firstChild;
if (first) scroller.scrollTop = first.offsetTop;
+ // the clip on screen's seek bar, every frame (timeupdate comes a few times a second)
+ if (video) {
+ const p = pager;
+ const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); };
+ p.raf = requestAnimationFrame(frame);
+ }
+ // turned on its side (or back), the slides change height: stay on this one
+ pager.onResize = () => { if (pager && pager.cur) pager.scroller.scrollTop = pager.cur.offsetTop; };
+ window.addEventListener('resize', pager.onResize);
// new posts land in the feed while the pager is open; counts move
pager.tick = setInterval(() => { if (pager) { growPager(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500);
}
@@ -12035,7 +12169,7 @@ export function messagesFeature(ctx) {
function growPager() {
if (!pager) return;
const c = feedNow();
- for (const it of (pager.fromFeed ? feedImageItems(c) : mediaItems(c, pager.kind))) {
+ for (const it of (pager.fromFeed ? feedMediaItems(c, pager.kind) : mediaItems(c, pager.kind))) {
const k = mediaKey(it);
if (pager.keys.has(k)) continue;
pager.keys.add(k);
diff --git a/src/style.css b/src/style.css
index 0a1562d..9b7d6a0 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1528,6 +1528,29 @@ body::after {
.media-pager.quiet .mp-top .mp-x { opacity: 0; pointer-events: none; }
.media-pager.quiet .mp-top .mp-sound { pointer-events: auto; }
.media-pager.quiet.show .mp-top .mp-x { opacity: 1; pointer-events: auto; }
+/* a clip's seek bar: a thin line along the bottom, thicker with the details
+ up or under a finger; the strip above it is the finger's (no swipe there) */
+.mp-seek {
+ position: absolute; left: 0; right: 0; bottom: env(safe-area-inset-bottom, 0px); z-index: 3; height: 28px;
+ display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden;
+}
+.mp-seek.ready { visibility: visible; }
+.mp-seek-track { position: relative; width: 100%; height: 3px; background: rgba(255, 255, 255, .28); transition: height .15s; }
+.mp-seek-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #fff; }
+.mp-seek-fill::after {
+ content: ''; position: absolute; right: -7px; top: 50%; width: 14px; height: 14px; margin-top: -7px;
+ border-radius: 50%; background: #fff; opacity: 0; transition: opacity .15s;
+}
+.media-pager.show .mp-seek-track, .mp-seek.drag .mp-seek-track { height: 5px; }
+.media-pager.show .mp-seek-fill::after, .mp-seek.drag .mp-seek-fill::after { opacity: 1; }
+.mp-seek-time {
+ position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%); padding: 3px 8px; border-radius: 6px;
+ font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; background: rgba(0, 0, 0, .55);
+ opacity: 0; pointer-events: none; transition: opacity .12s;
+}
+.mp-seek.drag .mp-seek-time { opacity: 1; }
+/* the details sit clear of the seek bar */
+.media-pager.videos .mp-meta { padding-bottom: calc(max(16px, env(safe-area-inset-bottom)) + 22px); }
/* a clip that waits for a tap: what it costs, over its (blank or still) box */
.vid-heavy {
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);
};
@@ -69,16 +70,32 @@ try {
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));