From 09ce0c7f213802757b13501898c02cfe309bfbf8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 3 Oct 2026 14:49:57 -0700 Subject: Videos opens on a grid of thumbnails; the pager waits for a tap Choosing Videos no longer jumps into the full-screen pager: it shows the vertical clips as a grid, like Images, and a tile opens the pager on that clip. Every tile gets a first frame when that costs only the start of the file (the index leads, or the server serves ranges), whatever the clip's size; a clip over budget wears its cost as a badge. Only an index-last file from a server without ranges shows its cost instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ULQZS9MQa6iAsXnrJUD9Du --- src/features/messages.js | 36 +++++++++++++++++------------------- src/style.css | 4 ++++ 2 files changed, 21 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 9a2179b..c9497e9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5543,10 +5543,11 @@ export function messagesFeature(ctx) { const videoProbes = new Map(); let videoProbing = 0; const videoProbeQueue = []; - function probeVideo(url) { + function probeVideo(url, layout = false) { const have = videoInfo.get(url); - // a post that states size and shape is taken at its word: no request at all - if (have && (have.probed || have.failed || (have.size && have.w))) return Promise.resolve(have); + // a post that states size and shape is taken at its word: no request at + // all — unless the file's layout is wanted (a thumbnail) + if (have && (have.probed || have.failed || (!layout && have.size && have.w))) return Promise.resolve(have); if (videoProbes.has(url)) return videoProbes.get(url); const task = new Promise((resolve) => videoProbeQueue.push({ url, resolve })).finally(() => videoProbes.delete(url)); videoProbes.set(url, task); @@ -9564,7 +9565,6 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - let videosAutoOpen = false; try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos') mediaMode = m; } catch {} const mediaKey = (it) => it.ev.id + '|' + it.url; // the picture an imeta tag names for a clip (NIP-92 `image`), if any @@ -9622,12 +9622,6 @@ export function messagesFeature(ctx) { function setMediaMode(m) { mediaMode = m; try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {} - videosAutoOpen = false; - if (m === 'videos') { - const items = mediaItems(feedNow(), 'videos'); - if (items.length) { openMediaPager('videos', mediaKey(items[0])); return; } - videosAutoOpen = true; // the first vertical clip found opens the pager - } render(); try { window.scrollTo({ top: 0 }); } catch {} } @@ -9676,13 +9670,21 @@ export function messagesFeature(ctx) { tileObs.unobserve(v); const tile = v.closest('.media-tile'); if (tile) tile._skipMorph = true; - // a first frame only from a clip that starts quickly and is within - // budget: a player asked for a still of an index-last file fetches - // most of it; the rest show what they'd cost instead + // A first frame costs the start of the file when the index leads or + // the server serves ranges — whatever the clip's size. Only an + // index-last file without ranges would have to come down whole for + // it; that one shows what it costs instead. A clip over budget wears + // its cost as a badge either way. const url = v.getAttribute('data-src').replace(/#t=[\d.]+$/, ''); - probeVideo(url).then((d) => { + probeVideo(url, true).then((d) => { if (!v.isConnected) return; - if (d && d.faststart !== false && (d.faststart || d.size) && !videoHeavy(url)) { v.muted = true; v.src = v.getAttribute('data-src'); return; } + const heavy = videoHeavy(url); + const still = d && (d.probed ? d.faststart || d.ranges : !heavy); + if (still) { + v.muted = true; v.src = v.getAttribute('data-src'); + if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); + return; + } v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : '')); }); } @@ -9922,10 +9924,6 @@ export function messagesFeature(ctx) { const def = feedDef(); const visitor = isVisitor(); queueMicrotask(() => { - if (videosAutoOpen && mediaMode === 'videos' && !ui.mediaPager) { - const items = mediaItems(feedNow(), 'videos'); - if (items.length) { videosAutoOpen = false; openMediaPager('videos', mediaKey(items[0])); return; } - } syncMediaPager(); if (mediaMode !== 'all') watchMediaTiles(); }); diff --git a/src/style.css b/src/style.css index 2f89590..be15a80 100644 --- a/src/style.css +++ b/src/style.css @@ -1460,3 +1460,7 @@ body::after { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 12px; font-weight: 600; background: var(--surface2); } +.media-tile-badge { + position: absolute; left: 6px; bottom: 6px; padding: 1px 6px; border-radius: 6px; + background: rgba(0, 0, 0, .6); color: #fff; font-size: 11px; font-weight: 600; pointer-events: none; +} -- cgit v1.2.3