aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-03 13:38:13 -0700
committerAdam Soltys <[email protected]>2026-10-03 13:38:13 -0700
commit347deec6465a42f24154457fb05227418ee83385 (patch)
tree5cb2343cc13e4400f9e831eba0a036ff098463c2 /src
parent543a66ca70323921acf4da9b8e4fca8128cf0b58 (diff)
downloadcoinosv3-347deec6465a42f24154457fb05227418ee83385.tar.gz
coinosv3-347deec6465a42f24154457fb05227418ee83385.zip
Videos: know what a clip costs before fetching it; vertical clips only
- What a clip costs is learned up front: the post's imeta (size, dim, image) when it says, else one 64 KB ranged request — the total from Content-Range, whether ranges are served, whether the MP4's index (moov) comes before its data, and the picture size from the track header. Up to three at a time; remembered across sessions. It also replaces the player-based shape probe the feed ran on every clip. - Clips over 25 MB (8 MB on 2G/3G, 3 MB with data saver), above 1080p, index-last files over 8 MB from servers without ranges, or clips that couldn't be measured wait for a tap: a cover saying what they cost ("50 MB", "4K"). Settings → Nostr → Videos: Autoplay large videos. - The pager loads only the clip on screen in full; the next gets its opening if within budget; everything else holds nothing. Sizes are asked three slides ahead. - Feed clips fetch nothing until they're about to play (preload none), with the post's still as a poster. - Video grid tiles show a first frame only for small clips that start quickly; others show their cost. - Videos lists only clips known to stand taller than wide; one of unknown shape is measured and joins when it qualifies; choosing Videos with none known yet opens the pager on the first that turns up. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ULQZS9MQa6iAsXnrJUD9Du
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js308
-rw-r--r--src/i18n.js6
-rw-r--r--src/locales/ar.json6
-rw-r--r--src/locales/bn.json6
-rw-r--r--src/locales/de.json6
-rw-r--r--src/locales/es.json6
-rw-r--r--src/locales/fr.json6
-rw-r--r--src/locales/hi.json6
-rw-r--r--src/locales/id.json6
-rw-r--r--src/locales/ja.json6
-rw-r--r--src/locales/ko.json6
-rw-r--r--src/locales/mr.json6
-rw-r--r--src/locales/pt.json6
-rw-r--r--src/locales/ru.json6
-rw-r--r--src/locales/sw.json6
-rw-r--r--src/locales/ta.json6
-rw-r--r--src/locales/te.json6
-rw-r--r--src/locales/tr.json6
-rw-r--r--src/locales/ur.json6
-rw-r--r--src/locales/vi.json6
-rw-r--r--src/locales/zh.json6
-rw-r--r--src/style.css17
22 files changed, 410 insertions, 35 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 10980ee..9a2179b 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -5477,12 +5477,170 @@ export function messagesFeature(ctx) {
}
return null;
};
+ // ---- what a clip costs, before it's downloaded --------------------------
+ // A post's imeta often says (size in bytes, dim, a still); otherwise one
+ // 64 KB ranged request does: the total from Content-Range, whether the
+ // server takes ranges at all, and the file's layout — an MP4 whose index
+ // (moov) sits behind its data can't start until the whole file is in when
+ // 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 = 600, VIDEO_PROBE_BYTES = 65536;
+ const VIDEO_LARGE_KEY = 'coinos-video-large';
+ const videoInfo = new Map(); // url -> { size, w, h, image, faststart, ranges, probed, failed }
+ try { for (const [u, d] of JSON.parse(localStorage.getItem(VIDEO_INFO_KEY) || '[]')) videoInfo.set(u, d); } catch {}
+ let videoInfoFlush = null;
+ const saveVideoInfo = () => {
+ if (videoInfoFlush) return;
+ videoInfoFlush = setTimeout(() => {
+ videoInfoFlush = null;
+ try { localStorage.setItem(VIDEO_INFO_KEY, JSON.stringify([...videoInfo].slice(-VIDEO_INFO_KEEP))); } catch {}
+ }, 1000);
+ };
+ const noteVideoInfo = (url, d) => { videoInfo.set(url, { ...(videoInfo.get(url) || {}), ...d }); saveVideoInfo(); };
+ // what the post itself says about its clips (NIP-92)
+ function learnVideoMeta(ev) {
+ for (const tag of ev?.tags || []) {
+ if (tag[0] !== 'imeta') continue;
+ const f = {};
+ for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); }
+ if (!f.url || !(VIDEO_RE.test(f.url) || /^video\//.test(f.m || ''))) continue;
+ const have = videoInfo.get(f.url) || {};
+ const d = {};
+ if (!have.size && +f.size > 0) d.size = +f.size;
+ const m = /^(\d+)x(\d+)$/.exec(f.dim || '');
+ if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; }
+ if (!have.image && /^https?:\/\//.test(f.image || '')) d.image = f.image;
+ if (Object.keys(d).length) noteVideoInfo(f.url, d);
+ }
+ }
+ // the top-level boxes of an MP4's first bytes: is moov ahead of mdat, and
+ // how big is the picture (a video track's tkhd ends in its width/height)
+ function readMp4Head(b) {
+ const u32 = (i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
+ const type = (i) => String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]);
+ if (b.length >= 4 && u32(0) === 0x1a45dfa3) return { faststart: true }; // WebM/Matroska streams from the start
+ let faststart, i = 0;
+ while (i + 8 <= b.length) {
+ let size = u32(i);
+ const tp = type(i + 4);
+ if (tp === 'moov') { faststart = true; break; }
+ if (tp === 'mdat') { faststart = false; break; }
+ if (size === 1) size = i + 16 <= b.length ? u32(i + 8) * 2 ** 32 + u32(i + 12) : 0;
+ if (size < 8) break;
+ i += size;
+ }
+ let w = 0, h = 0;
+ for (let j = 4; j + 4 <= b.length && !w; j++) {
+ if (b[j] !== 0x74 || type(j) !== 'tkhd') continue; // 't'
+ 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; }
+ }
+ return { faststart, ...(w ? { w, h } : {}) };
+ }
+ const videoProbes = new Map();
+ let videoProbing = 0;
+ const videoProbeQueue = [];
+ function probeVideo(url) {
+ const have = videoInfo.get(url);
+ // a post that states size and shape is taken at its word: no request at all
+ if (have && (have.probed || have.failed || (have.size && have.w))) return Promise.resolve(have);
+ if (videoProbes.has(url)) return videoProbes.get(url);
+ const task = new Promise((resolve) => videoProbeQueue.push({ url, resolve })).finally(() => videoProbes.delete(url));
+ videoProbes.set(url, task);
+ pumpVideoProbes();
+ return task;
+ }
+ function pumpVideoProbes() {
+ while (videoProbing < 3 && videoProbeQueue.length) {
+ const { url, resolve } = videoProbeQueue.shift();
+ videoProbing++;
+ runVideoProbe(url).then(resolve, () => resolve(videoInfo.get(url) || {}))
+ .finally(() => { videoProbing--; pumpVideoProbes(); });
+ }
+ }
+ async function runVideoProbe(url) {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 6000);
+ try {
+ const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, signal: ctrl.signal });
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ const ranges = r.status === 206;
+ const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length');
+ // never more than the first bytes, even from a server that ignored the range
+ const reader = r.body.getReader();
+ const parts = [];
+ let got = 0;
+ while (got < VIDEO_PROBE_BYTES) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ parts.push(value); got += value.length;
+ }
+ try { reader.cancel(); } catch {}
+ const buf = new Uint8Array(Math.min(got, VIDEO_PROBE_BYTES));
+ let o = 0;
+ for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
+ const head = readMp4Head(buf);
+ const d = { probed: true, ranges, faststart: head.faststart === true };
+ if (total > 0) d.size = total;
+ if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; }
+ if (head.w) rememberVideo(url, head.w, head.h);
+ noteVideoInfo(url, d);
+ } catch {
+ noteVideoInfo(url, { failed: true }); // a host that won't say (no CORS, down): asked again next session only
+ } finally { clearTimeout(timer); }
+ return videoInfo.get(url);
+ }
+ // How much a clip may cost before it waits for a tap: less on a slow or
+ // metered connection.
+ const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } };
+ function videoBudget() {
+ try {
+ const c = navigator.connection;
+ if (c && c.saveData) return 3e6;
+ if (c && /(^|-)2g$|^3g$/.test(c.effectiveType || '')) return 8e6;
+ } catch {}
+ return 25e6;
+ }
+ const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs
+ function videoHeavy(url) {
+ if (tappedClips.has(url) || largeVideosOk()) return false;
+ const d = videoInfo.get(url) || {};
+ if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true;
+ if (!d.size) return true; // nothing known: don't spend data blind
+ if (d.size > videoBudget()) return true;
+ // the whole file before the first frame: only worth it for a small one
+ if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true;
+ return false;
+ }
+ const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB';
+ function heavyLabel(url) {
+ const d = videoInfo.get(url) || {};
+ const edge = d.w && d.h ? Math.min(d.w, d.h) : 0;
+ const res = edge >= 2160 ? '4K' : edge > 1080 ? edge + 'p' : '';
+ return [d.size ? fmtBytes(d.size) : '', res].filter(Boolean).join(' · ') || t('videoTapToPlay');
+ }
const warmVideo = (url, dim) => {
if (!url || mediaReady.has(url)) return Promise.resolve();
const known = videoDims.get(url) || dim;
if (known) { rememberVideo(url, known.width, known.height); return Promise.resolve(); }
if (typeof document === 'undefined' || videoUnknown.has(url)) return Promise.resolve();
if (mediaWarming.has(url)) return mediaWarming.get(url);
+ // the 64 KB probe first: it reads the shape from the file's own header
+ // and costs nothing like a player's metadata load of an index-last file
+ const task = probeVideo(url).then(() => {
+ if (mediaReady.has(url)) return;
+ const d = videoInfo.get(url) || {};
+ if (d.probed) { videoUnknown.add(url); return; }
+ return playerShape(url);
+ }).finally(() => mediaWarming.delete(url));
+ mediaWarming.set(url, task);
+ return task;
+ };
+ // a host that wouldn't answer the probe: ask a player for the shape, briefly
+ const playerShape = (url) => {
const task = new Promise((resolve) => {
const v = document.createElement('video');
const done = () => {
@@ -5496,8 +5654,7 @@ export function messagesFeature(ctx) {
const timer = setTimeout(done, VIDEO_PROBE_MS);
v.onloadedmetadata = done; v.onerror = done;
v.muted = true; v.preload = 'metadata'; v.src = url;
- }).finally(() => mediaWarming.delete(url));
- mediaWarming.set(url, task);
+ });
return task;
};
// ---- link previews --------------------------------------------------
@@ -5605,7 +5762,7 @@ export function messagesFeature(ctx) {
}
async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })),
- ...noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u))),
+ ...(learnVideoMeta(ev), noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u)))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
@@ -6241,10 +6398,30 @@ export function messagesFeature(ctx) {
const videoBoxStyle = (d) => d && d.width && d.height
? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain`
: 'width:100%;aspect-ratio:16/9;object-fit:contain';
+ // Over a clip that waits for a tap: what it costs, and the tap that plays
+ // it (with sound — the reader asked for this one). The box is left alone
+ // by the morph from here, or a repaint would take the cover off.
+ function heavyCover(box, url, onPlay) {
+ if (box.querySelector('.vid-heavy')) return;
+ box._skipMorph = true;
+ const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'),
+ onClick: (e) => {
+ e.stopPropagation(); e.preventDefault();
+ tappedClips.add(url);
+ cover.remove();
+ onPlay && onPlay();
+ const v = box.querySelector('video');
+ if (v) { if (!v.getAttribute('src')) v.setAttribute('src', url); v.preload = 'auto'; v.play().catch(() => {}); }
+ } }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
+ box.append(cover);
+ }
function videoNode(url, { stable = false } = {}) {
const loud = unmutedClips.has(url);
- const v = h('video', { src: url, class: 'note-video', controls: true,
- preload: 'metadata', playsinline: true, muted: loud ? undefined : true, // play() in view fetches; a window of videos must not all stream
+ // 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: url, class: 'note-video', controls: true, poster: (videoInfo.get(url) || {}).image || undefined,
+ 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
onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
@@ -6281,7 +6458,12 @@ export function messagesFeature(ctx) {
v.setAttribute('src', url);
v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true });
}
- if (v.paused) v.play().then(() => { box._skipMorph = true; }).catch(() => {});
+ // a clip over the budget waits for a tap, with what it costs on it
+ probeVideo(url).then(() => {
+ if (!box._inView || !v.isConnected) return;
+ 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(() => {});
+ });
},
() => {
box._inView = false;
@@ -7802,6 +7984,18 @@ export function messagesFeature(ctx) {
h('button', { class: 'btn-ghost', disabled: rm.saving, onClick: () => loadRelayMgr(me) }, t('relaysReload'))));
}
+ // Settings → Nostr: whether clips over the budget play by themselves.
+ function videosCard() {
+ const on = largeVideosOk();
+ return h('div', { class: 'card col videos-card', style: 'gap:8px' },
+ h('h3', {}, t('videoLargeTitle')),
+ h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
+ h('input', { type: 'checkbox', class: 'video-large', checked: on, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0',
+ onChange: (e) => { try { localStorage.setItem(VIDEO_LARGE_KEY, e.target.checked ? '1' : '0'); } catch {} render(); } }),
+ h('span', {}, t('videoLargeToggle'))),
+ h('div', { class: 'small faint' }, t('videoLargeHelp', { mb: Math.round(videoBudget() / 1e6) })));
+ }
+
function moderationCard() {
const m = mutesNow();
const e = ui.modEdit || (ui.modEdit = { word: '' });
@@ -9370,6 +9564,7 @@ export function messagesFeature(ctx) {
// it), built outside the morph so a repaint never restarts a playing clip.
const MEDIA_MODE_KEY = 'coinos-feed-media';
let mediaMode = 'all';
+ let videosAutoOpen = false;
try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos') mediaMode = m; } catch {}
const mediaKey = (it) => it.ev.id + '|' + it.url;
// the picture an imeta tag names for a clip (NIP-92 `image`), if any
@@ -9381,11 +9576,32 @@ export function messagesFeature(ctx) {
}
return null;
};
+ // Videos is for flipping through phone-shaped clips: only those known to
+ // 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) {
+ 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;
+ if (!d.failed && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url);
+ return false;
+ }
+ const measuring = new Set();
+ function measureClip(url) {
+ if (measuring.has(url)) return;
+ measuring.add(url);
+ probeVideo(url).then((d) => {
+ if (d && d.probed && !d.w && d.ranges && !videoDims.has(url)) return playerShape(url);
+ }).finally(() => { if (mediaMode === 'videos') scheduleRepaint(); });
+ }
function mediaItems(c, kind) {
const out = [], seen = new Set();
for (const ev of c.notes) {
if (hidden(ev)) continue;
- const urls = kind === 'videos' ? noteVideoUrls(ev.content)
+ if (kind === 'videos') learnVideoMeta(ev);
+ const urls = kind === 'videos' ? noteVideoUrls(ev.content).filter(verticalClip)
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
for (const url of urls) {
const it = { ev, url };
@@ -9406,9 +9622,11 @@ export function messagesFeature(ctx) {
function setMediaMode(m) {
mediaMode = m;
try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {}
+ videosAutoOpen = false;
if (m === 'videos') {
const items = mediaItems(feedNow(), 'videos');
if (items.length) { openMediaPager('videos', mediaKey(items[0])); return; }
+ videosAutoOpen = true; // the first vertical clip found opens the pager
}
render();
try { window.scrollTo({ top: 0 }); } catch {}
@@ -9425,6 +9643,7 @@ export function messagesFeature(ctx) {
const items = mediaItems(c, mediaMode);
if (!items.length) {
return c.booting || c.status === 'loading' || c.loadingMore || !c.end
+ || (mediaMode === 'videos' && (videoProbes.size || videoProbeQueue.length))
? h('div', { class: 'row gap6', 'data-key': 'media-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
: h('div', { class: 'small faint', 'data-key': 'media-empty', style: 'text-align:center;padding:12px 0' },
t(mediaMode === 'videos' ? 'mediaNoVideos' : 'mediaNoImages'));
@@ -9432,7 +9651,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode },
...items.map((it) => {
const k = mediaKey(it);
- const poster = mediaMode === 'videos' ? posterOf(it.ev, it.url) : null;
+ const poster = mediaMode === 'videos' ? posterOf(it.ev, it.url) || (videoInfo.get(it.url) || {}).image : null;
return h('button', {
class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k,
'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k),
@@ -9455,10 +9674,17 @@ export function messagesFeature(ctx) {
if (!e.isIntersecting) continue;
const v = e.target;
tileObs.unobserve(v);
- v.muted = true;
- v.src = v.getAttribute('data-src');
const tile = v.closest('.media-tile');
if (tile) tile._skipMorph = true;
+ // a first frame only from a clip that starts quickly and is within
+ // budget: a player asked for a still of an index-last file fetches
+ // most of it; the rest show what they'd cost instead
+ const url = v.getAttribute('data-src').replace(/#t=[\d.]+$/, '');
+ probeVideo(url).then((d) => {
+ if (!v.isConnected) return;
+ if (d && d.faststart !== false && (d.faststart || d.size) && !videoHeavy(url)) { v.muted = true; v.src = v.getAttribute('data-src'); return; }
+ v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : ''));
+ });
}
}, { rootMargin: '400px 0px' });
for (const v of document.querySelectorAll('.media-tile video[data-src]:not([data-obs])')) {
@@ -9507,7 +9733,8 @@ export function messagesFeature(ctx) {
const { ev, url } = it;
const video = pager.kind === 'videos';
const media = video
- ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'auto', poster: posterOf(ev, url) || undefined })
+ ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
+ poster: posterOf(ev, url) || (videoInfo.get(url) || {}).image || undefined })
: h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' });
const caption = mediaCaption(ev);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
@@ -9547,9 +9774,52 @@ export function messagesFeature(ctx) {
const m = slide.querySelector('video.mp-media');
if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
}
+ // Only the clip on screen loads in full. The next one gets its opening
+ // (preload metadata: the index and first frames) if it's within budget;
+ // every other clip holds nothing. Sizes are asked for a few slides ahead,
+ // so the verdict is in before the reader gets there.
+ function pagerNeighbours(slide) {
+ if (!pager || pager.kind !== 'videos') return;
+ const slides = [...pager.scroller.children];
+ const i = slides.indexOf(slide);
+ slides.forEach((s, j) => {
+ if (j === i) return;
+ const v = s.querySelector('video.mp-media');
+ if (j === i + 1) {
+ const url = v.getAttribute('data-src');
+ probeVideo(url).then(() => {
+ if (!pager || pager.cur === s || videoHeavy(url) || slides.indexOf(pager.cur) !== i) return;
+ v.preload = 'metadata';
+ pagerAttach(s);
+ });
+ } else pagerRelease(s);
+ });
+ for (const s of slides.slice(i + 2, i + 4)) probeVideo(s.querySelector('video.mp-media').getAttribute('data-src'));
+ }
function playSlide(slide) {
const v = slide.querySelector('video.mp-media');
if (!v) return;
+ pagerNeighbours(slide);
+ const url = v.getAttribute('data-src');
+ probeVideo(url).then(() => {
+ if (!pager || pager.cur !== slide) return;
+ if (videoHeavy(url)) {
+ if (slide.querySelector('.vid-heavy')) return;
+ pagerRelease(slide);
+ slide.append(h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'), onClick: (e) => {
+ e.stopPropagation();
+ tappedClips.add(url);
+ e.currentTarget.remove();
+ startSlide(slide);
+ } }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url))));
+ return;
+ }
+ startSlide(slide);
+ });
+ }
+ function startSlide(slide) {
+ const v = slide.querySelector('video.mp-media');
+ v.preload = 'auto';
pagerAttach(slide);
v.muted = !pagerLoud;
v.play().catch((err) => {
@@ -9587,8 +9857,9 @@ export function messagesFeature(ctx) {
pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed };
pager.near = new IntersectionObserver((entries) => {
for (const e of entries) {
- if (e.isIntersecting) { pagerAttach(e.target); watchZaps([e.target._ev.id]); }
- else pagerRelease(e.target);
+ if (!e.isIntersecting) continue;
+ watchZaps([e.target._ev.id]);
+ if (pager.kind === 'images') pagerAttach(e.target); // pictures are cheap; clips are pagerNeighbours' call
}
}, { root: scroller, rootMargin: '150% 0px' });
pager.io = new IntersectionObserver((entries) => {
@@ -9650,7 +9921,14 @@ export function messagesFeature(ctx) {
c.opened = true;
const def = feedDef();
const visitor = isVisitor();
- queueMicrotask(() => { syncMediaPager(); if (mediaMode !== 'all') watchMediaTiles(); });
+ queueMicrotask(() => {
+ if (videosAutoOpen && mediaMode === 'videos' && !ui.mediaPager) {
+ const items = mediaItems(feedNow(), 'videos');
+ if (items.length) { videosAutoOpen = false; openMediaPager('videos', mediaKey(items[0])); return; }
+ }
+ syncMediaPager();
+ if (mediaMode !== 'all') watchMediaTiles();
+ });
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
prepareFeedAhead(c);
@@ -11527,7 +11805,7 @@ export function messagesFeature(ctx) {
id: 'messages',
// group-chat state is device state in IndexedDB: it goes with the account
wipeCache(bases) { import('../marmot-store.js').then((m) => { for (const b of bases) m.wipeMarmot(b); }).catch(() => {}); },
- nostrSettingsCards() { return [relaysCard(), followHistoryCard(), moderationCard()]; },
+ nostrSettingsCards() { return [relaysCard(), followHistoryCard(), moderationCard(), videosCard()]; },
// The app came back after being backgrounded. A phone freezes a hidden
// tab: the relay sockets are cut and every post made in the meantime is
// simply missing, which is why the feed used to sit there looking stale
diff --git a/src/i18n.js b/src/i18n.js
index 932b552..e4d2429 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -257,6 +257,10 @@ const STR = {
close: 'Close',
playVideo: 'Play',
videoUnmute: 'Unmute',
+ videoTapToPlay: "Tap to play",
+ videoLargeTitle: "Videos",
+ videoLargeToggle: "Autoplay large videos",
+ videoLargeHelp: "Videos over {mb} MB or above 1080p wait for a tap, so the feed doesn’t eat your data. The limit is lower on a slow or data-saving connection.",
videoMute: 'Mute',
openInYouTube: 'Open on YouTube',
accountsThisOne: 'signed in',
@@ -303,7 +307,7 @@ const STR = {
mediaImages: "Images",
mediaVideos: "Videos",
mediaNoImages: "No pictures in this feed yet.",
- mediaNoVideos: "No videos in this feed yet.",
+ mediaNoVideos: "No vertical videos in this feed yet.",
feedRelays: 'Relays',
feedRelaysFormHelp: 'Read this feed from these relays only. Leave empty for the usual ones.',
feedAllToggle: 'Every post on these relays (a firehose)',
diff --git a/src/locales/ar.json b/src/locales/ar.json
index cf1de50..e1e3d67 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -908,5 +908,9 @@
"mediaImages": "الصور",
"mediaVideos": "الفيديوهات",
"mediaNoImages": "لا صور في هذه الخلاصة بعد.",
- "mediaNoVideos": "لا فيديوهات في هذه الخلاصة بعد."
+ "mediaNoVideos": "لا فيديوهات عمودية في هذه الخلاصة بعد.",
+ "videoTapToPlay": "انقر للتشغيل",
+ "videoLargeTitle": "الفيديوهات",
+ "videoLargeToggle": "تشغيل الفيديوهات الكبيرة تلقائيًا",
+ "videoLargeHelp": "الفيديوهات التي تزيد على {mb} ميغابايت أو تتجاوز 1080p تنتظر نقرة، حتى لا تستهلك الخلاصة بياناتك. الحد أقل على الاتصال البطيء أو عند توفير البيانات."
}
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 6ec0e94..9687a7e 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -908,5 +908,9 @@
"mediaImages": "ছবি",
"mediaVideos": "ভিডিও",
"mediaNoImages": "এই ফিডে এখনও কোনো ছবি নেই।",
- "mediaNoVideos": "এই ফিডে এখনও কোনো ভিডিও নেই।"
+ "mediaNoVideos": "এই ফিডে এখনও কোনো খাড়া ভিডিও নেই।",
+ "videoTapToPlay": "চালাতে ট্যাপ করুন",
+ "videoLargeTitle": "ভিডিও",
+ "videoLargeToggle": "বড় ভিডিও নিজে থেকে চালান",
+ "videoLargeHelp": "{mb} MB-এর বেশি বা 1080p-এর ওপরের ভিডিও ট্যাপের অপেক্ষায় থাকে, যাতে ফিড আপনার ডেটা না খায়। ধীর বা ডেটা-সেভার সংযোগে সীমা কম।"
}
diff --git a/src/locales/de.json b/src/locales/de.json
index df72786..b1e5127 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -908,5 +908,9 @@
"mediaImages": "Bilder",
"mediaVideos": "Videos",
"mediaNoImages": "Noch keine Bilder in diesem Feed.",
- "mediaNoVideos": "Noch keine Videos in diesem Feed."
+ "mediaNoVideos": "Noch keine Hochkant-Videos in diesem Feed.",
+ "videoTapToPlay": "Zum Abspielen tippen",
+ "videoLargeTitle": "Videos",
+ "videoLargeToggle": "Große Videos automatisch abspielen",
+ "videoLargeHelp": "Videos über {mb} MB oder über 1080p warten auf einen Tipp, damit der Feed nicht dein Datenvolumen frisst. Bei langsamer Verbindung oder Datensparmodus ist die Grenze niedriger."
}
diff --git a/src/locales/es.json b/src/locales/es.json
index 1f1c170..569e9fa 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -908,5 +908,9 @@
"mediaImages": "Imágenes",
"mediaVideos": "Vídeos",
"mediaNoImages": "Aún no hay imágenes en este feed.",
- "mediaNoVideos": "Aún no hay vídeos en este feed."
+ "mediaNoVideos": "Aún no hay vídeos verticales en este feed.",
+ "videoTapToPlay": "Toca para reproducir",
+ "videoLargeTitle": "Vídeos",
+ "videoLargeToggle": "Reproducir vídeos grandes automáticamente",
+ "videoLargeHelp": "Los vídeos de más de {mb} MB o por encima de 1080p esperan a que los toques, para que el feed no se coma tus datos. El límite es menor con una conexión lenta o en ahorro de datos."
}
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 1d7c5f4..c345556 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -908,5 +908,9 @@
"mediaImages": "Images",
"mediaVideos": "Vidéos",
"mediaNoImages": "Pas encore d’images dans ce fil.",
- "mediaNoVideos": "Pas encore de vidéos dans ce fil."
+ "mediaNoVideos": "Pas encore de vidéos verticales dans ce fil.",
+ "videoTapToPlay": "Touchez pour lire",
+ "videoLargeTitle": "Vidéos",
+ "videoLargeToggle": "Lire automatiquement les grosses vidéos",
+ "videoLargeHelp": "Les vidéos de plus de {mb} Mo ou au-delà de 1080p attendent un toucher, pour que le fil ne dévore pas vos données. La limite est plus basse sur une connexion lente ou en économie de données."
}
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 7772bc1..cbee487 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -908,5 +908,9 @@
"mediaImages": "तस्वीरें",
"mediaVideos": "वीडियो",
"mediaNoImages": "इस फ़ीड में अभी कोई तस्वीर नहीं।",
- "mediaNoVideos": "इस फ़ीड में अभी कोई वीडियो नहीं।"
+ "mediaNoVideos": "इस फ़ीड में अभी कोई वर्टिकल वीडियो नहीं।",
+ "videoTapToPlay": "चलाने के लिए टैप करें",
+ "videoLargeTitle": "वीडियो",
+ "videoLargeToggle": "बड़े वीडियो अपने आप चलाएँ",
+ "videoLargeHelp": "{mb} MB से बड़े या 1080p से ऊपर के वीडियो टैप का इंतज़ार करते हैं, ताकि फ़ीड आपका डेटा न खाए। धीमे या डेटा-सेवर कनेक्शन पर सीमा कम होती है।"
}
diff --git a/src/locales/id.json b/src/locales/id.json
index a4fe07f..45be39b 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -244,5 +244,9 @@
"mediaImages": "Gambar",
"mediaVideos": "Video",
"mediaNoImages": "Belum ada gambar di feed ini.",
- "mediaNoVideos": "Belum ada video di feed ini."
+ "mediaNoVideos": "Belum ada video vertikal di feed ini.",
+ "videoTapToPlay": "Ketuk untuk memutar",
+ "videoLargeTitle": "Video",
+ "videoLargeToggle": "Putar otomatis video besar",
+ "videoLargeHelp": "Video di atas {mb} MB atau lebih dari 1080p menunggu ketukan, agar feed tidak menghabiskan kuota. Batasnya lebih rendah di koneksi lambat atau mode hemat data."
}
diff --git a/src/locales/ja.json b/src/locales/ja.json
index b376d3b..56e05f4 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -908,5 +908,9 @@
"mediaImages": "画像",
"mediaVideos": "動画",
"mediaNoImages": "このフィードにはまだ画像がありません。",
- "mediaNoVideos": "このフィードにはまだ動画がありません。"
+ "mediaNoVideos": "このフィードにはまだ縦型動画がありません。",
+ "videoTapToPlay": "タップして再生",
+ "videoLargeTitle": "動画",
+ "videoLargeToggle": "大きな動画を自動再生",
+ "videoLargeHelp": "{mb} MBを超える動画や1080pを超える動画は、データを使いすぎないようタップするまで再生しません。低速回線やデータセーバー時は上限が下がります。"
}
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 7d6bb2a..79f3cdf 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -907,5 +907,9 @@
"mediaImages": "이미지",
"mediaVideos": "동영상",
"mediaNoImages": "이 피드에는 아직 이미지가 없습니다.",
- "mediaNoVideos": "이 피드에는 아직 동영상이 없습니다."
+ "mediaNoVideos": "이 피드에는 아직 세로 동영상이 없습니다.",
+ "videoTapToPlay": "탭하여 재생",
+ "videoLargeTitle": "동영상",
+ "videoLargeToggle": "큰 동영상 자동 재생",
+ "videoLargeHelp": "{mb}MB를 넘거나 1080p보다 높은 동영상은 데이터를 아끼기 위해 탭할 때까지 기다립니다. 느린 연결이나 데이터 절약 모드에서는 한도가 더 낮습니다."
}
diff --git a/src/locales/mr.json b/src/locales/mr.json
index a5080f2..f35613b 100644
--- a/src/locales/mr.json
+++ b/src/locales/mr.json
@@ -244,5 +244,9 @@
"mediaImages": "चित्रे",
"mediaVideos": "व्हिडिओ",
"mediaNoImages": "या फीडमध्ये अजून चित्रे नाहीत.",
- "mediaNoVideos": "या फीडमध्ये अजून व्हिडिओ नाहीत."
+ "mediaNoVideos": "या फीडमध्ये अजून उभे व्हिडिओ नाहीत.",
+ "videoTapToPlay": "चालवण्यासाठी टॅप करा",
+ "videoLargeTitle": "व्हिडिओ",
+ "videoLargeToggle": "मोठे व्हिडिओ आपोआप चालवा",
+ "videoLargeHelp": "{mb} MB पेक्षा मोठे किंवा 1080p पेक्षा वरचे व्हिडिओ तुमचा डेटा वाचवण्यासाठी टॅपची वाट पाहतात. धीम्या किंवा डेटा-सेव्हर कनेक्शनवर मर्यादा कमी असते."
}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 142daf9..665cf73 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -908,5 +908,9 @@
"mediaImages": "Imagens",
"mediaVideos": "Vídeos",
"mediaNoImages": "Ainda não há imagens neste feed.",
- "mediaNoVideos": "Ainda não há vídeos neste feed."
+ "mediaNoVideos": "Ainda não há vídeos verticais neste feed.",
+ "videoTapToPlay": "Toque para reproduzir",
+ "videoLargeTitle": "Vídeos",
+ "videoLargeToggle": "Reproduzir vídeos grandes automaticamente",
+ "videoLargeHelp": "Vídeos acima de {mb} MB ou de 1080p esperam um toque, para o feed não consumir seus dados. O limite é menor em conexões lentas ou com economia de dados."
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index c94fb56..f7ee649 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -908,5 +908,9 @@
"mediaImages": "Фото",
"mediaVideos": "Видео",
"mediaNoImages": "В этой ленте пока нет фото.",
- "mediaNoVideos": "В этой ленте пока нет видео."
+ "mediaNoVideos": "В этой ленте пока нет вертикальных видео.",
+ "videoTapToPlay": "Нажмите, чтобы воспроизвести",
+ "videoLargeTitle": "Видео",
+ "videoLargeToggle": "Автовоспроизведение больших видео",
+ "videoLargeHelp": "Видео больше {mb} МБ или выше 1080p ждут нажатия, чтобы лента не съедала трафик. На медленном соединении или в режиме экономии лимит ниже."
}
diff --git a/src/locales/sw.json b/src/locales/sw.json
index 770551d..179531a 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -244,5 +244,9 @@
"mediaImages": "Picha",
"mediaVideos": "Video",
"mediaNoImages": "Bado hakuna picha kwenye mlisho huu.",
- "mediaNoVideos": "Bado hakuna video kwenye mlisho huu."
+ "mediaNoVideos": "Bado hakuna video wima kwenye mlisho huu.",
+ "videoTapToPlay": "Gusa ili kucheza",
+ "videoLargeTitle": "Video",
+ "videoLargeToggle": "Cheza video kubwa zenyewe",
+ "videoLargeHelp": "Video zaidi ya MB {mb} au juu ya 1080p husubiri uguse, ili mlisho usimalize data yako. Kikomo ni cha chini kwenye muunganisho wa polepole au wa kuokoa data."
}
diff --git a/src/locales/ta.json b/src/locales/ta.json
index 856841e..068bcc8 100644
--- a/src/locales/ta.json
+++ b/src/locales/ta.json
@@ -244,5 +244,9 @@
"mediaImages": "படங்கள்",
"mediaVideos": "வீடியோக்கள்",
"mediaNoImages": "இந்த ஊட்டத்தில் இன்னும் படங்கள் இல்லை.",
- "mediaNoVideos": "இந்த ஊட்டத்தில் இன்னும் வீடியோக்கள் இல்லை."
+ "mediaNoVideos": "இந்த ஊட்டத்தில் இன்னும் செங்குத்து வீடியோக்கள் இல்லை.",
+ "videoTapToPlay": "இயக்கத் தட்டவும்",
+ "videoLargeTitle": "வீடியோக்கள்",
+ "videoLargeToggle": "பெரிய வீடியோக்களைத் தானாக இயக்கு",
+ "videoLargeHelp": "{mb} MB-க்கு மேல் அல்லது 1080p-க்கு மேலான வீடியோக்கள் உங்கள் டேட்டாவைச் சேமிக்க, தட்டும் வரை காத்திருக்கும். மெதுவான அல்லது டேட்டா சேமிப்பு இணைப்பில் வரம்பு குறைவு."
}
diff --git a/src/locales/te.json b/src/locales/te.json
index 2e7deff..2c7160b 100644
--- a/src/locales/te.json
+++ b/src/locales/te.json
@@ -242,5 +242,9 @@
"mediaImages": "చిత్రాలు",
"mediaVideos": "వీడియోలు",
"mediaNoImages": "ఈ ఫీడ్‌లో ఇంకా చిత్రాలు లేవు.",
- "mediaNoVideos": "ఈ ఫీడ్‌లో ఇంకా వీడియోలు లేవు."
+ "mediaNoVideos": "ఈ ఫీడ్‌లో ఇంకా నిలువు వీడియోలు లేవు.",
+ "videoTapToPlay": "ప్లే చేయడానికి నొక్కండి",
+ "videoLargeTitle": "వీడియోలు",
+ "videoLargeToggle": "పెద్ద వీడియోలను ఆటో‌ప్లే చేయి",
+ "videoLargeHelp": "{mb} MB కంటే పెద్దవి లేదా 1080p పైబడిన వీడియోలు మీ డేటా ఆదా కోసం నొక్కే వరకు ఆగుతాయి. నెమ్మదైన లేదా డేటా సేవర్ కనెక్షన్‌లో పరిమితి తక్కువ."
}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 9b5dacf..b8ebb51 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -907,5 +907,9 @@
"mediaImages": "Görseller",
"mediaVideos": "Videolar",
"mediaNoImages": "Bu akışta henüz görsel yok.",
- "mediaNoVideos": "Bu akışta henüz video yok."
+ "mediaNoVideos": "Bu akışta henüz dikey video yok.",
+ "videoTapToPlay": "Oynatmak için dokun",
+ "videoLargeTitle": "Videolar",
+ "videoLargeToggle": "Büyük videoları otomatik oynat",
+ "videoLargeHelp": "{mb} MB’den büyük veya 1080p’nin üzerindeki videolar, akış verini tüketmesin diye dokunmanı bekler. Yavaş ya da veri tasarruflu bağlantıda sınır daha düşüktür."
}
diff --git a/src/locales/ur.json b/src/locales/ur.json
index 3a04f22..82437f2 100644
--- a/src/locales/ur.json
+++ b/src/locales/ur.json
@@ -244,5 +244,9 @@
"mediaImages": "تصاویر",
"mediaVideos": "ویڈیوز",
"mediaNoImages": "اس فیڈ میں ابھی کوئی تصویر نہیں۔",
- "mediaNoVideos": "اس فیڈ میں ابھی کوئی ویڈیو نہیں۔"
+ "mediaNoVideos": "اس فیڈ میں ابھی کوئی عمودی ویڈیو نہیں۔",
+ "videoTapToPlay": "چلانے کے لیے ٹیپ کریں",
+ "videoLargeTitle": "ویڈیوز",
+ "videoLargeToggle": "بڑی ویڈیوز خود چلائیں",
+ "videoLargeHelp": "{mb} MB سے بڑی یا 1080p سے اوپر کی ویڈیوز ٹیپ کا انتظار کرتی ہیں، تاکہ فیڈ آپ کا ڈیٹا نہ کھائے۔ سست یا ڈیٹا بچت والے کنکشن پر حد کم ہوتی ہے۔"
}
diff --git a/src/locales/vi.json b/src/locales/vi.json
index 7393d1d..7ad49e0 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -244,5 +244,9 @@
"mediaImages": "Ảnh",
"mediaVideos": "Video",
"mediaNoImages": "Bảng tin này chưa có ảnh.",
- "mediaNoVideos": "Bảng tin này chưa có video."
+ "mediaNoVideos": "Bảng tin này chưa có video dọc.",
+ "videoTapToPlay": "Chạm để phát",
+ "videoLargeTitle": "Video",
+ "videoLargeToggle": "Tự động phát video lớn",
+ "videoLargeHelp": "Video trên {mb} MB hoặc cao hơn 1080p sẽ chờ bạn chạm, để bảng tin không ngốn dữ liệu. Giới hạn thấp hơn khi kết nối chậm hoặc tiết kiệm dữ liệu."
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index f576f42..b2cd00d 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -908,5 +908,9 @@
"mediaImages": "图片",
"mediaVideos": "视频",
"mediaNoImages": "此动态中还没有图片。",
- "mediaNoVideos": "此动态中还没有视频。"
+ "mediaNoVideos": "此动态中还没有竖屏视频。",
+ "videoTapToPlay": "点按播放",
+ "videoLargeTitle": "视频",
+ "videoLargeToggle": "自动播放大视频",
+ "videoLargeHelp": "超过 {mb} MB 或高于 1080p 的视频会等你点按再播放,以免动态耗尽流量。在慢速或省流量连接下,上限更低。"
}
diff --git a/src/style.css b/src/style.css
index f8987f2..2f89590 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1443,3 +1443,20 @@ body::after {
.media-pager.quiet .mp-top .mp-x { opacity: 0; pointer-events: none; }
.media-pager.quiet .mp-top .mp-sound { pointer-events: auto; }
.media-pager.quiet.show .mp-top .mp-x { opacity: 1; pointer-events: auto; }
+
+/* a clip that waits for a tap: what it costs, over its (blank or still) box */
+.vid-heavy {
+ position: absolute; inset: 0; z-index: 2; margin: 0; padding: 0; border: 0; border-radius: inherit;
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
+ background: rgba(0, 0, 0, .45); color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, .6);
+}
+.vid-heavy-play {
+ 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;
+}
+.note-video-box .vid-heavy { min-height: 120px; }
+.media-tile-cost {
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+ color: var(--muted); font-size: 12px; font-weight: 600; background: var(--surface2);
+}