diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 75 |
1 files changed, 72 insertions, 3 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 2bc034f..44450ea 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12065,6 +12065,68 @@ export function messagesFeature(ctx) { pumpFrames(); return task; } + // ---- the pager's first-frame posters ------------------------------------- + // The clip ahead holds its own first frame — full size, frame 0, drawn + // from the opening of the file (the bytes headStart put in the cache, + // read again by range) — so a flick to it glides onto the very picture + // the player will show first, and the player's frame replaces it with no + // visible change. (Adam, 2026-10-16: "could we actually have the next + // image or video scroll in while the previous scrolls out? Right now it + // goes black before the next one scrolls in.") A dozen are kept, the + // oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the + // same decode when the tile has none yet. + const PAGER_POSTERS = 12, POSTER_W = 1100; + const pagerPosters = new Map(); // url -> object URL, oldest first + const posterGrabs = new Map(); // url -> promise + function pagerPoster(url) { + if (pagerPosters.has(url)) { const p = pagerPosters.get(url); pagerPosters.delete(url); pagerPosters.set(url, p); return Promise.resolve(p); } + if (posterGrabs.has(url)) return posterGrabs.get(url); + const task = probeVideo(url).then(() => grabPoster(url)).catch(() => null).finally(() => posterGrabs.delete(url)); + posterGrabs.set(url, task); + return task; + } + async function grabPoster(url) { + const d = videoInfo.get(url) || {}; + if (!d.probed || !d.faststart || !d.ranges || frameFailed.has(url) || (d.moovEnd || 0) + FRAME_BYTES_AFTER > FRAME_BYTES_MAX) return null; + // no more of the file than the head start fetched: the bytes come from the cache + const last = Math.min((d.moovEnd || 200_000) + FRAME_BYTES_AFTER, Math.max(HEAD_MIN, headStartBytes(url))) - 1; + let r; + try { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, signal: AbortSignal.timeout(20_000) }); } + catch { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, cache: 'no-store', signal: AbortSignal.timeout(20_000) }); } // a cached copy without CORS headers (Primal's bucket) + if (r.status !== 206 && !(r.ok && d.size && d.size <= last + 1)) { try { r.body.cancel(); } catch {} return null; } + const blob = new Blob([await r.arrayBuffer()], { type: (r.headers.get('content-type') || '').startsWith('video/') ? r.headers.get('content-type') : 'video/mp4' }); + const src = URL.createObjectURL(blob); + const v = document.createElement('video'); + v.muted = true; v.playsInline = true; v.preload = 'auto'; + try { + const shown = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(v.readyState >= 2), 6000); + v.onloadeddata = () => { clearTimeout(timer); resolve(true); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = src; + }); + if (!shown || !v.videoWidth) return null; + const w = Math.min(POSTER_W, v.videoWidth), hh = Math.round(w * v.videoHeight / v.videoWidth); + const cv = document.createElement('canvas'); + cv.width = w; cv.height = hh; + cv.getContext('2d').drawImage(v, 0, 0, w, hh); + const out = await new Promise((resolve) => cv.toBlob(resolve, 'image/jpeg', 0.9)); + if (!out) return null; + const p = URL.createObjectURL(out); + pagerPosters.set(url, p); + while (pagerPosters.size > PAGER_POSTERS) { const k = pagerPosters.keys().next().value; try { URL.revokeObjectURL(pagerPosters.get(k)); } catch {} pagerPosters.delete(k); } + // the tile's frame too, from the same decode, when it has none + if (!videoThumbs.has(url)) { + await new Promise((resolve) => { const t = setTimeout(resolve, 3000); v.onseeked = () => { clearTimeout(t); resolve(); }; try { v.currentTime = 0.1; } catch { clearTimeout(t); resolve(); } }); + await keepVideoThumb(v, url).catch(() => {}); + } + return p; + } finally { + v.onloadeddata = v.onseeked = v.onerror = null; + try { v.removeAttribute('src'); v.load(); } catch {} + URL.revokeObjectURL(src); + } + } function pumpFrames() { while (frameGrabbing < 2 && frameQueue.length) { const { url, resolve } = frameQueue.shift(); @@ -12761,7 +12823,10 @@ export function messagesFeature(ctx) { const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { pagerRelease(s); - headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + const src = playSrc(v.getAttribute('data-src')); + headStart(src, k === 0 ? 1 : 0.5); + // the next clip holds its first frame: the flick glides onto it + if (k === 0 && !v.getAttribute('poster')) pagerPoster(src).then((p) => { if (p && pager && s.isConnected && !v.getAttribute('poster')) v.setAttribute('poster', p); }); return; } if (k === 0) { @@ -13237,9 +13302,13 @@ export function messagesFeature(ctx) { // pictures are cheap — a post's all at once, the ones along its row // too (out of sight sideways, they'd load only on arrival); clips // are pagerNeighbours' call - if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x); + if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) { + pagerAttach(x); + const im = x.querySelector('img.mp-media'); // decoded before the flick lands: it glides in as a picture, not a blank + if (im && im.decode) im.decode().catch(() => {}); + } } - }, { root: scroller, rootMargin: '150% 0px' }); + }, { root: scroller, rootMargin: '250% 0px' }); // a slide has come to rest on screen (or a flight has landed on it) pager.arrive = (slide) => { if (!pager) return; |
