soulsync/webui/static/video/video-discover.js
BoulderBadgeDad a8ebc9b8f7 Video Discover page: trending hero + lazy genre/decade rails + filter grid
A browse-everything page for TMDB titles you don't own yet.

Backend:
- TMDBClient: discover() (genre/year/decade), curated() (popular / top_rated /
  now_playing / upcoming / on_the_air / airing_today), genres(); shared
  _disc_map() that carries backdrop + overview. trending() now routes through it.
- Engine: discover_curated / discover_filter / genre_list (cached + owned-
  annotated via library_id_for_tmdb) + _stamp_owned helper.
- api/video/discover.py: /discover/hero, /discover/genres, /discover/list
  (curated key | filtered kind+genre+year+decade+sort, paged).

Frontend (video-discover.js + .vdsc-* CSS + subpage markup):
- Cross-fading trending hero slideshow (per-title hue, dots, hover-pause,
  reduced-motion safe, click -> detail).
- Deep stack of Netflix-style rails (curated + every movie genre + decades),
  each lazy-loaded on scroll via IntersectionObserver, arrow scrollers,
  reusing the search card (owned 'In Library' / 'Preview' ribbon + get button).
- Filter bar (kind / genre / decade / sort) flips into a paged 'Load more' grid.
- Cards open detail through the shared soulsync:video-open-detail event.
2026-06-16 13:35:31 -07:00

