'; });
}
function openIgnoreModal() {
var ex = document.getElementById('vdsc-ignore-modal'); if (ex) ex.remove();
var ov = document.createElement('div');
ov.id = 'vdsc-ignore-modal';
ov.className = 'vdsc-ig-overlay';
ov.innerHTML =
'
' +
'
Not Interested
' +
'
Titles hidden from Discover — they won\'t show in any rail or recommendation.
' +
'
' +
'
' +
'
' +
'
Loading…
' +
'
';
document.body.appendChild(ov);
requestAnimationFrame(function () { ov.classList.add('vdsc-ig-in'); });
var close = function () { ov.classList.remove('vdsc-ig-in'); setTimeout(function () { ov.remove(); }, 180); };
ov.addEventListener('click', function (e) { if (e.target === ov || e.target.closest('.vdsc-ig-close')) close(); });
document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } });
renderIgnoreList(ov);
var input = ov.querySelector('.vdsc-ig-input');
var resBox = ov.querySelector('[data-ig-results]');
var t = null;
input.addEventListener('input', function () {
clearTimeout(t);
var q = input.value.trim();
if (q.length < 2) { resBox.innerHTML = ''; return; }
t = setTimeout(function () {
fetch('/api/video/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var items = ((d && d.results) || []).filter(function (x) {
return x.tmdb_id && (x.kind === 'movie' || x.kind === 'show');
}).slice(0, 8);
resBox.innerHTML = items.length ? items.map(function (x) { return igRowHtml(x, '+ Hide'); }).join('')
: '
No matches
';
}).catch(function () { resBox.innerHTML = ''; });
}, 300);
});
resBox.addEventListener('click', function (e) {
var b = e.target.closest('.vdsc-ig-res'); if (!b) return;
postIgnore({ action: 'add', kind: b.getAttribute('data-ig-kind'),
tmdb_id: parseInt(b.getAttribute('data-ig-id'), 10), title: b.getAttribute('data-ig-title'),
year: parseInt(b.getAttribute('data-ig-year'), 10) || null, poster: b.getAttribute('data-ig-poster') || null })
.then(function () { input.value = ''; resBox.innerHTML = ''; renderIgnoreList(ov); });
});
ov.querySelector('[data-ig-list]').addEventListener('click', function (e) {
var b = e.target.closest('.vdsc-ig-remove'); if (!b) return;
var c = b.closest('.vdsc-ig-card');
postIgnore({ action: 'remove', kind: c.getAttribute('data-ig-kind'),
tmdb_id: parseInt(c.getAttribute('data-ig-id'), 10) })
.then(function () { c.style.opacity = '0'; c.style.transform = 'scale(.9)';
setTimeout(function () { renderIgnoreList(ov); }, 160); });
});
}
// ── 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 =
'
';
}
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 =
'
' +
'' +
'
' +
'
' + esc(title || '') + '
' +
'
';
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 → 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' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (gen !== (state.railGen || 0)) return; // a newer rebuild superseded this
var rails = (d && d.rails) || [];
var body = $('[data-group-body="taste"]');
if (!rails.length || !body) return;
var html = rails.map(function (rl) {
return filledShelfHtml(rl.title, (rl.items || []).map(card).join(''), 'vdsc-shelf--ml');
}).join('');
body.insertAdjacentHTML('beforeend', html);
revealGroup('taste');
staggerWithin(body);
hydrateGet(body);
})
.catch(function () { /* personalization is best-effort */ });
}
// ── "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' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (gen !== (state.railGen || 0)) return; // a newer rebuild superseded this
var rails = (d && d.rails) || [];
var body = $('[data-group-body="collection"]');
if (!rails.length || !body) return;
var html = rails.map(function (rl) {
return filledShelfHtml(rl.title, (rl.items || []).map(card).join(''), 'vdsc-shelf--gap');
}).join('');
body.insertAdjacentHTML('beforeend', html);
revealGroup('collection');
staggerWithin(body);
hydrateGet(body);
})
.catch(function () { /* gaps are best-effort */ });
}
// ── "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' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (gen !== (state.railGen || 0)) return; // a newer rebuild superseded this
var items = (d && d.items) || [];
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 */ });
}
// Rebuild the whole rail stack (genre/curated shelves + the prepended personalized rows).
// Each rebuild bumps railGen; in-flight personalized loaders from a superseded rebuild check
// it before prepending, so rapid re-toggles can't stack duplicate "Recommended for you" rows.
function reloadRails() {
state.railGen = (state.railGen || 0) + 1;
renderShelves();
loadMoreLike();
loadGaps();
loadForYou();
}
// Coalesce rapid chip toggles (e.g. picking 3 services) into a single rebuild.
var _reloadTimer;
function reloadRailsSoon() {
clearTimeout(_reloadTimer);
_reloadTimer = setTimeout(reloadRails, 350);
}
// ── shelves (lazy rails) ──────────────────────────────────────────────────
function shelfNav(seeAll) {
return '
' +
(seeAll ? '' : '') +
'' +
'' +
'
';
}
// A lazy rail (skeleton until scrolled into view, then filled by fillShelf).
function lazyShelfHtml(sh) {
var rankCls = sh.ranked ? ' vdsc-shelf--ranked' : '';
var rankAttr = sh.ranked ? ' data-vdsc-ranked="1"' : '';
return '' +
'
' + esc(sh.title) + '
' + shelfNav(true) + '
' +
'
' +
'
' + Array(8).join('') + '
' +
'
' +
'';
}
// A pre-filled rail (async personalized rows already carry their items — no skeleton).
function filledShelfHtml(title, itemsHtml, cls) {
return '' +
'
' + esc(title) + '
' + shelfNav(false) + '
' +
'
' + itemsHtml + '
' +
'';
}
function renderShelves() {
var host = $('[data-vdsc-shelves]'); if (!host) return;
var sections = buildSections();
// A sticky "jump to section" bar (Netflix/Disney pattern) — the page is a deep
// stack of rails, so quick navigation makes it far easier to browse. Chips for
// async-only groups (foryou/collection/taste) start hidden, revealed when filled;
// chips for groups that prune away hide in lockstep (see reveal/pruneGroup).
var nav = '';
host.innerHTML = nav + sections.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 '' +
'
' + esc(sec.label) + '
' +
'
' + rails + '
' +
'';
}).join('');
wireJumpNav(host);
observeShelves();
}
// Smooth-scroll to a group when its jump chip is clicked (delegated, wired once).
function wireJumpNav(host) {
var nav = $('[data-vdsc-jumpnav]', host);
if (!nav || nav.getAttribute('data-wired')) return;
nav.setAttribute('data-wired', '1');
nav.addEventListener('click', function (e) {
var btn = e.target.closest('[data-jump]'); if (!btn) return;
var g = $('[data-group="' + btn.getAttribute('data-jump') + '"]');
if (g) { try { g.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (x) { g.scrollIntoView(); } }
});
}
// Reveal a group's header once an async loader has put rails in it (+ its jump chip).
function revealGroup(id) {
var g = $('[data-group="' + id + '"]');
if (g) g.classList.remove('vdsc-group--empty');
var chip = $('[data-jump="' + id + '"]');
if (chip) chip.classList.remove('hidden');
}
// Hide a group whose body has no shelves left (every rail failed / returned nothing) + its chip.
function pruneGroup(g) {
if (g && !g.querySelector('.vdsc-shelf')) {
g.classList.add('vdsc-group--empty');
var id = g.getAttribute('data-group');
var chip = id && $('[data-jump="' + id + '"]');
if (chip) chip.classList.add('hidden');
}
}
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 isHideOwned() {
var h = $('[data-vdsc-hideowned]');
return !!(h && h.checked);
}
// Tag each card with its index so the CSS cascade reveals them one-by-one (not all at once).
// Capped so a long rail's tail doesn't lag too far behind the head.
function stagger(rail) {
if (!rail) return;
var c = rail.children;
for (var i = 0; i < c.length; i++) c[i].style.setProperty('--i', i < 14 ? i : 14);
}
// Stagger every not-yet-tagged filled rail under a host (used by the prepended
// personalized rows, which render their cards inline rather than via fillShelf).
function staggerWithin(host) {
if (!host) return;
var rails = host.querySelectorAll('.vdsc-shelf--in .vdsc-rail');
for (var i = 0; i < rails.length; i++) {
if (!rails[i].getAttribute('data-stg')) { rails[i].setAttribute('data-stg', '1'); stagger(rails[i]); }
}
}
function fillShelf(shelf) {
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');
var ranked = shelf.getAttribute('data-vdsc-ranked') === '1';
var hideOwned = isHideOwned();
// 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.
// Ranked (Top 10): a single fixed chart — never paged, capped at 10. It must ALSO honour
// hide-owned at fetch time: the global `.vdsc-hide-owned .vsr-card--owned{display:none}`
// rule would otherwise hide an owned card while its rank numeral stayed, leaving a gap.
var q = shelf.getAttribute('data-vdsc-q') +
(ranked ? (hideOwned ? '&hide_owned=1' : '')
: (hideOwned ? '&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 = 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); });
}
// ── 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'); }
// Browse-all is a self-contained search: always send a language so it never silently
// inherits the rail language preference. 'any' = show every language; a code filters.
q.push('lang=' + (s.lang || 'any'));
if (s.lang) { var ln = activeChipText('lang'); if (ln) bits.push(ln); }
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 igBtn = $('[data-vdsc-ignore-open]'); if (igBtn && !igBtn._wired) { igBtn._wired = 1; igBtn.addEventListener('click', openIgnoreModal); }
wireNotInterested();
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 */ }
reloadRails(); // rails are server-filtered now (owned + paged deeper) — rebuild
});
}
// Rail language preference — multi-select chips, persisted server-side; rebuilds the rails.
var langWrap = $('[data-vdsc-langs]');
if (langWrap) {
fetch('/api/video/discover/languages', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var set = {}; ((d && d.languages) || ['en']).forEach(function (c) { set[c] = 1; });
langWrap.querySelectorAll('.vdsc-lang').forEach(function (b) {
b.classList.toggle('vdsc-lang--on', !!set[b.getAttribute('data-lang')]);
});
}).catch(function () { /* default chip state stands */ });
langWrap.addEventListener('click', function (e) {
var btn = e.target.closest('.vdsc-lang'); if (!btn) return;
btn.classList.toggle('vdsc-lang--on');
var langs = Array.prototype.map.call(langWrap.querySelectorAll('.vdsc-lang--on'),
function (b) { return b.getAttribute('data-lang'); });
if (!langs.length) { btn.classList.add('vdsc-lang--on'); return; } // never allow empty
fetch('/api/video/discover/languages', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ languages: langs }),
}).then(function () { reloadRailsSoon(); }).catch(function () { /* ignore */ });
});
}
// 'My streaming services' preference — multi-select; drives the 'On your services' rail.
var provWrap = $('[data-vdsc-myprov]');
if (provWrap && !provWrap._wired) {
provWrap._wired = 1;
fetch('/api/video/discover/providers-pref', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var on = {}; ((d && d.providers) || []).forEach(function (c) { on[String(c)] = 1; });
state.myProviders = (d && d.providers) || [];
provWrap.querySelectorAll('.vdsc-lang').forEach(function (b) {
b.classList.toggle('vdsc-lang--on', !!on[b.getAttribute('data-prov')]);
});
}).catch(function () { /* chips stay off */ });
provWrap.addEventListener('click', function (e) {
var btn = e.target.closest('.vdsc-lang'); if (!btn) return;
btn.classList.toggle('vdsc-lang--on');
var provs = Array.prototype.map.call(provWrap.querySelectorAll('.vdsc-lang--on'),
function (b) { return b.getAttribute('data-prov'); });
state.myProviders = provs;
fetch('/api/video/discover/providers-pref', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providers: provs }),
}).then(function () { reloadRailsSoon(); }).catch(function () { /* 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'),
jget('/api/video/discover/providers-pref')])
.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 || [] };
state.myProviders = (res[2] && res[2].providers) || [];
// 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
loadGaps(); // prepend 'what am I missing' (franchise + person) gap rails
loadForYou(); // prepend the blended 'Recommended for you' wall (sits on top)
});
}
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();
})();