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 --- src/features/messages.js | 178 +++++++++++++++++++++++++++++++++++++++++------ src/style.css | 23 ++++++ 2 files changed, 179 insertions(+), 22 deletions(-) (limited to 'src') 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 { -- cgit v1.2.3