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.
This commit is contained in:
BoulderBadgeDad 2026-06-16 13:35:31 -07:00
parent 4b7a915d39
commit a8ebc9b8f7
7 changed files with 758 additions and 15 deletions

View file

@ -45,6 +45,7 @@ def create_video_blueprint() -> Blueprint:
from .enrichment import register_routes as reg_enrichment
from .detail import register_routes as reg_detail
from .search import register_routes as reg_search
from .discover import register_routes as reg_discover
from .calendar import register_routes as reg_calendar
from .watchlist import register_routes as reg_watchlist
reg_dashboard(bp)
@ -55,6 +56,7 @@ def create_video_blueprint() -> Blueprint:
reg_enrichment(bp)
reg_detail(bp)
reg_search(bp)
reg_discover(bp)
reg_calendar(bp)
reg_watchlist(bp)

74
api/video/discover.py Normal file
View file

@ -0,0 +1,74 @@
"""Video discover API — browse TMDB (movies/TV the user doesn't own yet).
GET /api/video/discover/hero trending titles w/ backdrops (slideshow)
GET /api/video/discover/genres {movie:[{id,name}], show:[{id,name}]}
GET /api/video/discover/list?... one shelf/grid of items, e.g.
?key=trending trending movies + shows
?key=<curated>&page= a canned list (popular_movies, top_shows)
?kind=movie|show&genre=&year=&decade=&sort=&page= a filtered browse
Items are annotated with ``library_id`` when already owned (so the card links to
the owned detail, not the TMDB preview). Reads only the enrichment engine +
video.db; isolated from the music API.
"""
from __future__ import annotations
from flask import jsonify, request
from utils.logging_config import get_logger
logger = get_logger("video_api.discover")
def register_routes(bp):
@bp.route("/discover/hero", methods=["GET"])
def video_discover_hero():
"""A few trending titles that have a backdrop — drives the hero slideshow."""
from core.video.enrichment.engine import get_video_enrichment_engine
try:
items = [x for x in (get_video_enrichment_engine().trending() or [])
if x.get("backdrop")][:6]
except Exception:
logger.exception("discover hero failed")
items = []
return jsonify({"items": items})
@bp.route("/discover/genres", methods=["GET"])
def video_discover_genres():
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""
from core.video.enrichment.engine import get_video_enrichment_engine
eng = get_video_enrichment_engine()
try:
return jsonify({"movie": eng.genre_list("movie"), "show": eng.genre_list("show")})
except Exception:
logger.exception("discover genres failed")
return jsonify({"movie": [], "show": []})
@bp.route("/discover/list", methods=["GET"])
def video_discover_list():
"""One shelf (rail) or one page of a filtered browse — see module docstring."""
from core.video.enrichment.engine import get_video_enrichment_engine
eng = get_video_enrichment_engine()
try:
page = max(1, int(request.args.get("page", 1) or 1))
except (TypeError, ValueError):
page = 1
key = (request.args.get("key") or "").strip()
try:
if key == "trending":
items = eng.trending()
elif key:
items = eng.discover_curated(key, page=page)
else:
items = eng.discover_filter(
request.args.get("kind", "movie"),
genre=(request.args.get("genre") or None),
year=(request.args.get("year") or None),
decade=(request.args.get("decade") or None),
sort_by=(request.args.get("sort") or "popularity.desc"),
page=page)
return jsonify({"items": items or [], "page": page})
except Exception:
logger.exception("discover list failed (key=%s)", key)
return jsonify({"items": [], "page": page})

View file

