From acfffb3a915683b5ee44523fdfe23f2c955fe56d Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 5 Sep 2026 16:32:07 +0000 Subject: [PATCH] Prefetch the next page and hold it until the tail rows A page used to be requested at the moment the reader hit the bottom, so the cards that appeared were empty frames filling in as their thumbnails arrived. Now the next page is fetched as soon as the current one renders and its thumbnails are decoded off-screen (low priority, started on an idle callback, so warming never competes with what's on screen), then held until the second-to-last row comes into view -- at which point the cards appear already finished, and the page after that starts loading. The two loaders are split into fetch-a-batch and commit-a-batch so the prefetcher and the on-demand path share them; a held batch is dropped when the result set changes (search, channel, filters). Three things this surfaced, all handled: loadVideos awaited the in-flight prefetch before raising state.isLoading, so every caller that arrived meanwhile sailed past the guard and started a duplicate page load, each one re-filling the viewport and calling back in. On a short desktop page that amplified until the tab stopped responding. A guard now covers the whole call. The reveal test can't be "the topmost visible card is in the last two rows": a desktop viewport shows several rows at once, so the reader would reach the end of the list without it ever passing. It's now "the second-to-last row has come into view", measured against the layout. The reels feed pulls pages through the same entry point, where the grid's scroll position means nothing -- it (and the Load more button) now pass force, which skips the hold. Verified in headless Chrome: page 2 fetched and all 12 of its thumbnails warmed while it was still hidden, grid still at 12 cards; revealed on the second-to-last row (phone: viewport bottom 4337px vs row at 4335px; desktop 4-col: 1704px vs 1687px) with its images already decoded; feed paging, search reset, rotation, momentum and playback all still good. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd --- frontend/js/feed.js | 4 +- frontend/js/main.js | 2 +- frontend/js/videos.js | 302 ++++++++++++++++++++++++++++++++---------- 3 files changed, 234 insertions(+), 74 deletions(-) diff --git a/frontend/js/feed.js b/frontend/js/feed.js index b2d66a4..eddf0dd 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -623,7 +623,9 @@ App.feed = App.feed || {}; const bufferAhead = total - 1 - activeIndex; if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12) && state.hasNextPage && !state.isLoading) { - App.videos.loadVideos(); + // 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 }); } }; diff --git a/frontend/js/main.js b/frontend/js/main.js index 0688077..aeee447 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -20,7 +20,7 @@ window.App = window.App || {}; const loadMoreBtn = document.getElementById('load-more-btn'); if (loadMoreBtn) { loadMoreBtn.onclick = () => { - App.videos.loadVideos(); + App.videos.loadVideos({ force: true }); }; } diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 2711902..5583fd9 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -163,7 +163,7 @@ App.videos = App.videos || {}; // Fetches one page from every channel in a group and zips the results // together round-robin so the feed alternates between sources instead of // running through one channel's videos before moving to the next. - App.videos.loadGroupVideos = async function(session) { + const fetchGroupBatch = async function(session, signal) { const group = session.channel; const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; @@ -189,72 +189,48 @@ App.videos = App.videos || {}; const active = state.groupCursors.channels.filter((cursor) => cursor.hasNextPage); if (active.length === 0) { - state.hasNextPage = false; - App.videos.updateLoadMoreState(); - return; + return { items: [], hasNextPage: false }; } - try { - state.isLoading = true; - App.videos.updateLoadMoreState(); - state.currentLoadController = new AbortController(); + const results = await Promise.all(active.map(async (cursor) => { + const response = await fetch('/api/videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel: cursor.id, + query: query || "", + page: cursor.page, + perPage: GROUP_CHANNEL_PAGE_SIZE, + server: session.server + }), + signal: signal + }); + const data = await response.json(); + const items = data && Array.isArray(data.items) ? data.items : []; + cursor.page++; + cursor.hasNextPage = items.length > 0 && (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true); + return items; + })); - const results = await Promise.all(active.map(async (cursor) => { - const response = await fetch('/api/videos', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - channel: cursor.id, - query: query || "", - page: cursor.page, - perPage: GROUP_CHANNEL_PAGE_SIZE, - server: session.server - }), - signal: state.currentLoadController.signal - }); - const data = await response.json(); - const items = data && Array.isArray(data.items) ? data.items : []; - cursor.page++; - cursor.hasNextPage = items.length > 0 && (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true); - return items; - })); - - const interleaved = []; - const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0); - for (let i = 0; i < maxLen; i++) { - results.forEach((items) => { - if (items[i]) interleaved.push(items[i]); - }); - } - - App.videos.renderVideos({ items: interleaved }); - state.hasNextPage = state.groupCursors.channels.some((cursor) => cursor.hasNextPage); - App.videos.ensureViewportFilled(); - } catch (err) { - if (err.name !== 'AbortError') { - console.error("Failed to load group videos:", err); - } - } finally { - state.isLoading = false; - state.currentLoadController = null; - App.videos.updateLoadMoreState(); + const interleaved = []; + const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0); + for (let i = 0; i < maxLen; i++) { + results.forEach((items) => { + if (items[i]) interleaved.push(items[i]); + }); } + + return { + items: interleaved, + hasNextPage: state.groupCursors.channels.some((cursor) => cursor.hasNextPage) + }; }; - // Fetches the next page of videos and renders them into the grid. - App.videos.loadVideos = async function() { - const session = App.storage.getSession(); - if (!session || !session.channel) return; - if (state.isLoading || !state.hasNextPage) return; - - if (session.channel.isGroup) { - return App.videos.loadGroupVideos(session); - } - + const fetchChannelBatch = async function(session, signal) { const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; - let body = { + const body = { channel: session.channel.id, query: query || "", page: state.currentPage, @@ -270,28 +246,183 @@ App.videos = App.videos || {}; } }); + const response = await fetch('/api/videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal + }); + const videos = await response.json(); + state.currentPage++; + return { + items: videos && Array.isArray(videos.items) ? videos.items : [], + hasNextPage: videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true + }; + }; + + const fetchNextBatch = function(session, signal) { + return session.channel.isGroup + ? fetchGroupBatch(session, signal) + : fetchChannelBatch(session, signal); + }; + + // --------------------------------------------------------------------- + // Next-page prefetch + // + // The page after the visible one is fetched, and its thumbnails decoded, + // as soon as the current one renders -- but it is held back until the + // reader reaches the second-to-last row (see the virtualizer's update()). + // So the cards that come into view are complete on arrival instead of + // shimmering their way in while the request is still on the wire. + // --------------------------------------------------------------------- + const prefetch = { + batch: null, // fetched + warmed, waiting for the reader + inFlight: null, // promise of the fetch currently running + controller: null + }; + + // One slow thumbnail must not hold a whole page back. + const WARM_TIMEOUT_MS = 8000; + + const idle = function(fn) { + if (typeof requestIdleCallback === 'function') requestIdleCallback(fn, { timeout: 1000 }); + else setTimeout(fn, 0); + }; + + const warmThumbnails = function(items) { + const urls = (items || []).map((v) => v && v.thumb).filter(Boolean); + if (!urls.length) return Promise.resolve(); + const loads = urls.map((url) => new Promise((resolve) => { + // Off-screen and not needed yet: low priority, started when the + // browser is idle, so warming a page the reader hasn't reached + // never competes with the cards (or requests) in front of them. + idle(() => { + const img = new Image(); + img.decoding = 'async'; + img.fetchPriority = 'low'; + img.onload = resolve; + img.onerror = resolve; // the card's own retry handles failures + img.src = url; + }); + })); + return Promise.race([ + Promise.all(loads), + new Promise((resolve) => setTimeout(resolve, WARM_TIMEOUT_MS)) + ]); + }; + + const commitBatch = function(batch) { + if (!batch) return; + App.videos.renderVideos({ items: batch.items }); + state.hasNextPage = batch.hasNextPage; + App.videos.updateLoadMoreState(); + App.videos.ensureViewportFilled(); + App.videos.prefetchNextBatch(); // stay one page ahead again + }; + + App.videos.prefetchNextBatch = function() { + if (prefetch.batch || prefetch.inFlight) return Promise.resolve(); + if (!state.hasNextPage) return Promise.resolve(); + const session = App.storage.getSession(); + if (!session || !session.channel) return Promise.resolve(); + + prefetch.controller = new AbortController(); + const signal = prefetch.controller.signal; + prefetch.inFlight = (async () => { + try { + const batch = await fetchNextBatch(session, signal); + await warmThumbnails(batch.items); + if (!signal.aborted) prefetch.batch = batch; + } catch (err) { + if (err.name !== 'AbortError') { + console.error("Failed to prefetch videos:", err); + } + } finally { + prefetch.inFlight = null; + prefetch.controller = null; + } + })(); + return prefetch.inFlight; + }; + + // Called by the virtualizer once the reader is within two rows of the end. + App.videos.releasePrefetched = function() { + if (!prefetch.batch || state.isLoading) return false; + const batch = prefetch.batch; + prefetch.batch = null; + commitBatch(batch); + return true; + }; + + // Drops anything in flight or held, for when the result set changes out + // from under it (new search, channel, or filters). + App.videos.resetPrefetch = function() { + if (prefetch.controller) prefetch.controller.abort(); + prefetch.batch = null; + prefetch.inFlight = null; + prefetch.controller = null; + }; + + // Is the reader actually out of content? True when nothing is rendered yet, + // when the page is too short to scroll, or when they're already in the last + // two rows. Anything else -- the sentinel firing from a stale observation at + // startup, most of all -- means the prefetched page stays held. + const needsContentNow = function() { + if (!state.loadedVideos.length) return true; + const docHeight = document.documentElement.scrollHeight; + if (docHeight <= (window.innerHeight || 0) + 120) return true; + return !!(App.virtualGrid && App.virtualGrid.isNearEnd && App.virtualGrid.isNearEnd()); + }; + + // Guards the whole of loadVideos, including the awaits before the fetch + // starts. state.isLoading can't do this job: it is the UI's "a request is + // out" signal and is only raised around the fetch itself, so every caller + // that arrived while we were waiting on the prefetch would sail past it and + // start a duplicate page load -- each of which renders, refills the + // viewport, and calls back in here. On a short desktop page that amplifies + // until the tab stops responding. + let loadRunning = false; + + // Renders the next page: the prefetched one when it's ready (the common + // case), otherwise fetched here and rendered as it lands. `opts.force` is for + // callers that know they need content and can't be judged by where the grid + // is scrolled -- the reels feed running out of slides, or the Load more + // button being pressed. + App.videos.loadVideos = async function(opts) { + const force = !!(opts && opts.force); + const session = App.storage.getSession(); + if (!session || !session.channel) return; + if (loadRunning || state.isLoading) return; + loadRunning = true; + try { + if (prefetch.batch || prefetch.inFlight) { + // A page is already here or on its way; revealing it before the + // reader gets near the end would defeat the point of holding it. + if (!force && !needsContentNow()) return; + if (App.videos.releasePrefetched()) return; + if (prefetch.inFlight) { + // Wait for it rather than asking for the same page twice -- + // the fetch advances the page cursor, so a second request + // here would skip a page entirely. + await prefetch.inFlight; + if (App.videos.releasePrefetched()) return; + } + } + if (!state.hasNextPage) return; + state.isLoading = true; App.videos.updateLoadMoreState(); state.currentLoadController = new AbortController(); - const response = await fetch('/api/videos', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body), - signal: state.currentLoadController.signal - }); - const videos = await response.json(); - App.videos.renderVideos(videos); - state.hasNextPage = videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true; - state.currentPage++; - App.videos.ensureViewportFilled(); + const batch = await fetchNextBatch(session, state.currentLoadController.signal); + state.isLoading = false; + commitBatch(batch); } catch (err) { if (err.name !== 'AbortError') { console.error("Failed to load videos:", err); } } finally { + loadRunning = false; state.isLoading = false; state.currentLoadController = null; App.videos.updateLoadMoreState(); @@ -486,6 +617,8 @@ App.videos = App.videos || {}; clearBtn.disabled = !hasValue; } } + // The held/in-flight page belongs to the old result set. + App.videos.resetPrefetch(); state.currentPage = 1; state.hasNextPage = true; state.renderedVideoIds.clear(); @@ -505,6 +638,8 @@ App.videos = App.videos || {}; state.currentLoadController = null; state.isLoading = false; } + // The held/in-flight page belongs to the old result set. + App.videos.resetPrefetch(); state.currentPage = 1; state.hasNextPage = true; state.renderedVideoIds.clear(); @@ -787,6 +922,29 @@ App.videos = App.videos || {}; if (!heldAnchor) { lastAnchor = anchorIndex >= 0 ? { index: anchorIndex, offsetTop: anchorTop - viewTop } : null; } + // Hand the prefetched page over once the reader reaches the + // second-to-last row: it was fetched and its thumbnails decoded a + // page ago, so the new cards appear finished rather than loading. + if (App.videos.releasePrefetched && isNearEnd(viewTop, vh)) { + App.videos.releasePrefetched(); + } + }; + + // True once the second-to-last row has come into view. Deliberately not + // "the topmost visible card is in the last two rows": a desktop viewport + // shows several rows at once, so by that measure the reader would hit the + // bottom of the list without the test ever passing. + const isNearEnd = function(viewTop, vh) { + const el = grid(); + if (!el || !cols || !layout.length) return false; + if (viewTop == null) viewTop = -el.getBoundingClientRect().top; + if (vh == null) vh = window.innerHeight || 800; + let tailTop = Infinity; + for (let i = Math.max(0, layout.length - cols * 2); i < layout.length; i++) { + const l = layout[i]; + if (l && l.top < tailTop) tailTop = l.top; + } + return tailTop !== Infinity && viewTop + vh >= tailTop; }; const scheduleUpdate = function() { @@ -941,7 +1099,7 @@ App.videos = App.videos || {}; relayout(); }; - return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset }; + return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd }; })(); App.videos.updateLoadMoreState = function() {