From 550bd5cb8be1a879083d987edca7b4e676ac3557 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 22:11:55 -0700 Subject: The video pager: a clip that plays by itself has no poster, and its caption is live — names open profiles, the words open the post. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No poster: every poster there was (the kept frame, a post's imeta picture, the server's) is 480px wide and taken at some moment of the clip, so it showed for a beat, soft and not quite the first frame, and the clip's own first frame replaced it — a flash on every flick (Adam, 2026-10-16: "if we're auto playing let's not show the thumbnail let's just start on the first frame of the video"). The slide is the pager's black until the player, whose opening is in the cache by then, has its first frame. Giving the next clip's player its file ahead of time was tried and dropped: it pulled the whole file down (pager-eager-test). On a connection to be careful with the poster stays: a heavy clip waits for a tap, and the still is what there is. The caption was a plain excerpt, which left "@NostrValley" as dead text. It is the post's words rendered as a post's are now — names, tags, note references and links live. A tap on a name, a tag or a note reference leaves the pager (and the full screen) before going where it goes; a link to the web opens in its own tab and the pager stays; a tap anywhere else on the caption leaves the pager and opens the post, where the whole of a long caption reads ("clicking anywhere on the caption that's not a link should take me out of fullscreen and take me to the post"). The expand-in-place toggle is gone. Tests: tools/pager-no-poster-test.js (no poster on the clip on screen nor the ones ahead, playing; with Data Saver the posters stay), tools/pager-caption-test.js (the name is a link and opens the profile out of the pager; a tap on the words opens the post; a web link opens its own tab and the pager stays). Pre-existing on a clean checkout: feed-video-pager-test "from where it was in the feed", feed-pager-ahead-test "past fifteen posts with none" (fails on 33c7218 too). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/messages.js | 56 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 11 deletions(-) (limited to 'src/features/messages.js') diff --git a/src/features/messages.js b/src/features/messages.js index ad5c32c..2bc034f 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12284,11 +12284,31 @@ export function messagesFeature(ctx) { if (fsElement() === document.documentElement) exitFs(); } // The words of the post, without the links to the media it carries. - function mediaCaption(ev) { + function mediaCaption(ev, raw = false) { let s = isVideoEvent(ev) ? videoEventText(ev) : String(ev.content || ''); s = s.replace(new RegExp(MD_LINK, 'gi'), (m) => (MD_PARTS.exec(m)?.[1] ? '' : m)); for (const u of [...noteMediaUrls(s), ...noteVideoUrls(s)]) s = s.split(u).join(''); - return plainExcerpt(s); + return raw ? s.replace(/\s+/g, ' ').trim() : plainExcerpt(s); + } + // The pager's caption: the post's words with their names, tags and links + // live (a plain excerpt left "@NostrValley" dead — Adam, 2026-10-16). A + // tap on a name, a tag or a note reference leaves the pager, and the full + // screen with it, before going where it goes; a link to the web opens in + // its own tab and the pager stays. A tap anywhere else on the caption + // leaves the pager and opens the post ("clicking anywhere on the caption + // that's not a link should take me out of fullscreen and take me to the + // post"), where the whole of a long caption reads. + function captionNode(ev, text) { + const el = h('div', { class: 'mp-caption' }, ...noteBody(text, 2, noteEmojiTags(ev))); + el.addEventListener('click', (e) => { + const a = e.target && e.target.closest ? e.target.closest('a') : null; + if (a && /^https?:/i.test(a.getAttribute('href') || '')) { e.stopPropagation(); return; } + leavePager(); + if (a) return; // the link's own handler opens the name, the tag, the note + e.stopPropagation(); e.preventDefault(); + openNoteThread(ev); + }, true); + return el; } function setMediaMode(m) { mediaMode = m; @@ -12549,9 +12569,21 @@ export function messagesFeature(ctx) { // and the flight stuttered (Adam, 2026-10-16: "a bit of choppiness as // they scroll"). A copy that fails falls back to the original. const quick = !video && !/\.gif(\?|$)/i.test(url) ? (mediaReady.get(url) || {}).thumb || serverPic(url, 'col') || url : url; + // A clip that will play by itself has no poster: every poster there is + // (the kept frame, a post's imeta picture, the server's) is 480px wide + // and taken at some moment of the clip, so it showed for a beat, soft + // and not quite the first frame, and the clip's own first frame + // replaced it — a flash on every flick (Adam, 2026-10-16: "if we're + // auto playing let's not show the thumbnail let's just start on the + // first frame of the video"). The slide is the pager's black until the + // player, whose opening is in the cache by then (headStart), has its + // first frame. (Giving the next clip's player its file ahead of time + // was tried: it pulled the whole file down.) On a connection to be + // careful with the poster stays: the clip may never load (a heavy one + // waits for a tap), and the still is what there is. const media = video ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', - poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) }) + poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined }) : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async', onError: (e) => { const m = e.currentTarget; @@ -12562,7 +12594,7 @@ export function messagesFeature(ctx) { m.closest('.mp-slide').classList.add('mp-gone'); m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null)); } }); - const caption = mediaCaption(ev); + const caption = mediaCaption(ev, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, media, @@ -12570,7 +12602,7 @@ export function messagesFeature(ctx) { h('div', { class: 'mp-left' }, h('button', { class: 'mp-author', type: 'button', onClick: stop(() => { leavePager(); openProfile(ev.pubkey); }) }, avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))), - caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null), + caption ? captionNode(ev, caption) : null), h('div', { class: 'mp-counts' }, // as in the feed: a tap picks how you feel about it (the emoji // sheet, over the pager), a tap again takes it back @@ -12777,12 +12809,12 @@ export function messagesFeature(ctx) { } const headStarted = new Set(); function headStart(url, share = 1) { - if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return; + if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return Promise.resolve(); headStarted.add(url); if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value); const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); - fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { + return fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { const reader = r.ok && r.body ? r.body.getReader() : null; if (!reader) return; let got = 0; @@ -12809,11 +12841,11 @@ export function messagesFeature(ctx) { // first frame itself (only the start of the file, when it leads with // its index or is served in ranges) — again on every return, since // a slide left behind lets go of its file - slidePoster(slide); + slidePoster(slide, true); if (videoThumbs.has(url)) pagerRelease(slide); else grabFrame(url).then((kept) => { if (!pager || !slide.isConnected) return; - if (kept) { slidePoster(slide); return; } + if (kept) { slidePoster(slide, true); return; } // no frame to keep (the host's bytes can't be read, or the index // trails): the player shows its own first frame under the cover const d = videoInfo.get(url) || {}; @@ -12835,8 +12867,10 @@ export function messagesFeature(ctx) { }); } // the kept frame as the poster, looked up as the slide comes near (it may - // have been kept after the slide was built) - function slidePoster(slide) { + // have been kept after the slide was built) — only where a poster is + // wanted: a careful connection, or a heavy clip under its cover (force) + function slidePoster(slide, force = false) { + if (!force && !frugalData()) return; const v = slide.querySelector('video.mp-media'); const thumb = v && videoThumbs.get(v.getAttribute('data-src')); if (thumb && v.getAttribute('poster') !== thumb) v.setAttribute('poster', thumb); -- cgit v1.2.3