@ -442,29 +442,107 @@ class TMDBClient:
return out
def trending(self, window="week"):
"""Trending movies + shows this week — fills the search page when idle."""
"""Trending movies + shows this week — fills the search page when idle and
the Discover hero slideshow (hence backdrops via _disc_map)."""
if not self.api_key:
return []
import requests
r = requests.get(self.BASE + "/trending/all/" + window,
params={"api_key": self.api_key}, timeout=15)
r.raise_for_status()
return self._disc_map((r.json() or {}).get("results"), None)[:20]
# ── discover (browse TMDB by curated list / genre / year / decade) ────────
def _disc_map(self, results, kind):
"""Flatten a TMDB movie/tv list into SoulSync items. ``kind`` forces the
type for single-type endpoints; pass None to auto-detect each row's
media_type (mixed lists like trending). Carries backdrop + overview so the
Discover hero can render a rich slide."""
out = []
for it in ((r.json() or {}).get("results") or []):
mt, tid = it.get("media_type"), it.get("id")
if not tid or mt not in ("movie", "tv"):
for it in results or []:
tid = it.get("id")
if not tid:
continue
if mt == "movie":
out.append({"kind": "movie", "tmdb_id": tid, "title": it.get("title"),
"year": (it.get("release_date") or "")[:4] or None,
"rating": it.get("vote_average") or None,
"poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None})
else:
out.append({"kind": "show", "tmdb_id": tid, "title": it.get("name"),
"year": (it.get("first_air_date") or "")[:4] or None,
"rating": it.get("vote_average") or None,
"poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None})
return out[:20]
k = kind
if k is None:
mt = it.get("media_type")
k = "movie" if mt == "movie" else "show" if mt == "tv" else None
if k not in ("movie", "show"):
continue
is_movie = k == "movie"
out.append({
"kind": k, "tmdb_id": tid,
"title": it.get("title") if is_movie else it.get("name"),
"year": ((it.get("release_date") if is_movie else it.get("first_air_date")) or "")[:4] or None,
"rating": it.get("vote_average") or None,
"overview": it.get("overview") or None,
"poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None,
"backdrop": (self.BACKDROP_W + it["backdrop_path"]) if it.get("backdrop_path") else None,
})
return out
# canned TMDB lists → (path, forced kind)
_CURATED = {
"popular_movies": ("/movie/popular", "movie"),
"top_movies": ("/movie/top_rated", "movie"),
"now_playing": ("/movie/now_playing", "movie"),
"upcoming_movies": ("/movie/upcoming", "movie"),
"popular_shows": ("/tv/popular", "show"),
"top_shows": ("/tv/top_rated", "show"),
"on_the_air": ("/tv/on_the_air", "show"),
"airing_today": ("/tv/airing_today", "show"),
}
def curated(self, key, page=1):
"""One of the canned TMDB lists (popular / top-rated / now-playing / …)."""
spec = self._CURATED.get(key)
if not spec or not self.api_key:
return []
import requests
path, kind = spec
r = requests.get(self.BASE + path,
params={"api_key": self.api_key, "page": page}, timeout=15)
r.raise_for_status()
return self._disc_map((r.json() or {}).get("results"), kind)
def discover(self, kind, *, genre=None, year=None, decade=None,
sort_by="popularity.desc", page=1):
"""Browse /discover/{movie,tv} filtered by genre / year / decade."""
if not self.api_key:
return []
import requests
is_movie = kind == "movie"
path = "/discover/movie" if is_movie else "/discover/tv"
params = {"api_key": self.api_key, "sort_by": sort_by, "page": page,
"include_adult": "false", "vote_count.gte": 40}
if genre:
params["with_genres"] = genre
if year:
params["primary_release_year" if is_movie else "first_air_date_year"] = year
if decade:
try:
d0 = int(decade)
gte, lte = "%d-01-01" % d0, "%d-12-31" % (d0 + 9)
if is_movie:
params["primary_release_date.gte"], params["primary_release_date.lte"] = gte, lte
else:
params["first_air_date.gte"], params["first_air_date.lte"] = gte, lte
except (TypeError, ValueError):
pass
r = requests.get(self.BASE + path, params=params, timeout=15)
r.raise_for_status()
return self._disc_map((r.json() or {}).get("results"), kind)
def genres(self, kind):
"""TMDB genre id→name list for movies or shows."""
if not self.api_key:
return []
import requests
path = "/genre/movie/list" if kind == "movie" else "/genre/tv/list"
r = requests.get(self.BASE + path, params={"api_key": self.api_key}, timeout=15)
r.raise_for_status()
return [{"id": g["id"], "name": g["name"]}
for g in (r.json() or {}).get("genres") or [] if g.get("id")]
def full_detail(self, kind, tmdb_id, region="US"):
"""Complete detail for a TMDB title NOT in the library — shaped like the

View file

@ -305,6 +305,71 @@ class VideoEnrichmentEngine:
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv)
return cached
# ── discover (browse TMDB lists; owned titles annotated) ──────────────────
def _stamp_owned(self, items):
"""Annotate each movie/show with its library_id for the active server —
stamped fresh (not cached with ownership) so 'In Library' tracks scans."""
srv = self._server()
for r in items or []:
if r.get("kind") in ("movie", "show") and r.get("tmdb_id"):
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv)
return items
def discover_curated(self, key, page=1) -> list:
"""A canned TMDB list (popular / top-rated / now-playing / upcoming /
on-the-air / airing-today), cached then owned-annotated."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
ck = ("disc-cur", key, page)
items = self._cache_get(ck)
if items is None:
try:
items = w.client.curated(key, page=page) or []
self._cache_put(ck, items, ttl=3600)
except Exception:
logger.exception("discover curated failed (%s p%s)", key, page)
return []
return self._stamp_owned(items)
def discover_filter(self, kind, *, genre=None, year=None, decade=None,
sort_by="popularity.desc", page=1) -> list:
"""Browse /discover filtered by genre / year / decade, cached + annotated."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
if kind not in ("movie", "show"):
kind = "movie"
ck = ("disc-flt", kind, genre, year, decade, sort_by, page)
items = self._cache_get(ck)
if items is None:
try:
items = w.client.discover(kind, genre=genre, year=year, decade=decade,
sort_by=sort_by, page=page) or []
self._cache_put(ck, items, ttl=3600)
except Exception:
logger.exception("discover filter failed (%s g=%s y=%s d=%s)", kind, genre, year, decade)
return []
return self._stamp_owned(items)
def genre_list(self, kind) -> list:
"""TMDB genre id→name list (long-cached — these barely change)."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
if kind not in ("movie", "show"):
kind = "movie"
ck = ("genres", kind)
items = self._cache_get(ck)
if items is None:
try:
items = w.client.genres(kind) or []
self._cache_put(ck, items, ttl=86400)
except Exception:
logger.exception("genre list failed (%s)", kind)
return []
return items
def tmdb_detail(self, kind, tmdb_id) -> dict | None:
"""Full detail for a TMDB title not in the library — same shape as the
library detail (source='tmdb', direct image URLs, nothing owned). If it IS

View file

@ -863,6 +863,57 @@
</div>
</div>
</section>
<!-- Discover — browse TMDB titles you don't own: a trending hero
slideshow + a deep stack of lazy genre/decade/curated rails, with
a filter bar that flips into a paged grid. Built by
video/video-discover.js, styled by .vdsc-* in video-side.css. -->
<section class="video-subpage" data-video-subpage="video-discover" hidden>
<div class="vdsc-page">
<div class="vdsc-hero hidden" data-vdsc-hero></div>
<div class="vdsc-filterbar">
<span class="vdsc-filter-label">Browse</span>
<select class="vdsc-select" data-vdsc-f-kind aria-label="Type">
<option value="movie">Movies</option>
<option value="show">TV Shows</option>
</select>
<select class="vdsc-select" data-vdsc-f-genre aria-label="Genre">
<option value="">All genres</option>
</select>
<select class="vdsc-select" data-vdsc-f-decade aria-label="Decade">
<option value="">Any decade</option>
<option value="2020">2020s</option>
<option value="2010">2010s</option>
<option value="2000">2000s</option>
<option value="1990">1990s</option>
<option value="1980">1980s</option>
<option value="1970">1970s</option>
</select>
<select class="vdsc-select" data-vdsc-f-sort aria-label="Sort">
<option value="popularity.desc">Most popular</option>
<option value="vote_average.desc">Top rated</option>
<option value="vote_count.desc">Most voted</option>
</select>
<button class="vdsc-btn" type="button" data-vdsc-apply>Browse</button>
<button class="vdsc-btn vdsc-btn--ghost" type="button" data-vdsc-clear>Clear</button>
</div>
<div class="vdsc-shelves" data-vdsc-shelves></div>
<div class="vdsc-grid-wrap hidden" data-vdsc-grid-wrap>
<div class="vsr-loading hidden" data-vdsc-grid-loading>
<div class="loading-spinner"></div>
<div class="loading-text">Loading&hellip;</div>
</div>
<div class="vsr-grid vdsc-grid" data-vdsc-grid></div>
<div class="vsr-state hidden" data-vdsc-grid-empty>
<div class="vsr-state-ic">&#129300;</div>
<div class="vsr-state-title">Nothing here</div>
<div class="vsr-state-sub">Try a different genre, decade or sort.</div>
</div>
<div class="vdsc-more-wrap">
<button class="vdsc-btn vdsc-more hidden" type="button" data-vdsc-more>Load more</button>
</div>
</div>
</div>
</section>
<!-- Calendar — upcoming TV episodes for owned shows (agenda + day
strip). Built by video/video-calendar.js, styled by .vcal-* in
video-side.css. -->
@ -9571,6 +9622,8 @@
<script src="{{ url_for('static', filename='video/video-detail.js', v=static_v) }}"></script>
<!-- Video search (isolated; in-app multi-search → library/tmdb detail) -->
<script src="{{ url_for('static', filename='video/video-search.js', v=static_v) }}"></script>
<!-- Video discover (isolated; trending hero + lazy genre/decade rails + filter grid) -->
<script src="{{ url_for('static', filename='video/video-discover.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='video/video-calendar.js', v=static_v) }}"></script>
<!-- Video person page (isolated; cast/crew drill-in → filmography) -->
<script src="{{ url_for('static', filename='video/video-person.js', v=static_v) }}"></script>

View file

@ -0,0 +1,371 @@
/*
* 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();
})();

View file

@ -2158,3 +2158,103 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vgm-overlay.vgm-open .vgm-hero-content > * { animation: none; }
.vgm-modal { transition: opacity 0.2s ease; }
}
/*
Discover page trending hero slideshow + lazy genre/decade rails (.vdsc-*)
*/
.vdsc-page { padding: 0 0 60px; }
/* ── hero slideshow ───────────────────────────────────────────────────────── */
.vdsc-hero { position: relative; height: 420px; border-radius: 20px; overflow: hidden;
margin: 4px 0 26px; background: #0d0d12; isolation: isolate; }
.vdsc-hero.hidden { display: none; }
.vdsc-slides { position: absolute; inset: 0; }
.vdsc-slide { position: absolute; inset: 0; background-size: cover; background-position: center 18%;
opacity: 0; transition: opacity 1.1s ease; transform: scale(1.06); }
.vdsc-slide--on { opacity: 1; animation: vdscKen 12s ease-out forwards; }
@keyframes vdscKen { from { transform: scale(1.12); } to { transform: scale(1.0); } }
.vdsc-slide-scrim { position: absolute; inset: 0;
background: linear-gradient(90deg, #0b0b10 6%, rgba(11, 11, 16, 0.72) 38%, rgba(11, 11, 16, 0.15) 70%, transparent),
linear-gradient(0deg, #0b0b10 2%, transparent 42%),
radial-gradient(120% 130% at 0% 100%, hsla(var(--vgm-h, 230), 80%, 50%, 0.34), transparent 58%); }
.vdsc-hero-body { position: absolute; left: 40px; bottom: 34px; z-index: 2; max-width: 560px;
animation: vdscRise 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
@keyframes vdscRise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
.vdsc-hero-eyebrow { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em;
color: hsl(var(--vgm-h, 230), 85%, 78%); margin-bottom: 8px; }
.vdsc-hero-title { font-size: 38px; font-weight: 900; letter-spacing: -0.03em; line-height: 1.04; margin: 0;
color: #fff; text-shadow: 0 3px 26px rgba(0, 0, 0, 0.7), 0 0 46px hsla(var(--vgm-h, 230), 80%, 55%, 0.3); }
.vdsc-hero-pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 13px 0 0; }
.vdsc-hero-pill { font-size: 12px; font-weight: 700; padding: 4px 11px; border-radius: 999px;
background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.9); backdrop-filter: blur(4px); }
.vdsc-hero-ov { font-size: 14px; line-height: 1.6; color: rgba(255, 255, 255, 0.82); margin: 14px 0 0;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.vdsc-hero-cta { margin-top: 18px;
background: linear-gradient(135deg, hsl(var(--vgm-h, 230), 70%, 58%), hsl(var(--vgm-h, 230), 72%, 47%)) !important;
box-shadow: 0 12px 32px hsla(var(--vgm-h, 230), 70%, 45%, 0.5) !important; }
.vdsc-hero-cta:hover { transform: translateY(-2px); filter: brightness(1.07); }
.vdsc-dots { position: absolute; right: 26px; bottom: 26px; z-index: 3; display: flex; gap: 8px; }
.vdsc-dot { width: 9px; height: 9px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
background: rgba(255, 255, 255, 0.32); transition: all 0.2s ease; }
.vdsc-dot--on { background: hsl(var(--vgm-h, 230), 85%, 65%); width: 26px; border-radius: 5px; }
/* ── filter bar ───────────────────────────────────────────────────────────── */
.vdsc-filterbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 24px;
padding: 14px 16px; border-radius: 14px; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07); }
.vdsc-filter-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.42); margin-right: 2px; }
.vdsc-select { padding: 10px 13px; border-radius: 10px; background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; outline: none; transition: border-color 0.15s ease; }
.vdsc-select:focus { border-color: rgba(var(--accent-rgb, 88 101 242), 0.6); }
.vdsc-select option { background: #16161d; color: #fff; }
.vdsc-btn { padding: 10px 18px; border-radius: 10px; font-size: 13px; font-weight: 800; cursor: pointer;
border: 1px solid transparent; background: rgb(var(--accent-rgb, 88 101 242)); color: #fff;
box-shadow: 0 8px 22px rgba(var(--accent-rgb, 88 101 242), 0.34); transition: all 0.15s ease; }
.vdsc-btn:hover { filter: brightness(1.08); transform: translateY(-1px); }
.vdsc-btn--ghost { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.82); box-shadow: none; }
.vdsc-btn--ghost:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
/* ── shelves (lazy rails) ─────────────────────────────────────────────────── */
.vdsc-shelves.hidden, .vdsc-grid-wrap.hidden { display: none; }
.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; }
.vdsc-shelf-nav { display: flex; gap: 7px; }
.vdsc-arrow { width: 32px; height: 32px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.8); font-size: 18px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; }
.vdsc-arrow:hover { background: rgba(var(--accent-rgb, 88 101 242), 0.85); border-color: transparent; color: #fff; }
.vdsc-rail { display: flex; gap: 16px; overflow-x: auto; overflow-y: hidden; padding: 6px 2px 12px;
scroll-snap-type: x proximity; scrollbar-width: thin; }
.vdsc-rail::-webkit-scrollbar { height: 8px; }
.vdsc-rail::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 4px; }
.vdsc-rail::-webkit-scrollbar-track { background: transparent; }
.vdsc-rail .vsr-card { flex: 0 0 162px; width: 162px; scroll-snap-align: start; }
/* skeleton shimmer while a rail loads */
.vdsc-skel { display: flex; gap: 16px; }
.vdsc-skel-card { flex: 0 0 162px; height: 280px; border-radius: 12px;
background: linear-gradient(100deg, rgba(255, 255, 255, 0.04) 30%, rgba(255, 255, 255, 0.09) 50%, rgba(255, 255, 255, 0.04) 70%);
background-size: 220% 100%; animation: vdscShimmer 1.4s ease-in-out infinite; }
@keyframes vdscShimmer { from { background-position: 180% 0; } to { background-position: -80% 0; } }
/* ── filter grid ──────────────────────────────────────────────────────────── */
.vdsc-grid { margin-top: 4px; }
.vdsc-more-wrap { display: flex; justify-content: center; margin-top: 26px; }
.vdsc-more { padding: 12px 30px; }
.vdsc-more.hidden { display: none; }
@media (max-width: 720px) {
.vdsc-hero { height: 340px; }
.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; }
}
@media (prefers-reduced-motion: reduce) {
.vdsc-slide, .vdsc-slide--on { animation: none; transition: opacity 0.4s ease; transform: none; }
.vdsc-hero-body { animation: none; }
.vdsc-skel-card { animation: none; }
}