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:
parent
4b7a915d39
commit
a8ebc9b8f7
7 changed files with 758 additions and 15 deletions
|
|
@ -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
74
api/video/discover.py
Normal 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})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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…</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">🤔</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>
|
||||
|
|
|
|||
371
webui/static/video/video-discover.js
Normal file
371
webui/static/video/video-discover.js
Normal 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, '&').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; }
|
||||
|
||||
// ── 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="vsr-poster-ph">' + 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();
|
||||
})();
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue