aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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 /src/features
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]>
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js39
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);
});
},
() => {