diff options
| author | Adam Soltys <[email protected]> | 2026-10-03 13:38:13 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-03 13:38:13 -0700 |
| commit | 347deec6465a42f24154457fb05227418ee83385 (patch) | |
| tree | 5cb2343cc13e4400f9e831eba0a036ff098463c2 /src | |
| parent | 543a66ca70323921acf4da9b8e4fca8128cf0b58 (diff) | |
| download | coinosv3-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.js | 308 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | src/locales/ar.json | 6 | ||||
| -rw-r--r-- | src/locales/bn.json | 6 | ||||
| -rw-r--r-- | src/locales/de.json | 6 | ||||
| -rw-r--r-- | src/locales/es.json | 6 | ||||
| -rw-r--r-- | src/locales/fr.json | 6 | ||||
| -rw-r--r-- | src/locales/hi.json | 6 | ||||
| -rw-r--r-- | src/locales/id.json | 6 | ||||
| -rw-r--r-- | src/locales/ja.json | 6 | ||||
| -rw-r--r-- | src/locales/ko.json | 6 | ||||
| -rw-r--r-- | src/locales/mr.json | 6 | ||||
| -rw-r--r-- | src/locales/pt.json | 6 | ||||
| -rw-r--r-- | src/locales/ru.json | 6 | ||||
| -rw-r--r-- | src/locales/sw.json | 6 | ||||
| -rw-r--r-- | src/locales/ta.json | 6 | ||||
| -rw-r--r-- | src/locales/te.json | 6 | ||||
| -rw-r--r-- | src/locales/tr.json | 6 | ||||
| -rw-r--r-- | src/locales/ur.json | 6 | ||||
| -rw-r--r-- | src/locales/vi.json | 6 | ||||
| -rw-r--r-- | src/locales/zh.json | 6 | ||||
| -rw-r--r-- | src/style.css | 17 |
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); +} |
