Video Discover: Top 10 ranked rail + New/Mood/Studios sections + NEW badge
Best-in-class discovery pass (Netflix/Disney-class): - iconic ranked 'Top 10 today' rail (daily trending, big outlined rank numerals, true global chart via lang=any, single un-paged fetch) - 'New & noteworthy' (date-windowed new releases + theatrical pipeline) - 'By mood' (genre AND-combos w/ vote floors: feel-good, mind-bending…) - 'From the studios' (Pixar/Ghibli/A24/Marvel/DreamWorks via company ids) - 'Something different' (quick watches / epics / family) merged with gems - 'NEW' flag on un-owned current-year cards Preserves async-loader group ids (foryou/collection/taste). Empty rails self-prune; wrong studio ids just drop their rail.
This commit is contained in:
parent
bbf7abad1b
commit
a275cbfb7a
3 changed files with 87 additions and 10 deletions
|
|
@ -360,8 +360,8 @@ def register_routes(bp):
|
|||
# thread-safe) so digging 20 pages deep costs ~4 page-latencies, not 20.
|
||||
need_fill = hide_owned or bool(prefer_langs)
|
||||
target = 20 if need_fill else 0
|
||||
if key == "trending":
|
||||
consume(fetch(page))
|
||||
if key in ("trending", "trending_today"):
|
||||
consume(fetch(page)) # a single fixed chart; never paged
|
||||
elif not need_fill:
|
||||
for offset in range(pages): # no filtering: respect requested page count
|
||||
if not consume(fetch(page + offset)):
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
var PAGE_ID = 'video-discover';
|
||||
var LIST_URL = '/api/video/discover/list';
|
||||
var NOW_YEAR = new Date().getFullYear(); // for the 'NEW' (just-released) card flag
|
||||
|
||||
var state = {
|
||||
loaded: false, wired: false, mode: 'shelves',
|
||||
|
|
@ -48,16 +49,46 @@
|
|||
}
|
||||
|
||||
// ── the rail stack (personalized + curated + genre + decade) ──────────────
|
||||
// The iconic ranked "Top 10 today" row — daily TMDB trending, rendered with rank numbers.
|
||||
var TOP10 = { title: 'Top 10 Today', q: 'key=trending_today&lang=any', ranked: true };
|
||||
var CURATED = [
|
||||
{ title: 'Trending This Week', q: 'key=trending' },
|
||||
{ title: 'Popular Movies', q: 'key=popular_movies' },
|
||||
{ title: 'Popular Shows', q: 'key=popular_shows' },
|
||||
{ title: 'In Theaters Now', q: 'key=now_playing' },
|
||||
{ title: 'Coming Soon', q: 'key=upcoming_movies' },
|
||||
{ title: 'On The Air', q: 'key=on_the_air' },
|
||||
{ title: 'Top Rated Movies', q: 'key=top_movies' },
|
||||
{ title: 'Top Rated Shows', q: 'key=top_shows' },
|
||||
];
|
||||
// New & noteworthy — date-windowed "new" + the theatrical pipeline.
|
||||
var NEW_RAILS = [
|
||||
{ title: 'New Movies This Month', q: 'kind=movie&release_window=last_30&sort=popularity.desc' },
|
||||
{ title: 'Fresh on TV', q: 'kind=show&release_window=last_90&sort=popularity.desc' },
|
||||
{ title: 'In Theaters Now', q: 'key=now_playing' },
|
||||
{ title: 'Coming Soon', q: 'key=upcoming_movies' },
|
||||
];
|
||||
// Mood rails — genre AND-combos + a vote floor so they read as a vibe, not a dump.
|
||||
var MOOD_RAILS = [
|
||||
{ title: 'Feel-Good Favorites', q: 'kind=movie&genre=35,10751&sort=popularity.desc' }, // Comedy + Family
|
||||
{ title: 'Edge of Your Seat', q: 'kind=movie&genre=53&sort=popularity.desc&vote_count_min=300' }, // Thriller
|
||||
{ title: 'Mind-Bending', q: 'kind=movie&genre=878,9648&sort=vote_average.desc&vote_count_min=300' }, // Sci-Fi + Mystery
|
||||
{ title: 'Date Night', q: 'kind=movie&genre=10749,35&sort=popularity.desc' }, // Romance + Comedy
|
||||
{ title: 'Tearjerkers', q: 'kind=movie&genre=18&sort=vote_average.desc&vote_count_min=500' }, // Drama
|
||||
{ title: 'Laugh Out Loud', q: 'kind=movie&genre=35&sort=vote_average.desc&vote_count_min=400' }, // Comedy
|
||||
];
|
||||
// From the studios — TMDB company ids (stable). A wrong/empty one just drops its rail.
|
||||
var STUDIO_RAILS = [
|
||||
{ title: 'Pixar', q: 'kind=movie&companies=3&sort=primary_release_date.desc' },
|
||||
{ title: 'Studio Ghibli', q: 'kind=movie&companies=10342&sort=vote_average.desc' },
|
||||
{ title: 'A24', q: 'kind=movie&companies=41077&sort=primary_release_date.desc' },
|
||||
{ title: 'Marvel Studios', q: 'kind=movie&companies=420&sort=primary_release_date.desc' },
|
||||
{ title: 'DreamWorks Animation', q: 'kind=movie&companies=521&sort=popularity.desc' },
|
||||
];
|
||||
// Something different — runtime + family slices.
|
||||
var DIFFERENT_RAILS = [
|
||||
{ title: 'Quick Watches (under 90 min)', q: 'kind=movie&max_runtime=90&sort=popularity.desc&vote_count_min=200' },
|
||||
{ title: 'Epics (3 hrs+)', q: 'kind=movie&min_runtime=180&sort=vote_average.desc&vote_count_min=200' },
|
||||
{ title: 'Family Movie Night', q: 'kind=movie&genre=10751&sort=popularity.desc' },
|
||||
];
|
||||
var GENRE_RAILS = ['Action', 'Adventure', 'Comedy', 'Drama', 'Science Fiction',
|
||||
'Thriller', 'Horror', 'Animation', 'Fantasy', 'Romance', 'Documentary', 'Crime'];
|
||||
// A thematic colour per genre so the chips read intentional, not random.
|
||||
|
|
@ -120,14 +151,19 @@
|
|||
if (id != null && !used['m:' + name.toLowerCase()])
|
||||
genre.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
|
||||
});
|
||||
// foryou/collection/taste are also fed by async loaders (loadForYou/loadGaps/loadMoreLike).
|
||||
// foryou/collection/taste are also fed by async loaders (loadForYou/loadGaps/loadMoreLike) —
|
||||
// keep those group ids. Order of this array IS the on-screen order.
|
||||
return [
|
||||
{ id: 'foryou', label: 'For you', rails: foryou },
|
||||
{ id: 'topten', label: 'Top 10 today', rails: [TOP10] },
|
||||
{ id: 'collection', label: 'Finish your collection', rails: [] },
|
||||
{ id: 'taste', label: 'More of what you like', rails: taste },
|
||||
{ id: 'new', label: 'New & noteworthy', rails: NEW_RAILS },
|
||||
{ id: 'popular', label: 'Trending & popular', rails: CURATED },
|
||||
{ id: 'mood', label: 'By mood', rails: MOOD_RAILS },
|
||||
{ id: 'studios', label: 'From the studios', rails: STUDIO_RAILS },
|
||||
{ id: 'genre', label: 'Browse by genre', rails: genre },
|
||||
{ id: 'corners', label: 'Hidden gems & more', rails: GEM_RAILS.concat(DECADE_RAILS).concat(FOREIGN_RAILS) },
|
||||
{ id: 'different', label: 'Something different', rails: DIFFERENT_RAILS.concat(GEM_RAILS).concat(DECADE_RAILS).concat(FOREIGN_RAILS) },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +180,9 @@
|
|||
: '<span class="vsr-ribbon vsr-ribbon--preview">Preview</span>';
|
||||
var rating = it.rating
|
||||
? '<span class="vsr-rating">★ ' + (Math.round(it.rating * 10) / 10) + '</span>' : '';
|
||||
// 'NEW' flag for un-owned current-year releases — a quick "just out" signal.
|
||||
var fresh = (!owned && it.year && Number(it.year) >= NOW_YEAR)
|
||||
? '<span class="vsr-new">NEW</span>' : '';
|
||||
var source = owned ? 'library' : 'tmdb';
|
||||
var id = owned ? it.library_id : it.tmdb_id;
|
||||
var href = '/video-detail/' + source + '/' + it.kind + '/' + id;
|
||||
|
|
@ -158,11 +197,15 @@
|
|||
return '<a class="vsr-card' + (owned ? ' vsr-card--owned' : '') + '" href="' + href + '" ' +
|
||||
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id +
|
||||
'" style="--vgm-h:' + hueOf(it.title) + '">' + cb + notInt +
|
||||
'<div class="vsr-poster">' + img + ribbon + rating +
|
||||
'<div class="vsr-poster">' + img + ribbon + rating + fresh +
|
||||
'<span class="vsr-peek" aria-hidden="true">i</span></div>' +
|
||||
'<div class="vsr-info"><span class="vsr-name" title="' + esc(it.title) + '">' + esc(it.title) +
|
||||
'</span><span class="vsr-sub">' + esc(sub) + '</span></div></a>';
|
||||
}
|
||||
// A ranked card (Top 10): a big outlined rank numeral beside the normal poster.
|
||||
function rankedCard(it, rank) {
|
||||
return '<div class="vsr-ranked"><span class="vsr-rank" aria-hidden="true">' + rank + '</span>' + card(it) + '</div>';
|
||||
}
|
||||
function hydrateGet(root) { if (window.VideoWatchlist) VideoWatchlist.hydrate(root); }
|
||||
|
||||
// ── 'Not interested' + ignore-list management ─────────────────────────────
|
||||
|
|
@ -482,7 +525,9 @@
|
|||
}
|
||||
// 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) + '">' +
|
||||
var rankCls = sh.ranked ? ' vdsc-shelf--ranked' : '';
|
||||
var rankAttr = sh.ranked ? ' data-vdsc-ranked="1"' : '';
|
||||
return '<section class="vdsc-shelf' + rankCls + '"' + rankAttr + ' 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>' +
|
||||
|
|
@ -557,14 +602,22 @@
|
|||
shelf.setAttribute('data-vdsc-loaded', '1');
|
||||
var rail = $('[data-vdsc-rail]', shelf);
|
||||
var grp = shelf.closest('.vdsc-group');
|
||||
var ranked = shelf.getAttribute('data-vdsc-ranked') === '1';
|
||||
// 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');
|
||||
// Ranked (Top 10): a single fixed chart — never paged, capped at 10.
|
||||
var q = shelf.getAttribute('data-vdsc-q') + (ranked ? '' : (isHideOwned() ? '&hide_owned=1' : '&pages=2'));
|
||||
cachedFetch(LIST_URL + '?' + q)
|
||||
.then(function (d) {
|
||||
var items = (d && d.items) || [];
|
||||
if (ranked) items = items.slice(0, 10);
|
||||
if (!items.length) { shelf.remove(); pruneGroup(grp); return; } // drop empty shelves
|
||||
if (rail) { rail.innerHTML = items.map(card).join(''); stagger(rail); hydrateGet(rail); }
|
||||
if (rail) {
|
||||
rail.innerHTML = ranked
|
||||
? items.map(function (it, i) { return rankedCard(it, i + 1); }).join('')
|
||||
: items.map(card).join('');
|
||||
stagger(rail); hydrateGet(rail);
|
||||
}
|
||||
shelf.classList.add('vdsc-shelf--in'); // reveal (cards cascade via --i)
|
||||
})
|
||||
.catch(function () { shelf.remove(); pruneGroup(grp); });
|
||||
|
|
|
|||
|
|
@ -922,6 +922,15 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
|
|||
padding: 3px 8px; border-radius: 7px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* 'NEW' flag — un-owned current-year releases (bottom-left, clear of ribbon + rating) */
|
||||
.vsr-new {
|
||||
position: absolute; bottom: 10px; left: 10px; z-index: 2;
|
||||
font-size: 9.5px; font-weight: 900; letter-spacing: 0.6px;
|
||||
padding: 3px 7px; border-radius: 6px; color: #07110a;
|
||||
background: linear-gradient(135deg, #34d399, #10b981);
|
||||
box-shadow: 0 3px 10px rgba(16, 185, 129, 0.45);
|
||||
}
|
||||
|
||||
.vsr-info { padding: 11px 4px 0; min-width: 0; }
|
||||
.vsr-info--center { text-align: center; }
|
||||
.vsr-name {
|
||||
|
|
@ -2313,6 +2322,19 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdsc-rail::-webkit-scrollbar-track { background: transparent; }
|
||||
.vdsc-rail .vsr-card { flex: 0 0 162px; width: 162px; scroll-snap-align: start; }
|
||||
|
||||
/* ── Top 10 ranked rail — giant outlined numeral hugging each poster ──────────── */
|
||||
.vdsc-shelf--ranked .vdsc-rail { gap: 4px; align-items: flex-end; }
|
||||
.vsr-ranked { display: flex; align-items: flex-end; flex: 0 0 auto; scroll-snap-align: start; }
|
||||
.vsr-rank {
|
||||
font-family: "Arial Black", "Helvetica Neue", system-ui, sans-serif;
|
||||
font-size: 150px; font-weight: 900; line-height: 0.66; letter-spacing: -0.07em;
|
||||
color: #0a0c14; -webkit-text-stroke: 3px rgba(255, 255, 255, 0.5);
|
||||
text-shadow: 0 6px 22px rgba(0, 0, 0, 0.55);
|
||||
margin: 0 -22px 0 0; user-select: none; flex: 0 0 auto; align-self: flex-end;
|
||||
}
|
||||
.vsr-ranked .vsr-card { flex: 0 0 150px; width: 150px; }
|
||||
.vsr-ranked .vsr-card .vsr-info { padding-left: 0; }
|
||||
|
||||
/* skeleton shimmer while a rail loads */
|
||||
.vdsc-skel { display: flex; gap: 16px; }
|
||||
.vdsc-skel-card { flex: 0 0 162px; height: 280px; border-radius: 12px;
|
||||
|
|
@ -2331,6 +2353,8 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdsc-hero-body { left: 22px; bottom: 22px; right: 22px; }
|
||||
.vdsc-hero-title { font-size: 27px; }
|
||||
.vdsc-rail .vsr-card, .vdsc-skel-card { flex-basis: 132px; width: 132px; }
|
||||
.vsr-ranked .vsr-card { flex-basis: 120px; width: 120px; }
|
||||
.vsr-rank { font-size: 104px; margin-right: -16px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vdsc-slide, .vdsc-slide--on { animation: none; transition: opacity 0.4s ease; transform: none; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue