From 246c650db4a811d883502b23ee5d82b123229a75 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 15:44:36 -0700 Subject: [PATCH] Discover: fix grid pagination + session cache + responsive pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pagination (regression): the IntersectionObserver sentinel only fires on intersection *changes*, so a short first page kept it on-screen and it never re-fired — stuck at 20 — and I'd hidden the Load more button whenever IO exists, leaving no fallback. Now: - Load more button is always shown while there's more (the reliable control). - Auto-load is self-correcting: track sentinel visibility and, after each load, pull again via rAF if it's still on-screen (rAF lets the observer update first so a cached category doesn't load every page at once). - page increment moved inside loadGrid so the button + sentinel can't double-bump. Caching: cachedFetch() memoizes /discover/list responses per URL for the session (rails + grid pages) — revisits, paging and reopening a category are instant and don't re-hit TMDB. Responsive: small-screen pass for the Browse panel, hero (height/title/actions full-width), grid header wrap, and the trailer close button (kept on-screen). Also fixed the ambient layer's 130% width that could cause horizontal scroll. --- webui/static/video/video-discover.js | 40 ++++++++++++++++++++-------- webui/static/video/video-side.css | 25 ++++++++++++++--- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js index ade62b01..d055c866 100644 --- a/webui/static/video/video-discover.js +++ b/webui/static/video/video-discover.js @@ -25,6 +25,8 @@ sel: { kind: 'movie', genre: '', decade: '', providers: '', sort: 'popularity.desc' }, // Browse panel }; var AUTO = (typeof IntersectionObserver !== 'undefined'); // infinite-scroll capable + var sentinelVisible = false; + var listCache = {}; // session cache of /discover/list responses, keyed by URL function $(s, r) { return (r || document).querySelector(s); } function esc(s) { @@ -34,6 +36,15 @@ } function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; } function idMap(list) { var m = {}; (list || []).forEach(function (g) { m[(g.name || '').toLowerCase()] = g.id; }); return m; } + // Session-memoized GET → JSON for the list endpoint, so revisiting Discover / + // paging / reopening a category is instant and doesn't re-hit TMDB. (Ownership + // is re-stamped server-side; caching for a session is fine.) + function cachedFetch(url) { + if (listCache[url]) return Promise.resolve(listCache[url]); + return fetch(url, { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d) listCache[url] = d; return d; }); + } // ── the rail stack (personalized + curated + genre + decade) ────────────── var CURATED = [ @@ -301,8 +312,7 @@ shelf.setAttribute('data-vdsc-loaded', '1'); var rail = $('[data-vdsc-rail]', shelf); // 2 pages (~40 items) so a rail still looks full after 'Hide owned'. - fetch(LIST_URL + '?' + shelf.getAttribute('data-vdsc-q') + '&pages=2', { headers: { Accept: 'application/json' } }) - .then(function (r) { return r.ok ? r.json() : null; }) + cachedFetch(LIST_URL + '?' + shelf.getAttribute('data-vdsc-q') + '&pages=2') .then(function (d) { var items = (d && d.items) || []; if (!items.length) { shelf.remove(); return; } // drop empty shelves @@ -334,11 +344,11 @@ var c = state.cat; if (c.busy) return; c.busy = true; + if (!reset) c.page++; // advance to the next page here var more = $('[data-vdsc-more]'); if (more && !reset) { more.disabled = true; more.textContent = 'Loading…'; } var ld = reset ? $('[data-vdsc-grid-loading]') : $('[data-vdsc-more-loading]'); if (ld) ld.classList.remove('hidden'); - fetch(LIST_URL + '?' + c.q + '&page=' + c.page, { headers: { Accept: 'application/json' } }) - .then(function (r) { return r.ok ? r.json() : null; }) + cachedFetch(LIST_URL + '?' + c.q + '&page=' + c.page) .then(function (d) { c.busy = false; if (ld) ld.classList.add('hidden'); @@ -348,9 +358,9 @@ var empty = $('[data-vdsc-grid-empty]'); if (empty) empty.classList.toggle('hidden', !(reset && !items.length)); c.hasMore = c.paginates && items.length >= 18; - // With infinite scroll the sentinel pulls more — keep the button only - // as a no-IntersectionObserver fallback. - if (more) { more.textContent = 'Load more'; more.disabled = false; more.classList.toggle('hidden', AUTO || !c.hasMore); } + // Button is always the reliable control; the sentinel auto-loads on top. + if (more) { more.textContent = 'Load more'; more.disabled = false; more.classList.toggle('hidden', !c.hasMore); } + maybeAutoLoad(); // keep filling while the sentinel stays in view }) .catch(function () { c.busy = false; @@ -358,6 +368,15 @@ if (more) { more.textContent = 'Load more'; more.disabled = false; } }); } + // Self-correcting infinite scroll: if the sentinel is still on-screen after a + // load (short page), pull the next one. rAF lets the observer update first so + // a fully-cached category doesn't load every page at once. + function maybeAutoLoad() { + if (!(state.mode === 'grid' && sentinelVisible && state.cat.hasMore && !state.cat.busy)) return; + requestAnimationFrame(function () { + if (state.mode === 'grid' && sentinelVisible && state.cat.hasMore && !state.cat.busy) loadGrid(false); + }); + } function activeChipText(chipset) { var c = $('[data-vdsc-chipset="' + chipset + '"] .vdsc-chip--on'); return (c && c.getAttribute('data-val')) ? c.textContent : ''; @@ -452,7 +471,7 @@ var apply = $('[data-vdsc-apply]'); if (apply) apply.addEventListener('click', applyFilter); var clear = $('[data-vdsc-clear]'); if (clear) clear.addEventListener('click', closeCategory); - var more = $('[data-vdsc-more]'); if (more) more.addEventListener('click', function () { state.cat.page++; loadGrid(false); }); + var more = $('[data-vdsc-more]'); if (more) more.addEventListener('click', function () { loadGrid(false); }); var hide = $('[data-vdsc-hideowned]'); if (hide) { @@ -467,9 +486,8 @@ var sentinel = $('[data-vdsc-sentinel]'); if (sentinel && AUTO) { new IntersectionObserver(function (entries) { - if (entries[0].isIntersecting && state.mode === 'grid' && state.cat.hasMore && !state.cat.busy) { - state.cat.page++; loadGrid(false); - } + sentinelVisible = entries[0].isIntersecting; + maybeAutoLoad(); }, { rootMargin: '600px 0px' }).observe(sentinel); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 7dd27e15..4ef749e2 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2250,11 +2250,11 @@ body[data-side="video"] #soulsync-toggle { display: none; } } /* ── Discover v2: ambient glow + see-all + grid head + hide-owned + polish ─── */ -.vdsc-page { position: relative; } +.vdsc-page { position: relative; overflow-x: clip; } .vdsc-hero, .vdsc-browse, .vdsc-shelves, .vdsc-grid-wrap { position: relative; z-index: 1; } /* a faint page-top color bleed that follows the current hero slide's hue */ -.vdsc-amb { position: absolute; top: -40px; left: 50%; transform: translateX(-50%); z-index: 0; - width: 130%; height: 580px; pointer-events: none; opacity: 0.5; transition: opacity 0.7s ease; +.vdsc-amb { position: absolute; top: -40px; left: 0; z-index: 0; + width: 100%; height: 580px; pointer-events: none; opacity: 0.5; transition: opacity 0.7s ease; background: radial-gradient(58% 80% at 50% 0%, hsla(var(--vdsc-amb, 230), 82%, 55%, 0.22), transparent 70%); } /* "See all" → opens the rail as a paged grid */ @@ -2396,3 +2396,22 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdsc-btn:focus-visible, .vdsc-hero-trailer:focus-visible, .vdsc-dot:focus-visible, .vdsc-toggle:focus-within, .vdsc-page .vsr-card:focus-visible { outline: 2px solid rgba(var(--accent-rgb, 88 101 242), 0.9); outline-offset: 2px; } + +/* ── small screens ────────────────────────────────────────────────────────── */ +@media (max-width: 640px) { + .vdsc-browse { padding: 13px 13px 15px; margin-bottom: 22px; } + .vdsc-browse-top { gap: 10px; } + .vdsc-shelf { margin-bottom: 24px; } + .vdsc-shelf-title { font-size: 16px; } + .vdsc-grid-head { flex-wrap: wrap; gap: 10px; } + .vdsc-grid-title { font-size: 18px; } + .vdsc-hero { height: 300px; margin-bottom: 20px; } + .vdsc-hero-body { left: 18px; right: 18px; bottom: 18px; } + .vdsc-hero-title { font-size: 23px; } + .vdsc-hero-ov { -webkit-line-clamp: 3; } + .vdsc-hero-actions { gap: 9px; } + .vdsc-hero-actions .discog-submit-btn, .vdsc-hero-trailer { flex: 1 1 auto; justify-content: center; } + .vdsc-dots { right: 16px; bottom: 16px; } + /* keep the trailer close button on-screen (it sits above the frame on desktop) */ + .vdsc-tr-close { top: 6px; right: 6px; background: rgba(0, 0, 0, 0.65); } +}