aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js36
-rw-r--r--src/style.css4
-rw-r--r--tools/feed-media-views-test.js19
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; }));