aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 16:33:09 -0700
committerAdam Soltys <[email protected]>2026-10-07 16:33:09 -0700
commit6dfb59316865093eda01307ee3c95f87511cdf56 (patch)
treebfe60d7eebe74eb00324b92af690942fa061038a
parent738bace91bcd17c0ab8ba920fe2f0765f6504fc8 (diff)
downloadcoinosv3-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.js39
-rw-r--r--src/style.css6
-rw-r--r--tools/feed-media-cache-test.js4
-rw-r--r--tools/feed-media-views-test.js4
-rw-r--r--tools/feed-video-aspect-test.js7
-rw-r--r--tools/video-fallback-test.js13
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]');