diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 16:33:09 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 16:33:09 -0700 |
| commit | 6dfb59316865093eda01307ee3c95f87511cdf56 (patch) | |
| tree | bfe60d7eebe74eb00324b92af690942fa061038a | |
| parent | 738bace91bcd17c0ab8ba920fe2f0765f6504fc8 (diff) | |
| download | coinosv3-6dfb59316865093eda01307ee3c95f87511cdf56.tar.gz coinosv3-6dfb59316865093eda01307ee3c95f87511cdf56.zip | |
Feed videos before they play: the picture fills the box with only the play button and size (no seek bar or sound button until playing); hovering the cover no longer paints it over with the app's button grey; upright phone clips (stored sideways with a quarter-turn matrix) paint upright, v1 size caches dropped; a clip the probe found HEVC switches to the copy before fetching the original
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | src/features/messages.js | 39 | ||||
| -rw-r--r-- | src/style.css | 6 | ||||
| -rw-r--r-- | tools/feed-media-cache-test.js | 4 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 4 | ||||
| -rw-r--r-- | tools/feed-video-aspect-test.js | 7 | ||||
| -rw-r--r-- | tools/video-fallback-test.js | 13 |
6 files changed, 61 insertions, 12 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 12d928e..d4eec34 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5757,11 +5757,13 @@ export function messagesFeature(ctx) { // on disk so a clip seen once is never probed again. const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i; const AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)(\?[^\s]*)?$/i; - const VIDEO_PROBE_MS = 3000, VIDEO_DIMS_KEEP = 400, VIDEO_DIMS_KEY = 'coinos-video-dims'; + const VIDEO_PROBE_MS = 3000, VIDEO_DIMS_KEEP = 400, VIDEO_DIMS_KEY = 'coinos-video-dims-v2'; // v2: turned clips stand upright const videoDims = new Map(); // url -> { width, height } const videoUnknown = new Set(); // probed this session and no answer: paint 16:9, don't ask again const loadVideoDims = () => mergeStored(videoDims, VIDEO_DIMS_KEY); loadVideoDims(); + // the v1 caches held upright phone clips as landscape + onCacheReady(() => { for (const k of ['coinos-video-dims', 'coinos-video-info']) try { cacheStorage.removeItem(k); } catch {} }); let videoDimsFlush = null; const rememberVideo = (url, width, height) => { if (!(width > 0 && height > 0)) return; @@ -5791,7 +5793,7 @@ export function messagesFeature(ctx) { // the server won't serve ranges. Its track header gives the size on screen // too. Clips over the budget, above 1080p, or that we couldn't measure // wait for a tap — unless the reader asked for large clips to play. - const VIDEO_INFO_KEY = 'coinos-video-info', VIDEO_INFO_KEEP = 2000, VIDEO_PROBE_BYTES = 65536; + const VIDEO_INFO_KEY = 'coinos-video-info-v2', VIDEO_INFO_KEEP = 2000, VIDEO_PROBE_BYTES = 65536; const VIDEO_LARGE_KEY = 'coinos-video-large'; const videoInfo = new Map(); // url -> { size, w, h, image, faststart, ranges, moovEnd, probed, failedAt } const loadVideoInfo = () => mergeStored(videoInfo, VIDEO_INFO_KEY); @@ -5845,7 +5847,11 @@ export function messagesFeature(ctx) { const end = j - 4 + u32(j - 4); if (end > b.length || end < j + 8) continue; const tw = u32(end - 8) >>> 16, th = u32(end - 4) >>> 16; - if (tw && th) { w = tw; h = th; } + // a phone held upright stores its picture sideways with a quarter-turn + // matrix (the 36 bytes before width/height): a = 0 and b ≠ 0 there + const a = u32(end - 44), b2 = u32(end - 40); + const turned = end - 44 >= j + 8 && a === 0 && b2 !== 0; + if (tw && th) { w = turned ? th : tw; h = turned ? tw : th; } } // an iPhone's HEVC (hvc1/hev1 in the sample description) let hevc = false; @@ -6722,6 +6728,8 @@ export function messagesFeature(ctx) { // window unmounting the row and mounting it again, a quoted note loading. // Pinning the box alone left every one of those a muted restart. const unmutedClips = new Set(); + const wokeClips = new Set(); // played, or tried to: the player's controls show + // A feed box spans the column and takes the clip's own shape when it's // known, never taller than the screen; only a clip taller than that sits // in a black frame, rather than in a strip at the left. @@ -6741,7 +6749,11 @@ export function messagesFeature(ctx) { cover.remove(); onPlay && onPlay(); const v = box.querySelector('video'); - if (v) { if (!v.getAttribute('src')) v.setAttribute('src', playSrc(url)); v.preload = 'auto'; v.play().catch(() => {}); } + if (v) { + if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url)); + v.preload = 'auto'; + v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; }); + } } }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url))); box.append(cover); } @@ -6795,7 +6807,11 @@ export function messagesFeature(ctx) { // Nothing is fetched until the clip is about to play (preload none): a // window of rows used to pull every clip's metadata, which for a file // with its index at the end can be most of the file. - const v = h('video', { src: playSrc(url), class: 'note-video', controls: true, poster: videoThumbs.get(url) || (videoInfo.get(url) || {}).image || serverThumb(url), + // Until it plays, a clip is its picture (filling the box) and at most a + // play button: no seek bar, no sound button. Without autoplay the + // player's own controls are the way in, so they stay. + const idle = autoplayOk() && !wokeClips.has(url); + const v = h('video', { src: playSrc(url), class: 'note-video', controls: idle ? undefined : true, poster: videoThumbs.get(url) || (videoInfo.get(url) || {}).image || serverThumb(url), preload: 'none', playsinline: true, muted: loud ? undefined : true, style: stable ? videoBoxStyle(feedMedia(url)) : undefined, // learned now, used by the next paint of any row with this clip @@ -6803,8 +6819,15 @@ export function messagesFeature(ctx) { onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); guardPicture(v, url); v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted - const box = h('div', { class: 'note-video-box' }, v); + const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v); if (loud) box._skipMorph = true; + // remembered by clip, so a repaint draws the box as it now stands + const wake = () => { + wokeClips.add(url); + box.classList.remove('idle'); + v.controls = true; + }; + v.addEventListener('playing', wake); const btn = loud ? null : soundBtn((button) => { // Morphing copies handlers from a fresh tree onto the live button. // Resolve its player at tap time instead of changing a detached video. @@ -6837,8 +6860,10 @@ export function messagesFeature(ctx) { // a clip over the budget waits for a tap, with what it costs on it probeVideo(url).then(() => { if (!box._inView || !v.isConnected) return; + // known by now not to show here: the copy, before a byte of the original + if (v.paused && v.getAttribute('src') && v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url)); if (videoHeavy(url)) { heavyCover(box, url, () => { v.muted = false; unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); }); return; } - if (v.paused) v.play().then(() => { box._skipMorph = true; }).catch(() => {}); + if (v.paused) v.play().then(() => { box._skipMorph = true; }).catch(wake); }); }, () => { diff --git a/src/style.css b/src/style.css index 7fb4c0a..28990fa 100644 --- a/src/style.css +++ b/src/style.css @@ -1493,7 +1493,13 @@ body::after { width: 56px; height: 56px; border-radius: 50%; background: rgba(255, 255, 255, .2); display: flex; align-items: center; justify-content: center; font-size: 22px; padding-left: 4px; } +/* not the app's button hover (an opaque surface that hid the picture) */ +.vid-heavy:hover { background: rgba(0, 0, 0, .3); } +.vid-heavy:hover .vid-heavy-play { background: rgba(255, 255, 255, .3); } .note-video-box .vid-heavy { min-height: 120px; } +/* before it plays: the picture fills the box, no seek bar or sound button */ +.note-video-box.idle .note-video { object-fit: cover !important; } +.note-video-box.idle .vid-sound { display: none; } /* a clip whose picture this browser can't decode, even as the server's copy: its sound plays, this says why the box is black */ .vid-nopic { diff --git a/tools/feed-media-cache-test.js b/tools/feed-media-cache-test.js index 02850ef..eee88c5 100644 --- a/tools/feed-media-cache-test.js +++ b/tools/feed-media-cache-test.js @@ -149,8 +149,8 @@ try { && steps.every((u, i) => !i || u < steps[i - 1]), JSON.stringify(steps)); check('tile frames are kept as pictures (all but the no-CORS host\'s)', await waitFor(async () => (await (await caches.open('coinos-video-thumbs-v1')).keys()).length === 5, 12000), String(await page.evaluate(async () => (await (await caches.open('coinos-video-thumbs-v1')).keys()).map((r) => decodeURIComponent(r.url).split('/').pop()).join(',')))); - await waitFor(() => /h4\.mp4[^\]]*probed|probed[^\]]*h4/.test((window.coinosCache || localStorage).getItem('coinos-video-info') || '') && /n5\.mp4[^\]]*failedAt/.test((window.coinosCache || localStorage).getItem('coinos-video-info') || ''), 8000); // the write-behind - const info = await page.evaluate(() => Object.fromEntries(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info') || '[]').map(([u, d]) => [u.split('/').pop(), d]))); + await waitFor(() => /h4\.mp4[^\]]*probed|probed[^\]]*h4/.test((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '') && /n5\.mp4[^\]]*failedAt/.test((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || ''), 8000); // the write-behind + const info = await page.evaluate(() => Object.fromEntries(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]').map(([u, d]) => [u.split('/').pop(), d]))); check('a host hiding Content-Range gives its size to a HEAD', info['h4.mp4']?.probed && info['h4.mp4'].size > 1000 && info['h4.mp4'].size !== 12000, JSON.stringify(info['h4.mp4'])); check('a clip the browser cached from a plain load is still measured, and its frame kept', info['r6.mp4']?.probed === true && await page.evaluate(async () => (await (await caches.open('coinos-video-thumbs-v1')).keys()).some((r) => /r6\.mp4/.test(decodeURIComponent(r.url)))), JSON.stringify(info['r6.mp4'])); diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index 4890981..2f9d627 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -162,7 +162,7 @@ try { check('it waits under a cover with its size', await waitFor(() => [...document.querySelectorAll('.note-video-box .vid-heavy')].some((b) => /50 MB/.test(b.textContent)), 8000)); check('feed clips fetch nothing until they play', await page.evaluate(() => [...document.querySelectorAll('.note-video-box video')].every((v) => v.preload === 'none' || !v.paused))); console.log('\n[Videos: flip through]'); - const info = () => page.evaluate(() => Object.fromEntries(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info') || '[]').map(([u, d]) => [u.split('/').pop(), d]))); + const info = () => page.evaluate(() => Object.fromEntries(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]').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 shows a grid of the vertical clips, not the pager yet', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 8000) @@ -181,7 +181,7 @@ try { let ss = await srcs(); check('only the clip on screen loads in full; the next gets its opening; the rest nothing', ss[0].src && ss[0].pre === 'auto' && ss[1].src && ss[1].pre === 'metadata' && !ss[2].src, JSON.stringify(ss)); - await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info') || '[]')).length >= 3, 6000); + await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]')).length >= 3, 6000); await sleep(1500); const vi = await info(); check('a probe reads the size, ranges, layout and picture size from 64 KB', vi['a.mp4']?.probed && vi['a.mp4'].ranges && vi['a.mp4'].faststart diff --git a/tools/feed-video-aspect-test.js b/tools/feed-video-aspect-test.js index d052b33..84c0568 100644 --- a/tools/feed-video-aspect-test.js +++ b/tools/feed-video-aspect-test.js @@ -20,6 +20,9 @@ const clip = (name, size) => { if (p.exitCode !== 0) throw new Error('ffmpeg failed'); }; clip('tall.mp4', '360x480'); clip('wide.mp4', '640x360'); clip('told.mp4', '360x640'); +// a phone held upright: the picture stored sideways, with a quarter-turn matrix +clip('side.mp4', '640x352'); +if (Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-i', `${dir}/side.mp4`, '-c', 'copy', '-metadata:s:v:0', 'rotate=90', '-movflags', '+faststart', `${dir}/turned.mp4`]).exitCode !== 0) throw new Error('ffmpeg failed'); // photos: a camera-sized one (painted from a column-sized copy) and a small one const photo = (name, size) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', `testsrc=size=${size}`, '-frames:v', '1', `${dir}/${name}`]); @@ -47,6 +50,7 @@ const notes = [ post(1, 'wide clip ' + base + '/v/wide.mp4'), // imeta says 9:16; the file is too, but the probe must not be needed post(2, 'told clip ' + base + '/v/told.mp4', [['imeta', 'url ' + base + '/v/told.mp4', 'm video/mp4', 'dim 1080x1920']]), + post(6, 'turned clip ' + base + '/v/turned.mp4'), post(3, 'broken clip ' + base + '/broken.mp4'), post(4, 'big photo ' + base + '/i/big.jpg'), post(5, 'small photo ' + base + '/i/small.jpg'), @@ -114,6 +118,7 @@ try { check('a 16:9 clip stays 16:9', Math.abs(ratio(b['wide.mp4']) - 1.78) < 0.02 && /aspect-ratio:640\/360/.test(b['wide.mp4'].style || ''), JSON.stringify(b['wide.mp4'])); check('a clip whose post gives imeta dim paints at that shape, without waiting on the file', Math.abs(ratio(b['told.mp4']) - 0.56) < 0.02 && /aspect-ratio:1080\/1920/.test(b['told.mp4'].style || ''), JSON.stringify(b['told.mp4'])); check('...full width, no taller than the screen', b['told.mp4'] && b['told.mp4'].w > 300 && b['told.mp4'].h <= 845, JSON.stringify(b['told.mp4'])); + check('an upright phone clip, stored sideways, paints upright', /aspect-ratio:352\/640/.test(b['turned.mp4']?.style || ''), JSON.stringify(b['turned.mp4'])); check('a clip that will not load keeps the 16:9 box', /aspect-ratio:16\/9/.test(b['broken.mp4']?.style || ''), JSON.stringify(b['broken.mp4'])); // a camera-sized photo paints from a column-sized copy, decoded with the page const imgs = await page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.notes-feed img.note-img')].map((i) => [ @@ -123,7 +128,7 @@ try { check('a small photo is its own original', imgs.small && imgs.small.src === 'http:' && imgs.small.nw === 800, JSON.stringify(imgs.small)); check('the copy is kept on disk for the next visit', await page.evaluate(async () => !!(await (await caches.open('coinos-media-thumbs-v1')).match(location.origin + '/i/big.jpg')))); await sleep(1500); - check('shapes are remembered on the device', await page.evaluate(() => (JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-dims') || '[]')).length >= 3)); + check('shapes are remembered on the device', await page.evaluate(() => (JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-dims-v2') || '[]')).length >= 3)); check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300)); } catch (e) { console.log('✗ threw', e.stack); ok = false; diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js index 81c22d3..66becc2 100644 --- a/tools/video-fallback-test.js +++ b/tools/video-fallback-test.js @@ -95,10 +95,23 @@ try { const p0 = await player('garden'); check('the server\'s still is the poster', p0.poster === 'https://v3.coinos.io/vmedia/thumb.jpg?u=' + encodeURIComponent(CLIP.saved), p0.poster); + console.log('\n[waiting for a tap]'); + await page.evaluate(new Function(`${box('garden')}.scrollIntoView({ block: 'center' })`)); + await page.waitForFunction(new Function(`return !!${box('garden')}?.querySelector('.vid-heavy')`), { timeout: 10000 }); + const idle = await page.evaluate(new Function(`const b = ${box('garden')}, v = b.querySelector('video'), s = b.querySelector('.vid-sound'); + return { controls: v.controls, sound: s ? getComputedStyle(s).display : 'gone', fit: getComputedStyle(v).objectFit, label: b.querySelector('.vid-heavy').textContent };`)); + check('only the play button and the cost show: no seek bar, no sound button, the picture fills the box', !idle.controls && idle.sound === 'none' && idle.fit === 'cover' && idle.label.length > 1, JSON.stringify(idle)); + const at = await page.evaluate(new Function(`const r = ${box('garden')}.getBoundingClientRect(); return { x: r.x + r.width * 0.3, y: r.y + r.height * 0.3 };`)); + await page.mouse.move(at.x, at.y); await sleep(300); + const bg = await page.evaluate(new Function(`return getComputedStyle(${box('garden')}.querySelector('.vid-heavy')).backgroundColor`)); + check('hovering the cover leaves the picture showing through', /rgba\(0, 0, 0, 0?\.\d+\)/.test(bg), bg); + await page.mouse.move(0, 0); + console.log('\n[found out by playing]'); await show('garden'); check('the clip switches to the H.264 copy and shows its picture', await waitFor(new Function(`const v = ${box('garden')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/h264.mp4') && v.videoWidth > 0;`), null, 10000), JSON.stringify(await player('garden'))); + check('playing, the player\'s own controls are back', await page.evaluate(new Function(`const b = ${box('garden')}; return b.querySelector('video').controls && !b.classList.contains('idle')`))); check('the original was tried first', asked.some(([k, u]) => k === 'media' && u === CLIP.saved)); console.log('\n[the copy fails too]'); |
