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() {