371 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SoulSync — Video Discover page (isolated, in-app).
*
* A browse-everything page for TMDB titles you don't own yet: a cross-fading
* trending hero, then a deep stack of Netflix-style rails (curated lists, every
* genre, a few decades), each lazy-loaded on scroll. A filter bar (kind / genre /
* year / sort) flips the whole page into a paged grid. Cards reuse the search
* card look + owned/preview ribbon and open the detail page via the shared
* soulsync:video-open-detail event. Self-contained IIFE, no globals.
*/
(function () {
'use strict';
var PAGE_ID = 'video-discover';
var LIST_URL = '/api/video/discover/list';
var state = {
loaded: false, wired: false, mode: 'shelves',
genres: { movie: [], show: [] },
io: null, // IntersectionObserver for lazy rails
hero: { items: [], idx: 0, timer: null },
filter: { kind: 'movie', genre: '', decade: '', year: '', sort: 'popularity.desc', page: 1, busy: false },
};
function $(s, r) { return (r || document).querySelector(s); }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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; }
// ── the rail stack (curated + genre + decade) ─────────────────────────────
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' },
];
// Movie genres to surface as their own rails (resolved to ids once loaded).
var GENRE_RAILS = ['Action', 'Adventure', 'Comedy', 'Drama', 'Science Fiction',
'Thriller', 'Horror', 'Animation', 'Fantasy', 'Romance', 'Documentary', 'Crime'];
var DECADE_RAILS = [
{ title: 'Best of the 2010s', q: 'kind=movie&decade=2010&sort=vote_average.desc' },
{ title: '2000s Favorites', q: 'kind=movie&decade=2000&sort=vote_average.desc' },
{ title: '90s Classics', q: 'kind=movie&decade=1990&sort=vote_average.desc' },
{ title: 'Retro 80s', q: 'kind=movie&decade=1980&sort=vote_average.desc' },
];
function buildShelfList() {
var shelves = CURATED.slice();
var gmap = {};
state.genres.movie.forEach(function (g) { gmap[(g.name || '').toLowerCase()] = g.id; });
GENRE_RAILS.forEach(function (name) {
var id = gmap[name.toLowerCase()];
if (id != null) shelves.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
});
return shelves.concat(DECADE_RAILS);
}
// ── card (mirrors the search title card: owned ribbon + get button) ───────
function card(it) {
var fallback = it.kind === 'movie' ? '🎬' : '📺';
var img = it.poster
? '<img src="' + esc(it.poster) + '" alt="" loading="lazy" ' +
'onerror="this.outerHTML=\'<div class=&quot;vsr-poster-ph&quot;>' + fallback + '</div>\'">'
: '<div class="vsr-poster-ph">' + fallback + '</div>';
var owned = it.library_id != null;
var ribbon = owned
? '<span class="vsr-ribbon vsr-ribbon--owned">In Library</span>'
: '<span class="vsr-ribbon vsr-ribbon--preview">Preview</span>';
var rating = it.rating
? '<span class="vsr-rating">★ ' + (Math.round(it.rating * 10) / 10) + '</span>' : '';
var source = owned ? 'library' : 'tmdb';
var id = owned ? it.library_id : it.tmdb_id;
var href = '/video-detail/' + source + '/' + it.kind + '/' + id;
var sub = [it.year, it.kind === 'movie' ? 'Movie' : 'TV'].filter(Boolean).join(' · ');
var cb = window.VideoGet ? VideoGet.cardButton({ kind: it.kind, tmdbId: it.tmdb_id,
libraryId: it.library_id, title: it.title, poster: it.poster, status: it.status, source: source }) : '';
return '<a class="vsr-card" href="' + href + '" ' +
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' + cb +
'<div class="vsr-poster">' + img + ribbon + rating +
'<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>';
}
function hydrateGet(root) {
// shows already on the watchlist paint their eye as "watched"
if (window.VideoWatchlist) VideoWatchlist.hydrate(root);
}
// ── hero slideshow ────────────────────────────────────────────────────────
function loadHero() {
fetch('/api/video/discover/hero', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
state.hero.items = (d && d.items) || [];
renderHero();
})
.catch(function () { /* hero is optional chrome */ });
}
function renderHero() {
var host = $('[data-vdsc-hero]'); if (!host) return;
var items = state.hero.items;
if (!items.length) { host.classList.add('hidden'); return; }
host.classList.remove('hidden');
var slides = items.map(function (it, i) {
return '<div class="vdsc-slide' + (i === 0 ? ' vdsc-slide--on' : '') + '" data-vdsc-slide="' + i + '" ' +
'style="--vgm-h:' + hueOf(it.title) + ';' + (it.backdrop ? "background-image:url('" + esc(it.backdrop) + "')" : '') + '">' +
'<div class="vdsc-slide-scrim"></div></div>';
}).join('');
var dots = items.map(function (it, i) {
return '<button class="vdsc-dot' + (i === 0 ? ' vdsc-dot--on' : '') + '" type="button" ' +
'data-vdsc-go="' + i + '" aria-label="Slide ' + (i + 1) + '"></button>';
}).join('');
host.innerHTML =
'<div class="vdsc-slides" data-vdsc-slides>' + slides + '</div>' +
'<div class="vdsc-hero-body" data-vdsc-hero-body></div>' +
'<div class="vdsc-dots">' + dots + '</div>';
state.hero.idx = 0;
paintHeroBody();
startHeroTimer();
}
function paintHeroBody() {
var body = $('[data-vdsc-hero-body]'); if (!body) return;
var it = state.hero.items[state.hero.idx]; if (!it) return;
var owned = it.library_id != null;
var source = owned ? 'library' : 'tmdb';
var id = owned ? it.library_id : it.tmdb_id;
var pills = [it.kind === 'movie' ? 'Movie' : 'TV', it.year,
it.rating ? '★ ' + (Math.round(it.rating * 10) / 10) : null,
owned ? 'In Library' : null].filter(Boolean);
body.style.setProperty('--vgm-h', hueOf(it.title));
body.innerHTML =
'<div class="vdsc-hero-eyebrow">' + (owned ? 'In your library' : 'Trending now') + '</div>' +
'<h2 class="vdsc-hero-title">' + esc(it.title) + '</h2>' +
'<div class="vdsc-hero-pills">' + pills.map(function (p) {
return '<span class="vdsc-hero-pill">' + esc(p) + '</span>'; }).join('') + '</div>' +
(it.overview ? '<p class="vdsc-hero-ov">' + esc(it.overview) + '</p>' : '') +
'<button class="discog-submit-btn vdsc-hero-cta" type="button" data-vdsc-open ' +
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' +
'<span class="discog-submit-text">View ' + (it.kind === 'movie' ? 'movie' : 'show') + ' →</span></button>';
}
function goHero(i) {
var items = state.hero.items; if (!items.length) return;
state.hero.idx = (i + items.length) % items.length;
var slides = document.querySelectorAll('[data-vdsc-slide]');
for (var s = 0; s < slides.length; s++)
slides[s].classList.toggle('vdsc-slide--on', s === state.hero.idx);
var dots = document.querySelectorAll('[data-vdsc-go]');
for (var d = 0; d < dots.length; d++)
dots[d].classList.toggle('vdsc-dot--on', d === state.hero.idx);
paintHeroBody();
}
function startHeroTimer() {
stopHeroTimer();
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (state.hero.items.length < 2) return;
state.hero.timer = setInterval(function () { goHero(state.hero.idx + 1); }, 6500);
}
function stopHeroTimer() { if (state.hero.timer) { clearInterval(state.hero.timer); state.hero.timer = null; } }
// ── shelves (lazy rails) ──────────────────────────────────────────────────
function renderShelves() {
var host = $('[data-vdsc-shelves]'); if (!host) return;
var shelves = buildShelfList();
host.innerHTML = shelves.map(function (sh) {
return '<section class="vdsc-shelf" data-vdsc-q="' + esc(sh.q) + '">' +
'<div class="vdsc-shelf-head">' +
'<h3 class="vdsc-shelf-title">' + esc(sh.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>' +
'<div class="vdsc-skel">' + Array(7).join('<div class="vdsc-skel-card"></div>') + '</div>' +
'</div>' +
'</section>';
}).join('');
observeShelves();
}
function observeShelves() {
if (state.io) state.io.disconnect();
if (!('IntersectionObserver' in window)) { // no IO → just load all
var all = document.querySelectorAll('.vdsc-shelf');
for (var i = 0; i < all.length; i++) fillShelf(all[i]);
return;
}
state.io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) { state.io.unobserve(en.target); fillShelf(en.target); }
});
}, { rootMargin: '300px 0px' });
var shelves = document.querySelectorAll('.vdsc-shelf');
for (var j = 0; j < shelves.length; j++) state.io.observe(shelves[j]);
}
function fillShelf(shelf) {
if (!shelf || shelf.getAttribute('data-vdsc-loaded')) return;
shelf.setAttribute('data-vdsc-loaded', '1');
var rail = $('[data-vdsc-rail]', shelf);
var q = shelf.getAttribute('data-vdsc-q');
fetch(LIST_URL + '?' + q, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var items = (d && d.items) || [];
if (!items.length) { shelf.remove(); return; } // drop empty shelves
if (rail) { rail.innerHTML = items.map(card).join(''); hydrateGet(rail); }
})
.catch(function () { shelf.remove(); });
}
// ── filter / grid mode ────────────────────────────────────────────────────
function applyFilter() {
state.mode = 'grid';
state.filter.page = 1;
$('[data-vdsc-shelves]').classList.add('hidden');
$('[data-vdsc-grid-wrap]').classList.remove('hidden');
$('[data-vdsc-grid]').innerHTML = '';
loadGrid(true);
}
function clearFilter() {
state.mode = 'shelves';
$('[data-vdsc-grid-wrap]').classList.add('hidden');
$('[data-vdsc-shelves]').classList.remove('hidden');
}
function gridQuery() {
var f = state.filter;
var p = ['kind=' + f.kind, 'sort=' + encodeURIComponent(f.sort), 'page=' + f.filterPage];
if (f.genre) p.push('genre=' + f.genre);
if (f.decade) p.push('decade=' + f.decade);
if (f.year) p.push('year=' + encodeURIComponent(f.year));
return p.join('&');
}
function loadGrid(reset) {
var f = state.filter;
if (f.busy) return;
f.busy = true;
f.filterPage = f.page;
var more = $('[data-vdsc-more]'); if (more) { more.disabled = true; more.textContent = 'Loading…'; }
var ld = $('[data-vdsc-grid-loading]'); if (ld && reset) ld.classList.remove('hidden');
fetch(LIST_URL + '?' + gridQuery(), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
f.busy = false;
if (ld) ld.classList.add('hidden');
var items = (d && d.items) || [];
var grid = $('[data-vdsc-grid]');
if (grid) { grid.insertAdjacentHTML('beforeend', items.map(card).join('')); hydrateGet(grid); }
var empty = $('[data-vdsc-grid-empty]');
if (empty) empty.classList.toggle('hidden', !(reset && !items.length));
if (more) {
more.textContent = 'Load more';
more.disabled = false;
more.classList.toggle('hidden', items.length < 18); // a full page → likely more
}
})
.catch(function () {
f.busy = false;
if (ld) ld.classList.add('hidden');
if (more) { more.textContent = 'Load more'; more.disabled = false; }
});
}
function rebuildGenreOptions() {
var sel = $('[data-vdsc-f-genre]'); if (!sel) return;
var list = state.genres[state.filter.kind] || [];
sel.innerHTML = '<option value="">All genres</option>' + list.map(function (g) {
return '<option value="' + g.id + '">' + esc(g.name) + '</option>';
}).join('');
}
// ── wiring ────────────────────────────────────────────────────────────────
function wire() {
if (state.wired) return;
state.wired = true;
var page = $('[data-video-subpage="' + PAGE_ID + '"]'); if (!page) return;
// one delegated click for the whole page: cards + hero CTA + arrows + dots
page.addEventListener('click', function (e) {
var open = e.target.closest('[data-vsr-open]');
if (open) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
var kind = open.getAttribute('data-vsr-open');
var id = parseInt(open.getAttribute('data-vsr-id'), 10);
if (isNaN(id)) return;
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: kind, id: id, source: open.getAttribute('data-vsr-source') || 'tmdb' },
}));
return;
}
var arrow = e.target.closest('[data-vdsc-scroll]');
if (arrow) {
var rail = $('[data-vdsc-rail]', arrow.closest('.vdsc-shelf'));
if (rail) rail.scrollBy({ left: parseInt(arrow.getAttribute('data-vdsc-scroll'), 10) * rail.clientWidth * 0.8, behavior: 'smooth' });
return;
}
var dot = e.target.closest('[data-vdsc-go]');
if (dot) { goHero(parseInt(dot.getAttribute('data-vdsc-go'), 10)); startHeroTimer(); return; }
});
// hero auto-advance pauses while hovered
var hero = $('[data-vdsc-hero]');
if (hero) {
hero.addEventListener('mouseenter', stopHeroTimer);
hero.addEventListener('mouseleave', startHeroTimer);
}
var kind = $('[data-vdsc-f-kind]');
if (kind) kind.addEventListener('change', function () {
state.filter.kind = kind.value === 'show' ? 'show' : 'movie';
rebuildGenreOptions();
});
var genre = $('[data-vdsc-f-genre]');
if (genre) genre.addEventListener('change', function () { state.filter.genre = genre.value; });
var decade = $('[data-vdsc-f-decade]');
if (decade) decade.addEventListener('change', function () { state.filter.decade = decade.value; });
var sort = $('[data-vdsc-f-sort]');
if (sort) sort.addEventListener('change', function () { state.filter.sort = sort.value; });
var apply = $('[data-vdsc-apply]');
if (apply) apply.addEventListener('click', applyFilter);
var clear = $('[data-vdsc-clear]');
if (clear) clear.addEventListener('click', clearFilter);
var more = $('[data-vdsc-more]');
if (more) more.addEventListener('click', function () { state.filter.page++; loadGrid(false); });
}
function loadGenres() {
fetch('/api/video/discover/genres', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
state.genres = { movie: (d && d.movie) || [], show: (d && d.show) || [] };
rebuildGenreOptions();
renderShelves(); // genre rails need the id map
})
.catch(function () { renderShelves(); });
}
function load() {
if (state.loaded) return;
state.loaded = true;
loadHero();
loadGenres();
}
function onShown(e) { if (e && e.detail === PAGE_ID) { wire(); load(); startHeroTimer(); } }
function onHidden() { stopHeroTimer(); }
function init() {
document.addEventListener('soulsync:video-page-shown', onShown);
// pause the hero when leaving the page
document.addEventListener('soulsync:video-page-shown', function (e) {
if (e && e.detail !== PAGE_ID) onHidden();
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();