diff options
| -rw-r--r-- | src/features/messages.js | 36 | ||||
| -rw-r--r-- | src/style.css | 4 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 19 |
3 files changed, 35 insertions, 24 deletions
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; +} diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index 09b0abe..0811520 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -29,7 +29,9 @@ const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => { const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || ''); if (m && path !== '/v/slow.mp4') { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); - return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); + // c.mp4 stands in for a 50 MB file: its server says so + const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file) + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } }); } return new Response(f, { headers: { 'content-type': 'video/mp4' } }); } @@ -163,7 +165,14 @@ try { const info = () => page.evaluate(() => Object.fromEntries(JSON.parse(localStorage.getItem('coinos-video-info') || '[]').map(([u, d]) => [u.split('/').pop(), d]))); const srcs = () => page.evaluate(() => [...document.querySelectorAll('.media-pager video')].map((v) => ({ src: !!v.getAttribute('src'), pre: v.preload }))); await mode('videos'); - check('Videos opens straight into the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), 4000)); + check('Videos shows a grid of the vertical clips, not the pager yet', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 8000) + && await page.evaluate(() => !document.querySelector('.media-pager'))); + check('every tile gets a thumbnail — the 50 MB one too, with its cost as a badge', await waitFor(() => + [...document.querySelectorAll('.media-tile video')].filter((v) => v.getAttribute('src')).length === 3 + && [...document.querySelectorAll('.media-tile-badge')].some((x) => /50 MB/.test(x.textContent)), 8000)); + await shot('video-grid'); + await page.evaluate(() => document.querySelector('.media-grid .media-tile.video').click()); + check('the first tile opens the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), 4000)); check('...the first clip playing', await waitFor(() => { const v = document.querySelector('.media-pager video'); return v && !v.paused && v.currentTime > 0; }, 8000), JSON.stringify(await pagerState())); st = await pagerState(); check('...the three vertical clips, nothing over them, sound on', st.n === 3 && st.src === 'a.mp4' && !st.show && st.meta === '0' @@ -179,7 +188,7 @@ try { && vi['a.mp4'].size > 1000 && vi['a.mp4'].w === 360 && vi['a.mp4'].h === 640, JSON.stringify(vi['a.mp4'])); check('the landscape clip is left out of Videos', !(await page.evaluate(() => [...document.querySelectorAll('.media-pager video')].some((v) => /slow/.test(v.getAttribute('data-src')))))); check('an index-last file from a server without ranges is seen for what it is', vi['slow.mp4']?.probed && vi['slow.mp4'].faststart === false && vi['slow.mp4'].ranges === false, JSON.stringify(vi['slow.mp4'])); - check('the size a post states is believed', vi['c.mp4']?.size === 50000000, JSON.stringify(vi['c.mp4'])); + check('the 50 MB clip is known as such before any of it plays', vi['c.mp4']?.size === 50000000, JSON.stringify(vi['c.mp4'])); await page.keyboard.press('ArrowDown'); await sleep(1200); st = await pagerState(); check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st)); @@ -197,12 +206,12 @@ try { await page.keyboard.press('Escape'); await sleep(600); check('Escape closes it, leaving a grid of the clips', await page.evaluate(() => !document.querySelector('.media-pager') && document.querySelectorAll('.media-grid .media-tile.video').length === 3)); check('no clip goes on playing behind', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused))); - check('the big clip\'s tile shows its cost, not a frame', await waitFor(() => [...document.querySelectorAll('.media-tile-cost')].some((x) => /50 MB/.test(x.textContent)), 6000)); - check('...the small ones show their first frame', await waitFor(() => [...document.querySelectorAll('.media-tile video')].filter((v) => v.getAttribute('src')).length >= 2, 6000)); console.log('\n[the setting]'); await page.evaluate(() => localStorage.setItem('coinos-video-large', '1')); await mode('videos'); + await waitFor(() => !!document.querySelector('.media-grid .media-tile.video'), 4000); + await page.evaluate(() => document.querySelector('.media-grid .media-tile.video').click()); await waitFor(() => !!document.querySelector('.media-pager.videos'), 4000); await page.keyboard.press('ArrowDown'); await sleep(900); await page.keyboard.press('ArrowDown'); await sleep(1500); check('with "Autoplay large videos" on, it plays by itself', await page.evaluate(() => { const s = document.querySelectorAll('.mp-slide')[2]; const v = s.querySelector('video'); return !s.querySelector('.vid-heavy') && !v.paused; })); |
