aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 05:58:33 +0000
committerAdam Soltys <[email protected]>2026-10-13 05:58:33 +0000
commit72705992c3d40f87052ade605c50f60a18f80453 (patch)
treeaa79f9f44bc10e73a0630ed63213030da5c412a1 /src
parent4e1cd666a06c4ee852586986d0301278d680a614 (diff)
downloadcoinosv3-72705992c3d40f87052ade605c50f60a18f80453.tar.gz
coinosv3-72705992c3d40f87052ade605c50f60a18f80453.zip
Media pager: the head start sizes itself to the clip and the link, and a tap pauses any playing clip. Adam: a fixed 512 KB head start isn't right for every clip; and a tap anywhere on a playing clip but the seek bar should pause it. The head start is now seconds of the clip, not bytes: two seconds' worth on a link well ahead of the clip's bitrate, four when only just ahead, and on a link slower than the clip enough to cover the shortfall over its length — at least 256 KB, at most 4 MB, the second clip ahead half. The bitrate is the file's size over its length when the post says both (imeta size and duration; duration is now kept), else guessed from its picture size, else a phone clip's; the link's speed is measured off the head starts themselves (a moving average), and until there is a measure it's two seconds' worth — Chrome's own downlink guess runs low (in the test browser it would have had every first head start at the cap). A reader who swipes on within seconds (a moving average of how long clips hold them) gets just the opening; a clip of 3 MB or less comes whole. A tap on a playing clip in the media grid's Videos pager now pauses it with its details up, as one opened from the feed does; another plays on (it only toggled the details). And mergeFeed's duplicate check now counts the posts waiting a moment to go in together: a copy from a second relay, or another version of an addressable clip, could slip in during that window (video-events-test caught the second, one run in a few). pager-eager-test: clips whose imeta states size and length; two seconds' worth ahead (half for the second) before the link is measured, still that on a fast link measured, the cap (half) on a 3 Mbps link the server paces (the player still takes the opening from the cache there — DevTools' throttling had defeated the cache, not the app); feed-media-views-test checks the tap to pause and play on in the Videos pager.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js67
1 files changed, 54 insertions, 13 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 42e2839..598ff39 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -6388,6 +6388,7 @@ export function messagesFeature(ctx) {
if (!have.size && +f.size > 0) d.size = +f.size;
// no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough
else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8);
+ if (!have.dur && +f.duration > 0) d.dur = +f.duration;
const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.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;
@@ -6819,15 +6820,18 @@ export function messagesFeature(ctx) {
async function mergeFeed(evs, opts = {}, c = feedNow()) {
let staged = feedStaged.get(c);
if (!staged) feedStaged.set(c, staged = new Set());
- const known = new Set([...c.notes, ...(c.deferred || [])].map((e) => e.id));
+ // (with those waiting a moment to go in together — admitQueues — or a
+ // copy from a second relay, or another version of a clip, slips in)
+ const held = [...c.notes, ...(c.deferred || []), ...((admitQueues.get(c) || {}).evs || [])];
+ const known = new Set(held.map((e) => e.id));
for (const e of evs || []) noteForSpam(e);
// A note shows once: as itself, or under the first repost of it to
// arrive — counting the ones still warming at the door.
// (`staged` holds the note's id too, for a repost)
const noteOf = (e) => (e.kind === REPOST ? repostInner(e) : e);
- const notesIn = new Set([...c.notes, ...(c.deferred || [])].map((e) => noteOf(e)?.id).filter(Boolean));
+ const notesIn = new Set(held.map((e) => noteOf(e)?.id).filter(Boolean));
// an addressable post (a 34235/34236 clip) once, however many versions of it come
- for (const e of [...c.notes, ...(c.deferred || [])]) { const a = noteOf(e) && addrOf(noteOf(e)); if (a) notesIn.add(a); }
+ for (const e of held) { const a = noteOf(e) && addrOf(noteOf(e)); if (a) notesIn.add(a); }
const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); };
const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); };
const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } };
@@ -12237,7 +12241,7 @@ export function messagesFeature(ctx) {
const v = s.querySelector('video.mp-media');
if (eager && (j === i + 1 || j === i + 2)) {
pagerRelease(s);
- headStart(playSrc(v.getAttribute('data-src')));
+ headStart(playSrc(v.getAttribute('data-src')), j === i + 1 ? 1 : 0.5);
return;
}
if (j === i + 1) {
@@ -12256,19 +12260,52 @@ export function messagesFeature(ctx) {
// is answered from there as far as it goes, and streams on from the host.
// A host that ignores the range sends the whole file: the opening is
// read, the rest cancelled. A host that doesn't allow the request (no
- // CORS) gets none — its player still has its metadata and first frame.
- const HEAD_START = 512 * 1024;
+ // CORS) gets none.
+ //
+ // How much: enough seconds of the clip that it starts at once and the
+ // stream stays ahead of it — few when the link is well ahead of the clip's
+ // bitrate, more when it's only just, and when it's behind, enough to cover
+ // the shortfall over the clip's length. The bitrate is the file's size over
+ // its length when a post says both (else guessed from its picture size, or
+ // a phone clip's); the link's speed is measured off these fetches
+ // themselves — until there's a measure, a couple of seconds' worth (the
+ // browser's own guess at the speed runs low, and would have had every
+ // first head start at the cap). A reader who swipes on within seconds gets
+ // just the opening; a small clip comes whole.
+ const HEAD_MIN = 256 * 1024, HEAD_MAX = 4 * 1024 * 1024;
+ let netRate = 0; // bytes/s, from the head starts (a moving average)
+ let dwell = 0; // seconds a clip is watched before the swipe on (a moving average)
+ function headStartBytes(url) {
+ const d = videoInfo.get(url) || {};
+ const dur = d.dur > 0 ? d.dur : 0;
+ const B = d.size && dur ? d.size / dur // bytes/s
+ : d.w && d.h ? Math.max(150e3, d.w * d.h * 30 * 0.1 / 8) : 500e3;
+ const D = netRate;
+ let lead = !D || D >= 1.5 * B ? 2 : D >= B ? 4 : 2 + (dur || 20) * (1 - D / B);
+ if (dwell && dwell < 4) lead = Math.min(lead, 2);
+ let n = B * lead;
+ 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();
- function headStart(url) {
+ function headStart(url, share = 1) {
if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return;
headStarted.add(url);
if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value);
- fetch(url, { headers: { Range: 'bytes=0-' + (HEAD_START - 1) } }).then(async (r) => {
+ const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share));
+ const t0 = performance.now();
+ 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;
- while (got < HEAD_START) { const { done, value } = await reader.read(); if (done) return; got += value.length; }
- reader.cancel().catch(() => {});
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ got += value.length;
+ if (got >= want) { reader.cancel().catch(() => {}); break; }
+ }
+ 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(() => {});
}
function playSlide(slide) {
@@ -12359,9 +12396,10 @@ export function messagesFeature(ctx) {
el.addEventListener('click', (e) => {
if (e.target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return;
if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it
- if (pager && pager.fromFeed && video) {
- // A clip opened from the feed: a tap pauses it and brings up who
- // posted it, the ×, the seek bar to grab; another plays it on.
+ if (pager && video) {
+ // A clip (from the feed or the media grid): a tap anywhere but the
+ // seek bar and the buttons pauses it and brings up who posted it,
+ // the ×, the seek bar to grab; another plays it on.
const v = pager.cur && pager.cur.querySelector('video.mp-media');
if (v && !v.paused) { v.pause(); el.classList.add('show'); pagerCounts(pager.cur); return; }
el.classList.remove('show');
@@ -12421,11 +12459,14 @@ export function messagesFeature(ctx) {
if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue;
if (pager.cur && pager.cur !== e.target) {
const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause();
+ // how long a clip held the reader (the head start's measure of them)
+ if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
// past the clip it opened on, the screen follows the phone again
if (pagerFs || pagerLock) { pagerLock = false; try { screen.orientation?.unlock?.(); } catch {} }
if (pager.fromFeed && video) el.classList.remove('show');
}
pager.cur = e.target;
+ pager.curAt = Date.now();
if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === e.target._rowId); markSeen(row || e.target._ev); }
playSlide(e.target);
pagerCounts(e.target);