diff options
| author | Adam Soltys <[email protected]> | 2026-10-13 05:58:33 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-13 05:58:33 +0000 |
| commit | 72705992c3d40f87052ade605c50f60a18f80453 (patch) | |
| tree | aa79f9f44bc10e73a0630ed63213030da5c412a1 /src/features/messages.js | |
| parent | 4e1cd666a06c4ee852586986d0301278d680a614 (diff) | |
| download | coinosv3-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/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 67 |
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); |
