/* * SoulSync — Video Discover page (isolated, in-app). * * A browse-everything page for TMDB titles you don't own yet: a cross-fading * trending hero, personalized "because you like…" rails, then a deep stack of * Netflix-style genre/decade/curated rails — each lazy-loaded on scroll. Every * rail has a "See all" that opens it as a paged grid (Load more). A filter bar * (kind / genre / decade / sort) opens an arbitrary grid; a "Hide owned" toggle * drops titles already in your library. Cards reuse the search card + owned * ribbon and open detail 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: [] }, taste: { movie: [], show: [] }, io: null, hero: { items: [], idx: 0, timer: null }, cat: { title: '', q: '', page: 1, paginates: true, busy: false, hasMore: false }, 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) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } 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 = [ { 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' }, ]; 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. var GENRE_COLORS = { 'action': '239, 68, 68', 'adventure': '34, 197, 94', 'animation': '168, 85, 247', 'comedy': '245, 197, 24', 'crime': '120, 113, 108', 'documentary': '20, 184, 166', 'drama': '96, 165, 250', 'family': '74, 222, 128', 'fantasy': '192, 132, 252', 'history': '202, 138, 4', 'horror': '220, 38, 38', 'music': '236, 72, 153', 'mystery': '99, 102, 241', 'romance': '244, 114, 182', 'science fiction': '34, 211, 238', 'sci-fi & fantasy': '34, 211, 238', 'tv movie': '148, 163, 184', 'thriller': '100, 116, 139', 'war': '161, 98, 7', 'war & politics': '161, 98, 7', 'western': '180, 130, 80', 'kids': '74, 222, 128', 'reality': '251, 146, 60', 'soap': '244, 114, 182', 'talk': '148, 163, 184', 'news': '148, 163, 184', }; 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 out = [], used = {}; var gm = idMap(state.genres.movie), gs = idMap(state.genres.show); // personalized first — seeded from what you actually own (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; } }); (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' }); } }); out = out.concat(CURATED); 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' }); }); return out.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 ? '' : '
' + fallback + '
'; var owned = it.library_id != null; var ribbon = owned ? 'In Library' : 'Preview'; var rating = it.rating ? '★ ' + (Math.round(it.rating * 10) / 10) + '' : ''; 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 '' + cb + '
' + img + ribbon + rating + '
' + '
' + esc(it.title) + '' + esc(sub) + '
'; } function hydrateGet(root) { 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'); host.innerHTML = '
' + items.map(function (it, i) { return '
' + '
'; }).join('') + '
' + '
' + '
' + items.map(function (it, i) { return ''; }).join('') + '
'; 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); var hue = hueOf(it.title); body.style.setProperty('--vgm-h', hue); var pageEl = $('[data-vdsc-page]'); if (pageEl) pageEl.style.setProperty('--vdsc-amb', hue); // ambient bleed body.innerHTML = '
' + (owned ? 'In your library' : 'Trending now') + '
' + '

' + esc(it.title) + '

' + '
' + pills.map(function (p) { return '' + esc(p) + ''; }).join('') + '
' + (it.overview ? '

' + esc(it.overview) + '

' : '') + '
' + '' + '' + '
'; } 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; } } // ── trailer lightbox (in-app YouTube embed) ─────────────────────────────── var trailerEl = null, trKey = null; function closeTrailer() { if (!trailerEl) return; var el = trailerEl; trailerEl = null; el.classList.remove('vdsc-tr-open'); document.body.style.removeProperty('overflow'); if (trKey) { document.removeEventListener('keydown', trKey); trKey = null; } setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 200); startHeroTimer(); } function openTrailer(kind, tmdbId, title) { closeTrailer(); stopHeroTimer(); var ov = document.createElement('div'); ov.className = 'vdsc-tr-overlay'; ov.innerHTML = ''; document.body.appendChild(ov); document.body.style.overflow = 'hidden'; trailerEl = ov; requestAnimationFrame(function () { ov.classList.add('vdsc-tr-open'); }); ov.addEventListener('click', function (e) { if (e.target === ov || e.target.closest('[data-vdsc-tr-close]')) closeTrailer(); }); trKey = function (e) { if (e.key === 'Escape') closeTrailer(); }; document.addEventListener('keydown', trKey); fetch('/api/video/discover/trailer?kind=' + encodeURIComponent(kind) + '&tmdb_id=' + encodeURIComponent(tmdbId), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (!trailerEl) return; var frame = trailerEl.querySelector('[data-vdsc-tr-frame]'); var key = d && d.trailer && d.trailer.key; if (!key) { if (frame) frame.innerHTML = '
No trailer available
'; return; } if (frame) frame.innerHTML = ''; }) .catch(function () { var frame = trailerEl && trailerEl.querySelector('[data-vdsc-tr-frame]'); if (frame) frame.innerHTML = '
Couldn’t load trailer
'; }); } // ── "More like " rails (pre-filled, prepended above the stack) ─ function loadMoreLike() { fetch('/api/video/discover/morelike', { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var rails = (d && d.rails) || []; var host = $('[data-vdsc-shelves]'); if (!rails.length || !host) return; var html = rails.map(function (rl) { return '
' + '
' + '

' + esc(rl.title) + '

' + '
' + '' + '' + '
' + '
' + '
' + (rl.items || []).map(card).join('') + '
' + '
'; }).join(''); host.insertAdjacentHTML('afterbegin', html); hydrateGet(host); }) .catch(function () { /* personalization is best-effort */ }); } // ── shelves (lazy rails) ────────────────────────────────────────────────── function renderShelves() { var host = $('[data-vdsc-shelves]'); if (!host) return; host.innerHTML = buildShelfList().map(function (sh) { return '
' + '
' + '

