diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 136 |
1 files changed, 119 insertions, 17 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index ce578e4..3789f93 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12169,11 +12169,41 @@ export function messagesFeature(ctx) { // stand taller than they're wide. A clip of unknown shape is measured // (the header probe, or a player when the index sits at the end of a // file a server serves in ranges) and joins once it's known to qualify. - function verticalClip(url) { + // ---- which clips fit the screen ----------------------------------------- + // The Videos view and the pager show the clips the screen's way round: tall + // ones on a phone held upright, wide ones held sideways (or on a desktop + // window), a square either way — unless the reader asks for all shapes + // (remembered on this device). (Adam, 2026-10-16: "only show vertical + // videos by default on a phone that's in vertical orientation and only + // horizontal videos if the phone is in landscape but have some kind of + // control to toggle the ability to see all videos".) + const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round + const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } }; + const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} }; + const screenTall = () => window.innerHeight >= window.innerWidth; + const clipShape = (url) => { const d = videoInfo.get(url) || {}; const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url); const w = k && (k.w || k.width), hh = k && (k.h || k.height); - if (w && hh) return hh > w; + return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null; + }; + // true/false, or null while the clip's shape isn't known + const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); }; + const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide'); + // the toggle: in the pager's top bar and over the Videos grid + function clipsToggle(cls, after) { + return h('button', { class: cls, type: 'button', title: t('clipsToggleTitle'), onClick: (e) => { + e.stopPropagation(); + setClipsAll(!clipsAll()); + for (const b of document.querySelectorAll('.mp-fit, .media-fit-btn')) b.textContent = clipsLabel(); + if (after) after(); + if (mediaMode === 'videos') scheduleRepaint(); + } }, clipsLabel()); + } + function fitClip(url) { + const f = clipFits(url); + if (f !== null) return f; + const d = videoInfo.get(url) || {}; if (!probeFailedLately(d) && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url); return false; } @@ -12196,7 +12226,7 @@ export function messagesFeature(ctx) { // a grid shows the picture itself — no warning to fold it behind if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; if (kind === 'videos') learnVideoMeta(ev); - const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip) + const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip) : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); for (const url of urls) { const it = { ev, url }; @@ -12408,6 +12438,7 @@ export function messagesFeature(ctx) { if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); } return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, + mediaMode === 'videos' ? h('div', { class: 'media-fit', 'data-key': 'media-fit' }, clipsToggle('media-fit-btn')) : null, ...items.map((it) => { const k = mediaKey(it); // a video tile shows a still at once: kept, given, or the server's @@ -12754,7 +12785,11 @@ export function messagesFeature(ctx) { // post), each a row of its pictures or clips — its quotes' too — side by // side (left and right go within it). Its slides, in reading order: const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []); - const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []); + // the posts in the carousel: those on offer (a clip's post waits out of it + // until it is ready to play, and a clip the wrong way round for the screen + // is kept out — see pagerReveal, pagerFit), or every one built (all) + const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)'; + const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []); // the slide a post's row rests on const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; }; // bring a slide to the screen: its post, and within it, the slide @@ -12768,13 +12803,17 @@ export function messagesFeature(ctx) { // along (the next in this post), and the post after next function pagerAhead(slide) { const out = []; - const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; }; + // (a post kept out for its shape is passed over: nothing to ready there) + const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.nextElementSibling; return n; }; const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1); const down = g1 && rowSlide(g1); if (down) out.push(down); if (slide.nextElementSibling) out.push(slide.nextElementSibling); const down2 = g2 && rowSlide(g2); if (down2) out.push(down2); + const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; }; + const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up); + if (back) out.push(back); // (the one before: ready again for the swipe back) return out; } function pagerAttach(slide) { @@ -12823,7 +12862,7 @@ export function messagesFeature(ctx) { const k = ahead.indexOf(s); if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s); // the slides around the reader (and the one landed on) hold their clip's first frame - if (eager && (Math.abs(j - i) <= 3 || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k); + if (eager && ((j > i && j - i <= 5) || (j < i && i - j <= 3) || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k); if (j === i) return; const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { @@ -12865,6 +12904,7 @@ export function messagesFeature(ctx) { if (!p || !pager || !s.isConnected || v._frame === 'first') return false; if (kind === 'soft' && (v._frame || pager.cur === s)) return false; // never a soft still under the clip on screen v.setAttribute('poster', p); v._frame = kind; + pagerReveal(); return true; }; const soft = () => put(videoThumbs.get(url) || posterOf(s._ev, url), 'soft'); @@ -12873,7 +12913,11 @@ export function messagesFeature(ctx) { v._frameBusy = true; const im = new Image(); im.decoding = 'async'; - const ok = () => { v._frameBusy = false; put(first, 'first'); }; + const ok = () => { + v._frameBusy = false; + if (im.naturalWidth && im.naturalHeight && !clipShape(url)) { rememberVideo(url, im.naturalWidth, im.naturalHeight); pagerFit(); } // its shape, from the frame + put(first, 'first'); + }; im.onload = () => (im.decode ? im.decode().then(ok, ok) : ok()); im.onerror = () => { v._frameBusy = false; serverFirstFailed.add(url); slideFrame(s, k); }; im.src = first; @@ -12919,14 +12963,14 @@ export function messagesFeature(ctx) { if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size; return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity)); } - const headStarted = new Set(); + const headStarted = new Map(); // url -> its promise + const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read) function headStart(url, share = 1) { - if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return Promise.resolve(); - headStarted.add(url); - if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value); + if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); + if (headStarted.has(url)) return headStarted.get(url); const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); - return fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { + const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { const reader = r.ok && r.body ? r.body.getReader() : null; if (!reader) return; let got = 0; @@ -12938,7 +12982,10 @@ export function messagesFeature(ctx) { } const secs = (performance.now() - t0) / 1000; if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; } - }).catch(() => {}); + }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); } + return task; } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); @@ -13163,8 +13210,10 @@ export function messagesFeature(ctx) { h('div', { class: 'mp-position', role: 'status' }, '0 / 0'), h('div', { class: 'mp-top' }, h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), - video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, - h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null), + video ? h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => pagerFit()), + h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, + h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute')))) : null), // a mouse's way through (shown where there is a pointer to hover with, // style.css): up and down the posts at the right edge, left and right // along a post's pictures when it has more than one (has-left/right) @@ -13264,7 +13313,7 @@ export function messagesFeature(ctx) { // let go of a drag: on to the next (or the one before), or back function settle(d, step, vel = 0) { const box = d.box, y = d.axis === 'y'; - const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y)); + const stops = [...box.children].filter((c) => c.matches(y ? PAGER_ON : '.mp-slide')).map((c) => pagerAt(box, c, y)); if (!stops.length) return; // the stop it set off from, then one along (or back) — or, flung, several const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0); @@ -13413,6 +13462,7 @@ export function messagesFeature(ctx) { if (bar) bar._paint(); const r = p.cur && p.cur.getBoundingClientRect(); p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2)); + if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); } p.raf = requestAnimationFrame(frame); }; p.raf = requestAnimationFrame(frame); @@ -13430,6 +13480,8 @@ export function messagesFeature(ctx) { if (!pager) return; pager.screenH = pager.scroller.clientHeight; if (pager.anchor) pagerShow(pager.anchor); + for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel(); + pagerFit(); }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move @@ -13459,7 +13511,7 @@ export function messagesFeature(ctx) { async function pagerLoadAhead() { const p = pager; if (!p) return; - const groups = pagerGroups(); + const groups = pagerGroups(true); const slide = p.cur || p.anchor; p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group'))); if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return; @@ -13500,16 +13552,66 @@ export function messagesFeature(ctx) { let g = pager.groups.get(gk); if (!g) { g = pagerGroup(); + if (pager.kind === 'videos' && !frugalData()) g.classList.add('mp-wait'); // out of the carousel until its clip is ready to play pager.groups.set(gk, g); pager.scroller.append(g); } + if (k === pager.start) { g.classList.remove('mp-wait'); g._opened = true; } // the clip tapped: in, whatever its state g._row.append(s); pagerDots(g); pager.near.observe(s); pager.io.observe(s); } + pagerFit(); + pagerPosition(); + } + // A post's clip is ready to play when its first frame is on the slide and + // its opening is in the cache (the head start done): only then does the + // post join the carousel, so the reader never lands on a black slide + // (Adam, 2026-10-16: "i never want to land on a black screen i'd rather + // just not have a video in carousel yet if it's not ready to play"). A + // post joining above the one on screen would push it down: the scroll + // moves by the post's height in the same frame, snapping off meanwhile, + // and not while the pager is in motion (then, once it has settled). + function pagerReveal() { + if (!pager || pager.kind !== 'videos') return; + const eager = !frugalData(); + for (const g of pager.scroller.querySelectorAll(':scope > .mp-group.mp-wait')) { + const v = g._row.firstElementChild && g._row.firstElementChild.querySelector('video.mp-media'); + if (!v) continue; + const url = v.getAttribute('data-src'); + // the frame is asked of a clip that can have one (the server's: an https + // clip); a local http clip (a dev setup) waits only for its opening + if ((serverFirst(url) && !v._frame) || (eager && !headDone.has(playSrc(url)))) continue; + pagerToggleGroup(g, 'mp-wait', false); + } + } + // the posts whose clips are the wrong way round for the screen stay out + // (not the one opened on; a shape not yet known is let in) + function pagerFit() { + if (!pager || pager.kind !== 'videos') return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + for (const g of pagerGroups(true)) { + const fits = g._opened || g === cg || [...g._row.children].some((s) => { const v = s.querySelector('video.mp-media'); return !v || clipFits(v.getAttribute('data-src')) !== false; }); + pagerToggleGroup(g, 'mp-off', !fits); + } + } + function pagerToggleGroup(g, cls, on) { + if (!pager || g.classList.contains(cls) === on) return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + const above = !!cg && cg !== g && !!(g.compareDocumentPosition(cg) & Node.DOCUMENT_POSITION_FOLLOWING); + if (above && pagerMoving()) { (pager.later ||= []).push([g, cls, on]); return; } // once it has settled (the frame loop) + const box = pager.scroller; + const shown = g.offsetParent !== null; // (a group hidden the other way stays hidden: no height to count) + const h0 = shown ? g.offsetHeight : 0; + box.style.scrollSnapType = 'none'; + g.classList.toggle(cls, on); + if (above) box.scrollTop += (g.offsetParent !== null ? g.offsetHeight : 0) - h0; + requestAnimationFrame(() => { if (pager && !box._glide) box.style.scrollSnapType = ''; }); pagerPosition(); + if (cg === g && on) { const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); } // the one on screen went out (turned the phone): whoever is there now } + const pagerMoving = () => !!pager && (pager.flying || !!pager.scroller._glide || pager.el.classList.contains('moving')); // A post in the pager: its row of slides, side by side, and — with more // than one — dots saying how many and which. function pagerGroup() { |
