Discover: fix grid pagination + session cache + responsive pass

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.
This commit is contained in:
BoulderBadgeDad 2026-06-16 15:44:36 -07:00
parent 80d504f94d
commit 246c650db4
2 changed files with 51 additions and 14 deletions

View file

@ -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);
}

View file

@ -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); }
}