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 /src/features/messages.js | |
| 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]>
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 39 |
1 files changed, 32 insertions, 7 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); }); }, () => { |