' + esc(sh.title) + '

' + '
' + '' + '' + '' + '
' + '
' + '
' + '
' + Array(8).join('
') + '
' + '
' + '
'; }).join(''); observeShelves(); } function observeShelves() { if (state.io) state.io.disconnect(); var shelves = document.querySelectorAll('.vdsc-shelf'); if (!('IntersectionObserver' in window)) { for (var i = 0; i < shelves.length; i++) fillShelf(shelves[i]); return; } state.io = new IntersectionObserver(function (entries) { entries.forEach(function (en) { if (en.isIntersecting) { state.io.unobserve(en.target); fillShelf(en.target); } }); }, { rootMargin: '400px 0px' }); 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); // 2 pages (~40 items) so a rail still looks full after 'Hide owned'. 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 if (rail) { rail.innerHTML = items.map(card).join(''); hydrateGet(rail); } shelf.classList.add('vdsc-shelf--in'); // reveal }) .catch(function () { shelf.remove(); }); } // ── category / filter grid (paged) ──────────────────────────────────────── function openCategory(title, q) { state.mode = 'grid'; state.cat = { title: title, q: q, page: 1, paginates: !/key=trending/.test(q), busy: false, hasMore: false }; $('[data-vdsc-shelves]').classList.add('hidden'); var hero = $('[data-vdsc-hero]'); if (hero) hero.classList.add('hidden'); var wrap = $('[data-vdsc-grid-wrap]'); wrap.classList.remove('hidden'); var ttl = $('[data-vdsc-grid-title]'); if (ttl) ttl.textContent = title; $('[data-vdsc-grid]').innerHTML = ''; try { wrap.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { /* ignore */ } loadGrid(true); } function closeCategory() { state.mode = 'shelves'; $('[data-vdsc-grid-wrap]').classList.add('hidden'); $('[data-vdsc-shelves]').classList.remove('hidden'); if (state.hero.items.length) { var h = $('[data-vdsc-hero]'); if (h) h.classList.remove('hidden'); } } function loadGrid(reset) { 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'); cachedFetch(LIST_URL + '?' + c.q + '&page=' + c.page) .then(function (d) { c.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)); c.hasMore = c.paginates && items.length >= 18; // 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; if (ld) ld.classList.add('hidden'); 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 : ''; } function applyFilter() { var s = state.sel; var q = ['kind=' + s.kind, 'sort=' + encodeURIComponent(s.sort)]; var bits = [s.kind === 'show' ? 'Shows' : 'Movies']; if (s.genre) { q.push('genre=' + s.genre); var gn = genreName(s.kind, s.genre); if (gn) bits.push(gn); } if (s.providers) { q.push('providers=' + s.providers); var pn = activeChipText('providers'); if (pn) bits.push('on ' + pn); } if (s.decade) { q.push('decade=' + s.decade); bits.push(s.decade + 's'); } openCategory(bits.join(' · '), q.join('&')); } function genreName(kind, id) { var list = state.genres[kind] || []; for (var i = 0; i < list.length; i++) if (String(list[i].id) === String(id)) return list[i].name; return ''; } function renderGenreChips() { var box = $('[data-vdsc-chipset="genre"]'); if (!box) return; box.innerHTML = '' + (state.genres[state.sel.kind] || []).map(function (g) { var c = GENRE_COLORS[(g.name || '').toLowerCase()]; return ''; }).join(''); state.sel.genre = ''; } function setActive(box, el, selector, onClass) { var all = box.querySelectorAll(selector); for (var i = 0; i < all.length; i++) all[i].classList.toggle(onClass, all[i] === el); } // ── wiring ──────────────────────────────────────────────────────────────── function wire() { if (state.wired) return; state.wired = true; var page = $('[data-video-subpage="' + PAGE_ID + '"]'); if (!page) return; 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 id = parseInt(open.getAttribute('data-vsr-id'), 10); if (isNaN(id)) return; document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: open.getAttribute('data-vsr-open'), id: id, source: open.getAttribute('data-vsr-source') || 'tmdb' }, })); return; } var trbtn = e.target.closest('[data-vdsc-trailer]'); if (trbtn) { openTrailer(trbtn.getAttribute('data-kind'), trbtn.getAttribute('data-tmdb'), trbtn.getAttribute('data-title')); return; } var seeall = e.target.closest('[data-vdsc-seeall]'); if (seeall) { var shelf = seeall.closest('.vdsc-shelf'); if (shelf) openCategory(shelf.getAttribute('data-vdsc-title'), shelf.getAttribute('data-vdsc-q')); return; } var seg = e.target.closest('.vdsc-seg-btn'); if (seg) { var sbox = seg.closest('[data-vdsc-seg]'); var which = sbox.getAttribute('data-vdsc-seg'); setActive(sbox, seg, '.vdsc-seg-btn', 'vdsc-seg-btn--on'); state.sel[which] = seg.getAttribute('data-val'); if (which === 'kind') renderGenreChips(); // genres differ by kind return; } var chip = e.target.closest('.vdsc-chip'); if (chip) { var cbox = chip.closest('[data-vdsc-chipset]'); setActive(cbox, chip, '.vdsc-chip', 'vdsc-chip--on'); state.sel[cbox.getAttribute('data-vdsc-chipset')] = chip.getAttribute('data-val'); 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.85, behavior: 'smooth' }); return; } var dot = e.target.closest('[data-vdsc-go]'); if (dot) { goHero(parseInt(dot.getAttribute('data-vdsc-go'), 10)); startHeroTimer(); } }); var hero = $('[data-vdsc-hero]'); if (hero) { hero.addEventListener('mouseenter', stopHeroTimer); hero.addEventListener('mouseleave', startHeroTimer); } 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 () { loadGrid(false); }); var hide = $('[data-vdsc-hideowned]'); if (hide) { try { if (localStorage.getItem('vdsc_hideowned') === '1') hide.checked = true; } catch (e) { /* ignore */ } page.classList.toggle('vdsc-hide-owned', hide.checked); hide.addEventListener('change', function () { page.classList.toggle('vdsc-hide-owned', hide.checked); try { localStorage.setItem('vdsc_hideowned', hide.checked ? '1' : '0'); } catch (e) { /* ignore */ } }); } // Infinite scroll: a sentinel near the grid bottom pulls the next page. var sentinel = $('[data-vdsc-sentinel]'); if (sentinel && AUTO) { new IntersectionObserver(function (entries) { sentinelVisible = entries[0].isIntersecting; maybeAutoLoad(); }, { rootMargin: '600px 0px' }).observe(sentinel); } // Hero keyboard nav (←/→) when Discover is the visible view. document.addEventListener('keydown', function (e) { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; if (trailerEl || state.mode !== 'shelves' || !state.hero.items.length) return; if (!page || page.offsetParent === null) return; // Discover not visible var tag = e.target && e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; e.preventDefault(); goHero(state.hero.idx + (e.key === 'ArrowRight' ? 1 : -1)); startHeroTimer(); }); } function loadMeta() { var jget = function (u) { return fetch(u, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }; Promise.all([jget('/api/video/discover/genres'), jget('/api/video/discover/taste')]) .then(function (res) { var g = res[0] || {}, t = res[1] || {}; state.genres = { movie: g.movie || [], show: g.show || [] }; state.taste = { movie: t.movie || [], show: t.show || [] }; // Genres are a static TMDB endpoint — empty means TMDB isn't set up. if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; } renderGenreChips(); renderShelves(); loadMoreLike(); // prepend personalized 'More like…' rails when ready }); } function showEmpty() { var e = $('[data-vdsc-empty]'); if (e) e.classList.remove('hidden'); var b = $('[data-video-subpage="' + PAGE_ID + '"] .vdsc-browse'); if (b) b.classList.add('hidden'); var sh = $('[data-vdsc-shelves]'); if (sh) sh.classList.add('hidden'); var h = $('[data-vdsc-hero]'); if (h) h.classList.add('hidden'); } function load() { if (state.loaded) return; state.loaded = true; loadHero(); loadMeta(); } function onShown(e) { if (!e) return; if (e.detail === PAGE_ID) { wire(); load(); startHeroTimer(); } else stopHeroTimer(); // left the page → stop the slideshow } function init() { document.addEventListener('soulsync:video-page-shown', onShown); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();