diff --git a/frontend/js/videos.js b/frontend/js/videos.js index b2c531e..8bf26c9 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -1331,15 +1331,60 @@ App.videos = App.videos || {}; let builtCards = 0; let recycledCards = 0; + // --------------------------------------------------------------- + // Flinging + // + // A card that flies past costs far more than it costs to build. Its + // thumbnail is a request, a decode and a height correction; its title + // is a forced layout; its formats are a yt-dlp extraction on the + // server. Every one of those is work for a video the reader has not + // looked at, and during a fast scroll there are dozens of them. + // + // So a mount while flinging only *places* the card -- it takes its + // space in the layout, at the right size, with its text. Everything + // that costs is deferred to the moment the scroll settles, and only + // for the cards still on screen by then. Anything scrolled past in the + // meantime is unmounted having cost almost nothing. + // --------------------------------------------------------------- + const FLING_PX_PER_MS = 1.6; // ~1600px/s, well past a deliberate scroll + const SETTLE_MS = 140; + let flinging = false; + let lastScrollY = 0; + let lastScrollAt = 0; + let settleTimer = null; + const unfilled = new Set(); // mounted indices still waiting to be filled + + const noteScroll = function() { + const now = performance.now(); + const y = window.scrollY; + const dt = now - lastScrollAt; + if (dt > 0 && lastScrollAt) { + flinging = (Math.abs(y - lastScrollY) / dt) > FLING_PX_PER_MS; + } + lastScrollY = y; + lastScrollAt = now; + if (settleTimer) clearTimeout(settleTimer); + settleTimer = setTimeout(settle, SETTLE_MS); + }; + + const settle = function() { + settleTimer = null; + flinging = false; + unfilled.forEach((i) => { + const card = mounted.get(i); + if (card) fill(i, card, state.loadedVideos[i]); + }); + unfilled.clear(); + }; + const acquire = function(v) { const card = pool.pop(); if (!card) { builtCards++; - return App.videos.buildCard(v); + return App.videos.buildCard(v, { skipThumbnail: true }); } recycledCards++; App.videos.bindCard(card, v); - App.videos.attachThumbnail(cardRefs(card).img, v.thumb); return card; }; @@ -1380,9 +1425,19 @@ App.videos = App.videos || {}; } grid().appendChild(card); mounted.set(i, card); + if (flinging) unfilled.add(i); + else fill(i, card, v); + }; + + // The part of a mount that costs: the thumbnail, the title measurement + // and the format resolve the observer triggers. + const fill = function(i, card, v) { + if (!card || !v) return; + unfilled.delete(i); + const img = cardRefs(card).img; + App.videos.attachThumbnail(img, v.thumb); // Once the thumbnail loads, drop the 16:9 placeholder so it shows at // its true aspect ratio, clear the shimmer, then correct the height. - const img = card.querySelector('img'); if (img) { const reveal = () => { // Release nulls this, so a reveal already queued for a frame @@ -1416,6 +1471,7 @@ App.videos = App.videos || {}; const card = mounted.get(i); if (!card) return; mounted.delete(i); + unfilled.delete(i); release(card); }; @@ -1485,6 +1541,11 @@ App.videos = App.videos || {}; return tailTop !== Infinity && viewTop + vh >= tailTop; }; + const onScroll = function() { + noteScroll(); + scheduleUpdate(); + }; + const scheduleUpdate = function() { if (rafPending) return; rafPending = true; @@ -1601,7 +1662,7 @@ App.videos = App.videos || {}; if (initialized) return; initialized = true; settleWidth = window.innerWidth || 0; - window.addEventListener('scroll', scheduleUpdate, { passive: true }); + window.addEventListener('scroll', onScroll, { passive: true }); // orientationchange fires first (before the viewport metrics change), // which is exactly when the pre-rotation anchor is still valid. window.addEventListener('orientationchange', beginSettle); @@ -1620,6 +1681,8 @@ App.videos = App.videos || {}; // its own cards, and these are exactly the right shape for it. mounted.forEach((card, i) => unmount(i)); mounted.clear(); + unfilled.clear(); + flinging = false; revealed.clear(); // new result set should animate in again layout.length = 0; colBottoms = []; @@ -1641,7 +1704,8 @@ App.videos = App.videos || {}; }; const stats = function() { - return { built: builtCards, recycled: recycledCards, pooled: pool.length }; + return { built: builtCards, recycled: recycledCards, pooled: pool.length, + unfilled: unfilled.size, flinging: flinging }; }; return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd, stats }; diff --git a/tests/smoke_grid.py b/tests/smoke_grid.py index 6d62a48..fbf76c8 100644 --- a/tests/smoke_grid.py +++ b/tests/smoke_grid.py @@ -170,7 +170,16 @@ def check_cards(c, cards, phase): def main(): c = Checks() with sync_playwright() as p: - browser = p.chromium.launch(args=["--no-sandbox"]) + # Lean launch flags: this runs alongside the app's own server, and a + # default Chromium spikes hard enough at startup to get itself killed on + # a constrained box. + browser = p.chromium.launch(args=[ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--renderer-process-limit=1", + "--js-flags=--max-old-space-size=512", + ]) page = browser.new_page(viewport={"width": 1400, "height": 1000}) boot(page)