video discover: organise rails into fixed, labelled sections (stable order)
The three async personalized loaders each did insertAdjacentHTML('afterbegin'), so 'Recommended
for you' / 'More like X' / gap rails landed in whatever order their fetch finished — the top of
the page reshuffled every load, and taste-based rails were scattered through generic ones.
Now Discover renders 6 authored groups in a fixed order, each with a header:
For you · Finish your collection · More of what you like · Trending & popular ·
Browse by genre · Hidden gems & more
Each async loader fills its own group's body (deterministic position) instead of racing to the
top; async-only groups (gaps) stay hidden until filled, and a group whose rails all drop out is
pruned. Extracted lazyShelfHtml/filledShelfHtml/shelfNav helpers; lazy-load, stagger, gen-guard,
see-all and scroll arrows all unchanged.
This commit is contained in:
parent
09a1646a6a
commit
82cbc22577
2 changed files with 98 additions and 75 deletions
|
|
@ -94,30 +94,41 @@
|
|||
{ title: 'Hindi Cinema', q: 'kind=movie&sort=popularity.desc&lang=hi' },
|
||||
];
|
||||
|
||||
function buildShelfList() {
|
||||
var out = [], used = {};
|
||||
var gm = idMap(state.genres.movie), gs = idMap(state.genres.show);
|
||||
// personalized first — seeded from what you actually own
|
||||
// The page is organised into a FIXED, authored sequence of groups (each with a header).
|
||||
// Static rails are placed into their group here; the async personalized rows fill their
|
||||
// group's body when they arrive — so the on-screen order is stable no matter which fetch
|
||||
// returns first. The order of the returned array IS the order on screen.
|
||||
function buildSections() {
|
||||
var gm = idMap(state.genres.movie), gs = idMap(state.genres.show), used = {};
|
||||
var taste = [];
|
||||
(state.taste.movie || []).slice(0, 3).forEach(function (name) {
|
||||
var id = gm[name.toLowerCase()];
|
||||
if (id != null) { out.push({ title: 'Because you like ' + name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); used['m:' + name.toLowerCase()] = 1; }
|
||||
if (id != null) { taste.push({ title: 'Because you like ' + name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); used['m:' + name.toLowerCase()] = 1; }
|
||||
});
|
||||
(state.taste.show || []).slice(0, 2).forEach(function (name) {
|
||||
var id = gs[name.toLowerCase()];
|
||||
if (id != null) { out.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc' }); }
|
||||
if (id != null) taste.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc' });
|
||||
});
|
||||
// 'On your streaming services' — only when the user has set their subscriptions.
|
||||
var foryou = [];
|
||||
if (state.myProviders && state.myProviders.length) {
|
||||
out.push({ title: 'On your streaming services',
|
||||
foryou.push({ title: 'On your streaming services',
|
||||
q: 'kind=movie&providers=' + state.myProviders.join(',') + '&sort=popularity.desc' });
|
||||
}
|
||||
out = out.concat(CURATED);
|
||||
var genre = [];
|
||||
GENRE_RAILS.forEach(function (name) {
|
||||
var id = gm[name.toLowerCase()];
|
||||
if (id != null && !used['m:' + name.toLowerCase()])
|
||||
out.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
|
||||
genre.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
|
||||
});
|
||||
return out.concat(GEM_RAILS).concat(DECADE_RAILS).concat(FOREIGN_RAILS);
|
||||
// foryou/collection/taste are also fed by async loaders (loadForYou/loadGaps/loadMoreLike).
|
||||
return [
|
||||
{ id: 'foryou', label: 'For you', rails: foryou },
|
||||
{ id: 'collection', label: 'Finish your collection', rails: [] },
|
||||
{ id: 'taste', label: 'More of what you like', rails: taste },
|
||||
{ id: 'popular', label: 'Trending & popular', rails: CURATED },
|
||||
{ id: 'genre', label: 'Browse by genre', rails: genre },
|
||||
{ id: 'corners', label: 'Hidden gems & more', rails: GEM_RAILS.concat(DECADE_RAILS).concat(FOREIGN_RAILS) },
|
||||
];
|
||||
}
|
||||
|
||||
// ── card (mirrors the search title card: owned ribbon + get button) ───────
|
||||
|
|
@ -381,7 +392,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
// ── "More like <owned title>" rails (pre-filled, prepended above the stack) ─
|
||||
// ── "More like <owned title>" rails → the "More of what you like" group (after taste rails) ─
|
||||
function loadMoreLike() {
|
||||
var gen = state.railGen || 0;
|
||||
fetch('/api/video/discover/morelike', { headers: { Accept: 'application/json' } })
|
||||
|
|
@ -389,29 +400,21 @@
|
|||
.then(function (d) {
|
||||
if (gen !== (state.railGen || 0)) return; // a newer rebuild superseded this
|
||||
var rails = (d && d.rails) || [];
|
||||
var host = $('[data-vdsc-shelves]');
|
||||
if (!rails.length || !host) return;
|
||||
var body = $('[data-group-body="taste"]');
|
||||
if (!rails.length || !body) return;
|
||||
var html = rails.map(function (rl) {
|
||||
return '<section class="vdsc-shelf vdsc-shelf--in vdsc-shelf--ml" data-vdsc-loaded="1">' +
|
||||
'<div class="vdsc-shelf-head">' +
|
||||
'<h3 class="vdsc-shelf-title">' + esc(rl.title) + '</h3>' +
|
||||
'<div class="vdsc-shelf-nav">' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left">‹</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right">›</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="vdsc-rail" data-vdsc-rail>' + (rl.items || []).map(card).join('') + '</div>' +
|
||||
'</section>';
|
||||
return filledShelfHtml(rl.title, (rl.items || []).map(card).join(''), 'vdsc-shelf--ml');
|
||||
}).join('');
|
||||
host.insertAdjacentHTML('afterbegin', html);
|
||||
staggerWithin(host);
|
||||
hydrateGet(host);
|
||||
body.insertAdjacentHTML('beforeend', html);
|
||||
revealGroup('taste');
|
||||
staggerWithin(body);
|
||||
hydrateGet(body);
|
||||
})
|
||||
.catch(function () { /* personalization is best-effort */ });
|
||||
}
|
||||
|
||||
// ── "What am I missing?" gap rails — franchises you've started but not finished,
|
||||
// and more from the directors/creators you own the most (prepended on top). ──
|
||||
// ── "What am I missing?" gaps — unfinished franchises + more from directors you own most.
|
||||
// Fills the "Finish your collection" group (async-only; hidden until these arrive). ──
|
||||
function loadGaps() {
|
||||
var gen = state.railGen || 0;
|
||||
fetch('/api/video/discover/gaps', { headers: { Accept: 'application/json' } })
|
||||
|
|
@ -419,28 +422,20 @@
|
|||
.then(function (d) {
|
||||
if (gen !== (state.railGen || 0)) return; // a newer rebuild superseded this
|
||||
var rails = (d && d.rails) || [];
|
||||
var host = $('[data-vdsc-shelves]');
|
||||
if (!rails.length || !host) return;
|
||||
var body = $('[data-group-body="collection"]');
|
||||
if (!rails.length || !body) return;
|
||||
var html = rails.map(function (rl) {
|
||||
return '<section class="vdsc-shelf vdsc-shelf--in vdsc-shelf--gap" data-vdsc-loaded="1">' +
|
||||
'<div class="vdsc-shelf-head">' +
|
||||
'<h3 class="vdsc-shelf-title">' + esc(rl.title) + '</h3>' +
|
||||
'<div class="vdsc-shelf-nav">' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left">‹</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right">›</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="vdsc-rail" data-vdsc-rail>' + (rl.items || []).map(card).join('') + '</div>' +
|
||||
'</section>';
|
||||
return filledShelfHtml(rl.title, (rl.items || []).map(card).join(''), 'vdsc-shelf--gap');
|
||||
}).join('');
|
||||
host.insertAdjacentHTML('afterbegin', html);
|
||||
staggerWithin(host);
|
||||
hydrateGet(host);
|
||||
body.insertAdjacentHTML('beforeend', html);
|
||||
revealGroup('collection');
|
||||
staggerWithin(body);
|
||||
hydrateGet(body);
|
||||
})
|
||||
.catch(function () { /* gaps are best-effort */ });
|
||||
}
|
||||
|
||||
// ── "Recommended for you" — one wall blended from across your library, prepended on top ─
|
||||
// ── "Recommended for you" — one blended wall at the top of the "For you" group ─
|
||||
function loadForYou() {
|
||||
var gen = state.railGen || 0;
|
||||
fetch('/api/video/discover/foryou', { headers: { Accept: 'application/json' } })
|
||||
|
|
@ -448,21 +443,14 @@
|
|||
.then(function (d) {
|
||||
if (gen !== (state.railGen || 0)) return; // a newer rebuild superseded this
|
||||
var items = (d && d.items) || [];
|
||||
var host = $('[data-vdsc-shelves]');
|
||||
if (items.length < 6 || !host) return;
|
||||
var html = '<section class="vdsc-shelf vdsc-shelf--in vdsc-shelf--foryou" data-vdsc-loaded="1">' +
|
||||
'<div class="vdsc-shelf-head">' +
|
||||
'<h3 class="vdsc-shelf-title">Recommended for you</h3>' +
|
||||
'<div class="vdsc-shelf-nav">' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left">‹</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right">›</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="vdsc-rail" data-vdsc-rail>' + items.map(card).join('') + '</div>' +
|
||||
'</section>';
|
||||
host.insertAdjacentHTML('afterbegin', html);
|
||||
staggerWithin(host);
|
||||
hydrateGet(host);
|
||||
var body = $('[data-group-body="foryou"]');
|
||||
if (items.length < 6 || !body) return;
|
||||
// afterbegin: sits above the (static) "On your streaming services" rail in the group.
|
||||
body.insertAdjacentHTML('afterbegin',
|
||||
filledShelfHtml('Recommended for you', items.map(card).join(''), 'vdsc-shelf--foryou'));
|
||||
revealGroup('foryou');
|
||||
staggerWithin(body);
|
||||
hydrateGet(body);
|
||||
})
|
||||
.catch(function () { /* best-effort */ });
|
||||
}
|
||||
|
|
@ -485,25 +473,51 @@
|
|||
}
|
||||
|
||||
// ── shelves (lazy rails) ──────────────────────────────────────────────────
|
||||
function shelfNav(seeAll) {
|
||||
return '<div class="vdsc-shelf-nav">' +
|
||||
(seeAll ? '<button class="vdsc-seeall" type="button" data-vdsc-seeall>See all</button>' : '') +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left">‹</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right">›</button>' +
|
||||
'</div>';
|
||||
}
|
||||
// A lazy rail (skeleton until scrolled into view, then filled by fillShelf).
|
||||
function lazyShelfHtml(sh) {
|
||||
return '<section class="vdsc-shelf" data-vdsc-q="' + esc(sh.q) + '" data-vdsc-title="' + esc(sh.title) + '">' +
|
||||
'<div class="vdsc-shelf-head"><h3 class="vdsc-shelf-title">' + esc(sh.title) + '</h3>' + shelfNav(true) + '</div>' +
|
||||
'<div class="vdsc-rail" data-vdsc-rail>' +
|
||||
'<div class="vdsc-skel">' + Array(8).join('<div class="vdsc-skel-card"></div>') + '</div>' +
|
||||
'</div>' +
|
||||
'</section>';
|
||||
}
|
||||
// A pre-filled rail (async personalized rows already carry their items — no skeleton).
|
||||
function filledShelfHtml(title, itemsHtml, cls) {
|
||||
return '<section class="vdsc-shelf vdsc-shelf--in ' + cls + '" data-vdsc-loaded="1">' +
|
||||
'<div class="vdsc-shelf-head"><h3 class="vdsc-shelf-title">' + esc(title) + '</h3>' + shelfNav(false) + '</div>' +
|
||||
'<div class="vdsc-rail" data-vdsc-rail>' + itemsHtml + '</div>' +
|
||||
'</section>';
|
||||
}
|
||||
function renderShelves() {
|
||||
var host = $('[data-vdsc-shelves]'); if (!host) return;
|
||||
host.innerHTML = buildShelfList().map(function (sh) {
|
||||
return '<section class="vdsc-shelf" data-vdsc-q="' + esc(sh.q) + '" data-vdsc-title="' + esc(sh.title) + '">' +
|
||||
'<div class="vdsc-shelf-head">' +
|
||||
'<h3 class="vdsc-shelf-title">' + esc(sh.title) + '</h3>' +
|
||||
'<div class="vdsc-shelf-nav">' +
|
||||
'<button class="vdsc-seeall" type="button" data-vdsc-seeall>See all</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left">‹</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right">›</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="vdsc-rail" data-vdsc-rail>' +
|
||||
'<div class="vdsc-skel">' + Array(8).join('<div class="vdsc-skel-card"></div>') + '</div>' +
|
||||
'</div>' +
|
||||
host.innerHTML = buildSections().map(function (sec) {
|
||||
var rails = sec.rails.map(lazyShelfHtml).join('');
|
||||
// Groups with no static rails (async-only, e.g. gaps) start hidden — revealed when filled.
|
||||
var emptyCls = sec.rails.length ? '' : ' vdsc-group--empty';
|
||||
return '<section class="vdsc-group' + emptyCls + '" data-group="' + sec.id + '">' +
|
||||
'<h2 class="vdsc-group-head">' + esc(sec.label) + '</h2>' +
|
||||
'<div class="vdsc-group-body" data-group-body="' + sec.id + '">' + rails + '</div>' +
|
||||
'</section>';
|
||||
}).join('');
|
||||
observeShelves();
|
||||
}
|
||||
// Reveal a group's header once an async loader has put rails in it.
|
||||
function revealGroup(id) {
|
||||
var g = $('[data-group="' + id + '"]');
|
||||
if (g) g.classList.remove('vdsc-group--empty');
|
||||
}
|
||||
// Hide a group whose body has no shelves left (every rail failed / returned nothing).
|
||||
function pruneGroup(g) {
|
||||
if (g && !g.querySelector('.vdsc-shelf')) g.classList.add('vdsc-group--empty');
|
||||
}
|
||||
function observeShelves() {
|
||||
if (state.io) state.io.disconnect();
|
||||
var shelves = document.querySelectorAll('.vdsc-shelf');
|
||||
|
|
@ -542,17 +556,18 @@
|
|||
if (!shelf || shelf.getAttribute('data-vdsc-loaded')) return;
|
||||
shelf.setAttribute('data-vdsc-loaded', '1');
|
||||
var rail = $('[data-vdsc-rail]', shelf);
|
||||
var grp = shelf.closest('.vdsc-group');
|
||||
// Normal: 2 pages (~40 items). Hiding owned: let the backend page DEEPER and drop
|
||||
// owned server-side, so a huge library's rail still fills instead of CSS-hiding to ~nothing.
|
||||
var q = shelf.getAttribute('data-vdsc-q') + (isHideOwned() ? '&hide_owned=1' : '&pages=2');
|
||||
cachedFetch(LIST_URL + '?' + q)
|
||||
.then(function (d) {
|
||||
var items = (d && d.items) || [];
|
||||
if (!items.length) { shelf.remove(); return; } // drop empty shelves
|
||||
if (!items.length) { shelf.remove(); pruneGroup(grp); return; } // drop empty shelves
|
||||
if (rail) { rail.innerHTML = items.map(card).join(''); stagger(rail); hydrateGet(rail); }
|
||||
shelf.classList.add('vdsc-shelf--in'); // reveal (cards cascade via --i)
|
||||
})
|
||||
.catch(function () { shelf.remove(); });
|
||||
.catch(function () { shelf.remove(); pruneGroup(grp); });
|
||||
}
|
||||
|
||||
// ── category / filter grid (paged) ────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2290,6 +2290,14 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
|
||||
/* ── shelves (lazy rails) ─────────────────────────────────────────────────── */
|
||||
.vdsc-shelves.hidden, .vdsc-grid-wrap.hidden { display: none; }
|
||||
/* Discover is grouped into labelled sections (For you / Browse by genre / …) for scannability.
|
||||
A group with no rails (async-only, not yet filled, or all rails dropped) is hidden entirely. */
|
||||
.vdsc-group { margin: 0; }
|
||||
.vdsc-group--empty { display: none; }
|
||||
.vdsc-group-head { font-size: 12.5px; font-weight: 800; letter-spacing: 0.5px; text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.5); margin: 30px 0 16px; padding-bottom: 9px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
|
||||
.vdsc-shelf { margin: 0 0 30px; }
|
||||
.vdsc-shelf-head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 13px; }
|
||||
.vdsc-shelf-title { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; color: #fff; margin: 0; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue