Load a split step's videos together, and three steps ahead

Preloading was counted in steps and then shared out between the panels,
so with four panels only the very next step was ready and each panel did
its resolving and buffering at the moment it was swiped to -- four
panels, four starts in a row. It is now counted per panel: every panel
keeps its next three videos ready (N * 3 for a step of N), the rendered
window reaches far enough forward to hold them, and the step right
behind the one being watched buffers for real rather than taking its
metadata alone.

Their formats are resolved up front and all at once, rather than one
panel at a time as each is swiped to.

The listing is fetched several pages at a time to keep up: a page number
is claimed before its request goes out, which is what lets pages
overlap, and finished pages wait their turn so the reader still gets
them in order. A page whose request fails puts its number back rather
than leaving a hole. How far ahead to run scales with the split, since a
step of N panels gets through a page N times as fast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-09-22 09:11:52 +00:00
parent 1b38f9d9e7
commit 8163693150
3 changed files with 536 additions and 84 deletions

View File

@@ -22,7 +22,14 @@ App.feed = App.feed || {};
// Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the
// active slide so the data buffer is always full before we need to render
// a slide from it.
const PRELOAD_COUNT = 2; // slides ahead kept with a live <video> playing/preloaded
// Preloading is counted in steps, so it scales with the split rather than
// being shared out between the panels: every panel keeps its own next three
// videos ready, which is N * 3 videos loading ahead for a step of N panels.
// The step right behind the one being watched buffers for real; the two
// behind that hold their metadata, which is what makes their first frame
// immediate without four more streams downloading in full.
const PRELOAD_STEPS = 3; // steps ahead kept loaded, per panel
const BUFFER_STEPS = 1; // of those, how many buffer rather than just resolve
const RENDER_AHEAD = 5; // slides ahead kept materialized in the DOM
const HISTORY_COUNT = 5; // slides behind kept materialized in the DOM
const PREFETCH_PAGES = 2; // pages of JSON to keep buffered ahead of the active slide
@@ -453,7 +460,9 @@ App.feed = App.feed || {};
// `opts.silent` plays without sound whatever the panel's own switch says --
// for a panel that is only half on screen (see peek), whose audio would
// otherwise talk over the one still being watched.
// otherwise talk over the one still being watched. `opts.buffer` asks for a
// real buffer rather than metadata alone, for a step close enough that it
// is about to be swiped to.
const loadSlideSource = function(slide, videoData, autoplay, opts) {
const video = slide.querySelector('.feed-video');
if (!video) return;
@@ -506,11 +515,11 @@ App.feed = App.feed || {};
const isHls = App.videos.classifySource(resolved).isHls;
video.muted = silent || slide._muted !== false;
// The step being watched buffers properly; the one preloaded behind it
// only needs enough to start instantly on the swipe. With four panes
// that is the difference between four extra streams downloading and
// four holding a few seconds each.
video.preload = autoplay ? 'auto' : 'metadata';
// The step being watched and the one right behind it buffer properly,
// so a swipe lands on video that is already there. Steps further ahead
// take their metadata only: enough to start without delay, without four
// more streams downloading in full behind them.
video.preload = (autoplay || (opts && opts.buffer)) ? 'auto' : 'metadata';
video._tearingDown = false;
applyResume(video, videoData && videoData.id, resolved.isLive);
@@ -534,11 +543,13 @@ App.feed = App.feed || {};
video._hlsPlayer = hls;
hls.loadSource(streamUrl);
hls.attachMedia(video);
if (!autoplay && split) {
if (!autoplay && split && !(opts && opts.buffer)) {
// Buffered enough to start instantly, then stopped: four
// panels' worth of hls.js all fetching and demuxing ahead is
// work for a step the reader has not swiped to yet. loadSlide-
// Source runs again with autoplay when they do.
// work for a step the reader has not swiped to yet. The step
// right behind the active one is exempt -- it is the one about
// to be swiped to, and maxBufferLength stops it on its own.
// loadSlideSource runs again with autoplay when they do.
hls.on(HlsLib.Events.FRAG_BUFFERED, function once() {
hls.off(HlsLib.Events.FRAG_BUFFERED, once);
if (video._hlsPlayer === hls) hls.stopLoad();
@@ -955,10 +966,12 @@ App.feed = App.feed || {};
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
// any missing ones inside it, and sizes the top spacer to stand in for the
// slides above the window.
// Which steps are materialised. Steps ahead/behind are counted in steps,
// but each one now costs a whole step's worth of <video> elements, so
// splitting four ways should not put four times as many in the document:
// the window narrows as panes are added.
// Which steps are materialised. Behind, the window narrows as panes are
// added -- history is the cheap thing to give up. Ahead it does not: what
// is preloaded has to be in the document to be preloaded at all, so the
// window always reaches at least PRELOAD_STEPS forward however many panels
// there are. A four-way split therefore holds four times as many elements
// as a single panel, which is the price of four panels never waiting.
//
// The top spacer stands in for the steps below `start`, so anything that
// sizes the spacer has to agree with this exactly -- disagreeing by a step
@@ -972,7 +985,7 @@ App.feed = App.feed || {};
return {
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
end: Math.min(stepCount() - 1,
activeIndex + Math.max(1, Math.round(RENDER_AHEAD / per)))
activeIndex + Math.max(PRELOAD_STEPS, Math.round(RENDER_AHEAD / per)))
};
};
@@ -1002,13 +1015,38 @@ App.feed = App.feed || {};
if (spacer) spacer.style.height = `${start * slideHeight()}px`;
};
// Once the active slide gets within PREFETCH_PAGES of the end of the loaded
// JSON, pull the next page so the buffer stays ahead of the rendered window.
// The videos the next few swipes will want, resolved now and all at once.
// Resolving is a round trip per video (yt-dlp, on the backend), and leaving
// it until load time put that round trip between a panel and its first
// frame -- with four panels, four of them, arriving one after another. They
// are deduped per video, so asking early costs nothing but the request, and
// asking for all of them at once is the point: they run in parallel.
const warmFormats = function(activeIndex) {
if (!App.videos || typeof App.videos.ensureFormats !== 'function') return;
const videos = state.loadedVideos || [];
const per = paneCount();
const from = Math.max(0, activeIndex * per);
const to = Math.min(videos.length, from + per * (PRELOAD_STEPS + 1));
for (let i = from; i < to; i++) {
if (videos[i]) App.videos.ensureFormats(videos[i]);
}
};
// Keeps the JSON buffer ahead of what the feed is about to eat. A step of N
// panels consumes N videos per swipe, so the same page of listing goes N
// times as fast -- the buffer it needs, and the number of pages worth having
// on the wire at once, both scale with the split.
const prefetchIfNeeded = function(activeIndex) {
const per = paneCount();
const perPage = state.perPage || 12;
const total = (state.loadedVideos || []).length;
const bufferAhead = total - 1 - ((activeIndex + 1) * paneCount() - 1);
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
&& state.hasNextPage && !state.isLoading) {
const bufferAhead = total - 1 - ((activeIndex + 1) * per - 1);
// Everything the rendered window will reach into, plus room to grow.
const needed = Math.max(PREFETCH_PAGES * perPage, per * (PRELOAD_STEPS + 2));
if (App.videos.setPrefetchDepth) {
App.videos.setPrefetchDepth(Math.ceil(needed / perPage));
}
if (bufferAhead < needed && state.hasNextPage && !state.isLoading) {
// The feed is its own reader: the grid's scroll position says
// nothing about whether it needs the next page.
App.videos.loadVideos({ force: true });
@@ -1016,7 +1054,7 @@ App.feed = App.feed || {};
};
// Promotes the slide at `index` to active: syncs the window, updates the
// active styling, plays it, preloads the next PRELOAD_COUNT, and tears down
// active styling, plays it, preloads the next PRELOAD_STEPS, and tears down
// playback for everything else in the window.
const setActive = function(index) {
const clamped = clampIndex(index);
@@ -1044,14 +1082,14 @@ App.feed = App.feed || {};
slidesByIndex.forEach((slide, i) => {
if (i === clamped) return;
// Same floor, same reason: at least the next step is preloaded, so
// every panel has its next video ready before the swipe. It is
// preloaded for *all* of that step's panes, which is what makes the
// guarantee hold per panel rather than only for the first.
const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
const ahead = i - clamped;
panesOf(slide).forEach((pane) => {
if (i > clamped && i <= clamped + preloadAhead) {
loadSlideSource(pane, pane._videoData, false);
if (ahead > 0 && ahead <= PRELOAD_STEPS) {
// Every panel of the step, started together: what used to
// make them start one after another is that only the first
// step ahead was loaded, so each panel did its resolving
// and buffering at the moment it was swiped to.
loadSlideSource(pane, pane._videoData, false, { buffer: ahead <= BUFFER_STEPS });
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
// Recently-watched panes stay loaded but paused so scrolling
// back resumes seamlessly from where it was paused.
@@ -1063,6 +1101,7 @@ App.feed = App.feed || {};
});
updateAutoPiPTarget();
warmFormats(clamped);
prefetchIfNeeded(clamped);
};
@@ -1729,6 +1768,9 @@ App.feed = App.feed || {};
container.setAttribute('aria-hidden', 'true');
document.body.classList.remove('feed-mode-open');
document.body.style.overflow = 'auto';
// The grid reveals a page at a time and is back in charge of the
// buffer: the feed's deeper one was for swipes eating N videos each.
if (App.videos.setPrefetchDepth) App.videos.setPrefetchDepth(1);
App.feed.updateToggleButton();
};