Discover: 'More like…' rails + in-app hero trailer
More-like rails (real personalization beyond genre): - db.random_owned_titles() seeds from a few random owned titles (with tmdb_id); client.recommendations() + engine.recommendations() (cached + owned-annotated) fetch TMDB recs. New /discover/morelike interleaves movie/show seeds (max 3 rails) and the page prepends them, pre-filled, above the rail stack with a gradient 'for you' title. Hero trailer (cheap big visual win): - client.video_trailer() (light /videos call, Trailer over Teaser) + engine .trailer() (day-cached) + /discover/trailer. Hero gets a 'Trailer' button that opens an in-app YouTube lightbox (autoplay, Esc/backdrop close, pauses the slideshow) — nothing leaves SoulSync. Tests: +11 (recs parse/annotate/cache, trailer pick+fallback+cache, random owned seeds owned-with-tmdb only, /discover/trailer + /discover/morelike endpoints). Enrichment + API suites: 114 passed.
This commit is contained in:
parent
5aa792d399
commit
a9ec025706
8 changed files with 363 additions and 1 deletions
|
|
@ -51,6 +51,56 @@ def register_routes(bp):
|
|||
logger.exception("discover taste failed")
|
||||
return jsonify({"movie": [], "show": []})
|
||||
|
||||
@bp.route("/discover/morelike", methods=["GET"])
|
||||
def video_discover_morelike():
|
||||
"""'More like <owned title>' rails — TMDB recommendations seeded from a few
|
||||
random titles you already own (interleaved movie/show, max 3 rails)."""
|
||||
from . import get_video_db
|
||||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||
try:
|
||||
from core.video.sources import resolve_video_server
|
||||
srv = resolve_video_server()
|
||||
except Exception:
|
||||
srv = None
|
||||
db = get_video_db()
|
||||
eng = get_video_enrichment_engine()
|
||||
try:
|
||||
seeds = db.random_owned_titles(2, srv)
|
||||
movies = [s for s in seeds if s["kind"] == "movie"]
|
||||
shows = [s for s in seeds if s["kind"] == "show"]
|
||||
ordered = []
|
||||
while (movies or shows) and len(ordered) < 3:
|
||||
if movies:
|
||||
ordered.append(movies.pop(0))
|
||||
if shows and len(ordered) < 3:
|
||||
ordered.append(shows.pop(0))
|
||||
rails = []
|
||||
for s in ordered:
|
||||
items = [it for it in eng.recommendations(s["kind"], s["tmdb_id"])
|
||||
if it.get("tmdb_id") != s["tmdb_id"]]
|
||||
if len(items) >= 4:
|
||||
rails.append({"title": "More like " + s["title"], "items": items[:30]})
|
||||
return jsonify({"rails": rails})
|
||||
except Exception:
|
||||
logger.exception("discover morelike failed")
|
||||
return jsonify({"rails": []})
|
||||
|
||||
@bp.route("/discover/trailer", methods=["GET"])
|
||||
def video_discover_trailer():
|
||||
"""Best YouTube trailer {key,name} for a tmdb title (hero 'Trailer' button)."""
|
||||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||
kind = request.args.get("kind", "movie")
|
||||
try:
|
||||
tmdb_id = int(request.args.get("tmdb_id"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"trailer": None})
|
||||
try:
|
||||
tr = get_video_enrichment_engine().trailer(kind, tmdb_id)
|
||||
except Exception:
|
||||
logger.exception("discover trailer failed")
|
||||
tr = None
|
||||
return jsonify({"trailer": tr or None})
|
||||
|
||||
@bp.route("/discover/genres", methods=["GET"])
|
||||
def video_discover_genres():
|
||||
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""
|
||||
|
|
|
|||
|
|
@ -544,6 +544,37 @@ class TMDBClient:
|
|||
return [{"id": g["id"], "name": g["name"]}
|
||||
for g in (r.json() or {}).get("genres") or [] if g.get("id")]
|
||||
|
||||
def recommendations(self, kind, tmdb_id, page=1):
|
||||
"""TMDB 'recommended' titles for a movie/show — powers 'More like …' rails."""
|
||||
if not self.api_key or tmdb_id is None:
|
||||
return []
|
||||
import requests
|
||||
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id) + "/recommendations"
|
||||
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 video_trailer(self, kind, tmdb_id):
|
||||
"""The best YouTube trailer key for a title (official Trailer over Teaser).
|
||||
Light — just the /videos endpoint, not the whole detail append."""
|
||||
if not self.api_key or tmdb_id is None:
|
||||
return None
|
||||
import requests
|
||||
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id) + "/videos"
|
||||
r = requests.get(self.BASE + path, params={"api_key": self.api_key}, timeout=15)
|
||||
r.raise_for_status()
|
||||
teaser = None
|
||||
for v in ((r.json() or {}).get("results") or []):
|
||||
if v.get("site") != "YouTube" or not v.get("key"):
|
||||
continue
|
||||
t = v.get("type") or ""
|
||||
if t == "Trailer":
|
||||
return {"key": v["key"], "name": v.get("name")}
|
||||
if t == "Teaser" and teaser is None:
|
||||
teaser = {"key": v["key"], "name": v.get("name")}
|
||||
return teaser
|
||||
|
||||
def full_detail(self, kind, tmdb_id, region="US"):
|
||||
"""Complete detail for a TMDB title NOT in the library — shaped like the
|
||||
library detail payload but with direct image URLs (so the same detail UI
|
||||
|
|
|
|||
|
|
@ -371,6 +371,38 @@ class VideoEnrichmentEngine:
|
|||
return []
|
||||
return items
|
||||
|
||||
def recommendations(self, kind, tmdb_id, page=1) -> list:
|
||||
"""'More like this' titles for a tmdb id — cached + owned-annotated."""
|
||||
w = self.workers.get("tmdb")
|
||||
if not w or not w.enabled or not tmdb_id:
|
||||
return []
|
||||
ck = ("recs", kind, tmdb_id, page)
|
||||
items = self._cache_get(ck)
|
||||
if items is None:
|
||||
try:
|
||||
items = w.client.recommendations(kind, tmdb_id, page=page) or []
|
||||
self._cache_put(ck, items, ttl=3600)
|
||||
except Exception:
|
||||
logger.exception("recommendations failed (%s %s)", kind, tmdb_id)
|
||||
return []
|
||||
return self._stamp_owned(items)
|
||||
|
||||
def trailer(self, kind, tmdb_id) -> dict | None:
|
||||
"""Best YouTube trailer for a title (cached a day — trailers don't move)."""
|
||||
w = self.workers.get("tmdb")
|
||||
if not w or not w.enabled or not tmdb_id:
|
||||
return None
|
||||
ck = ("trailer", kind, tmdb_id)
|
||||
cached = self._cache_get(ck)
|
||||
if cached is None:
|
||||
try:
|
||||
cached = w.client.video_trailer(kind, tmdb_id) or {}
|
||||
self._cache_put(ck, cached, ttl=86400)
|
||||
except Exception:
|
||||
logger.exception("trailer failed (%s %s)", kind, tmdb_id)
|
||||
return None
|
||||
return cached or None
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -425,6 +425,31 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def random_owned_titles(self, limit: int = 2, server_source=None) -> list:
|
||||
"""A few random owned titles (with a tmdb_id) to seed 'More like …' rails —
|
||||
up to ``limit`` movies and ``limit`` shows."""
|
||||
out = []
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
for kind, tbl, alias, owned in (("movie", "movies", "m", "m.has_file=1"),
|
||||
("show", "shows", "s", "1=1")):
|
||||
sql = (f"SELECT {alias}.id AS id, {alias}.tmdb_id AS tmdb_id, {alias}.title AS title "
|
||||
f"FROM {tbl} {alias} WHERE {alias}.tmdb_id IS NOT NULL AND {owned}")
|
||||
args: list = []
|
||||
if server_source:
|
||||
sql += f" AND {alias}.server_source=?"
|
||||
args.append(server_source)
|
||||
sql += " ORDER BY RANDOM() LIMIT ?"
|
||||
args.append(int(limit))
|
||||
for r in conn.execute(sql, args):
|
||||
out.append({"kind": kind, "tmdb_id": r["tmdb_id"],
|
||||
"title": r["title"], "library_id": r["id"]})
|
||||
return out
|
||||
except sqlite3.Error:
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
|
||||
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
|
||||
Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""
|
||||
|
|
|
|||
|
|
@ -58,6 +58,35 @@ def test_blueprint_exposes_dashboard_route():
|
|||
assert "/api/video/discover/genres" in rules
|
||||
assert "/api/video/discover/taste" in rules
|
||||
assert "/api/video/discover/list" in rules
|
||||
assert "/api/video/discover/morelike" in rules
|
||||
assert "/api/video/discover/trailer" in rules
|
||||
|
||||
|
||||
def test_discover_trailer_returns_key(tmp_path, monkeypatch):
|
||||
client, _ = _make_client(tmp_path)
|
||||
import core.video.enrichment.engine as eng_mod
|
||||
|
||||
class FakeEng:
|
||||
def trailer(self, kind, tmdb_id): return {"key": "abc123", "name": "Official"}
|
||||
monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng())
|
||||
assert client.get("/api/video/discover/trailer?kind=movie&tmdb_id=5").get_json()["trailer"]["key"] == "abc123"
|
||||
# a non-numeric id is rejected without touching the engine
|
||||
assert client.get("/api/video/discover/trailer?kind=movie").get_json() == {"trailer": None}
|
||||
|
||||
|
||||
def test_discover_morelike_builds_seeded_rails(tmp_path, monkeypatch):
|
||||
client, vapi = _make_client(tmp_path)
|
||||
db = vapi._video_db
|
||||
db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 1, "file": {"relative_path": "a.mkv"}})
|
||||
import core.video.enrichment.engine as eng_mod
|
||||
|
||||
class FakeEng:
|
||||
def recommendations(self, kind, tmdb_id, page=1):
|
||||
return [{"kind": "movie", "tmdb_id": 100 + i} for i in range(6)]
|
||||
monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng())
|
||||
rails = client.get("/api/video/discover/morelike").get_json()["rails"]
|
||||
assert rails and rails[0]["title"] == "More like Dune"
|
||||
assert len(rails[0]["items"]) == 6
|
||||
|
||||
|
||||
def test_discover_list_pages_concatenates_and_dedupes(tmp_path, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -1152,6 +1152,81 @@ def test_stamp_owned_is_one_query_per_kind(db, monkeypatch):
|
|||
assert items[0]["library_id"] is not None and items[1]["library_id"] is None
|
||||
|
||||
|
||||
def test_tmdb_recommendations_parses_with_forced_kind(monkeypatch):
|
||||
body = {"results": [
|
||||
{"id": 5, "title": "Rec", "release_date": "2019-01-01", "poster_path": "/r.jpg",
|
||||
"backdrop_path": "/b.jpg", "vote_average": 7.0, "overview": "O"}]}
|
||||
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
|
||||
res = TMDBClient("KEY").recommendations("movie", 1)
|
||||
assert res[0]["kind"] == "movie" and res[0]["tmdb_id"] == 5 and res[0]["year"] == "2019"
|
||||
|
||||
|
||||
def test_tmdb_video_trailer_prefers_trailer_over_teaser(monkeypatch):
|
||||
body = {"results": [
|
||||
{"site": "YouTube", "type": "Teaser", "key": "teasekey", "name": "Teaser"},
|
||||
{"site": "Vimeo", "type": "Trailer", "key": "nope"}, # wrong site, ignored
|
||||
{"site": "YouTube", "type": "Trailer", "key": "trailkey", "name": "Official"}]}
|
||||
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
|
||||
assert TMDBClient("KEY").video_trailer("movie", 1) == {"key": "trailkey", "name": "Official"}
|
||||
|
||||
|
||||
def test_tmdb_video_trailer_falls_back_to_teaser(monkeypatch):
|
||||
body = {"results": [{"site": "YouTube", "type": "Teaser", "key": "teasekey", "name": "T"}]}
|
||||
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
|
||||
assert TMDBClient("KEY").video_trailer("show", 1) == {"key": "teasekey", "name": "T"}
|
||||
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp({"results": []})))
|
||||
assert TMDBClient("KEY").video_trailer("movie", 1) is None # nothing → None
|
||||
|
||||
|
||||
def test_engine_recommendations_annotates_and_caches(db):
|
||||
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
|
||||
calls = []
|
||||
|
||||
class Tmdb:
|
||||
enabled = True
|
||||
def recommendations(self, kind, tmdb_id, page=1):
|
||||
calls.append((kind, tmdb_id, page))
|
||||
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
|
||||
{"kind": "movie", "tmdb_id": 2, "title": "Other"}]
|
||||
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
|
||||
res = eng.recommendations("movie", 99)
|
||||
assert res[0]["library_id"] == mid and res[1]["library_id"] is None
|
||||
eng.recommendations("movie", 99)
|
||||
assert calls == [("movie", 99, 1)] # cached on repeat
|
||||
|
||||
|
||||
def test_engine_trailer_caches(db):
|
||||
calls = []
|
||||
|
||||
class Tmdb:
|
||||
enabled = True
|
||||
def video_trailer(self, kind, tmdb_id):
|
||||
calls.append((kind, tmdb_id)); return {"key": "abc", "name": "T"}
|
||||
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
|
||||
assert eng.trailer("movie", 5)["key"] == "abc"
|
||||
eng.trailer("movie", 5)
|
||||
assert calls == [("movie", 5)]
|
||||
|
||||
|
||||
def test_engine_trailer_none_when_no_video(db):
|
||||
class Tmdb:
|
||||
enabled = True
|
||||
def video_trailer(self, kind, tmdb_id): return None
|
||||
assert VideoEnrichmentEngine(db, {"tmdb": Tmdb()}).trailer("movie", 5) is None
|
||||
|
||||
|
||||
def test_random_owned_titles_only_owned_with_tmdb(db):
|
||||
db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1, "file": {"relative_path": "a.mkv"}})
|
||||
db.upsert_movie("plex", {"server_id": "m2", "title": "NoFile", "tmdb_id": 2}) # not owned
|
||||
db.upsert_movie("plex", {"server_id": "m3", "title": "NoTmdb", "file": {"relative_path": "c.mkv"}}) # no tmdb
|
||||
db.upsert_show_tree("plex", {"server_id": "s1", "title": "Show", "tmdb_id": 9, "seasons": []})
|
||||
seeds = db.random_owned_titles(5, server_source="plex")
|
||||
by_title = {s["title"]: s for s in seeds}
|
||||
assert "Owned" in by_title and by_title["Owned"]["tmdb_id"] == 1
|
||||
assert "Show" in by_title # library shows seed too
|
||||
assert "NoFile" not in by_title and "NoTmdb" not in by_title
|
||||
|
||||
|
||||
def test_top_owned_genres_orders_by_count(db):
|
||||
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1,
|
||||
"genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}})
|
||||
|
|
|
|||
|
|
@ -148,9 +148,14 @@
|
|||
'<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>' : '') +
|
||||
'<div class="vdsc-hero-actions">' +
|
||||
'<button class="discog-submit-btn vdsc-hero-cta" type="button" ' +
|
||||
'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>';
|
||||
'<span class="discog-submit-text">View ' + (it.kind === 'movie' ? 'movie' : 'show') + ' →</span></button>' +
|
||||
'<button class="vdsc-hero-trailer" type="button" data-vdsc-trailer ' +
|
||||
'data-kind="' + it.kind + '" data-tmdb="' + it.tmdb_id + '" data-title="' + esc(it.title) + '">' +
|
||||
'<span class="vdsc-tr-ic" aria-hidden="true">▶</span> Trailer</button>' +
|
||||
'</div>';
|
||||
}
|
||||
function goHero(i) {
|
||||
var items = state.hero.items; if (!items.length) return;
|
||||
|
|
@ -169,6 +174,81 @@
|
|||
}
|
||||
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 =
|
||||
'<div class="vdsc-tr-box" role="dialog" aria-modal="true">' +
|
||||
'<button class="vdsc-tr-close" type="button" data-vdsc-tr-close aria-label="Close">×</button>' +
|
||||
'<div class="vdsc-tr-frame" data-vdsc-tr-frame><div class="loading-spinner"></div></div>' +
|
||||
'<div class="vdsc-tr-title">' + esc(title || '') + '</div>' +
|
||||
'</div>';
|
||||
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 = '<div class="vdsc-tr-none">No trailer available</div>'; return; }
|
||||
if (frame) frame.innerHTML = '<iframe src="https://www.youtube.com/embed/' + encodeURIComponent(key) +
|
||||
'?autoplay=1&rel=0" title="Trailer" frameborder="0" ' +
|
||||
'allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen></iframe>';
|
||||
})
|
||||
.catch(function () {
|
||||
var frame = trailerEl && trailerEl.querySelector('[data-vdsc-tr-frame]');
|
||||
if (frame) frame.innerHTML = '<div class="vdsc-tr-none">Couldn’t load trailer</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── "More like <owned title>" rails (pre-filled, prepended above the stack) ─
|
||||
function loadMoreLike() {
|
||||
fetch('/api/video/discover/morelike', { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
var rails = (d && d.rails) || [];
|
||||
var host = $('[data-vdsc-shelves]');
|
||||
if (!rails.length || !host) return;
|
||||
var html = rails.map(function (rl) {
|
||||
return '<section class="vdsc-shelf vdsc-shelf--in vdsc-shelf--ml" data-vdsc-loaded="1">' +
|
||||
'<div class="vdsc-shelf-head">' +
|
||||
'<h3 class="vdsc-shelf-title">' + esc(rl.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>' + (rl.items || []).map(card).join('') + '</div>' +
|
||||
'</section>';
|
||||
}).join('');
|
||||
host.insertAdjacentHTML('afterbegin', html);
|
||||
hydrateGet(host);
|
||||
})
|
||||
.catch(function () { /* personalization is best-effort */ });
|
||||
}
|
||||
|
||||
// ── shelves (lazy rails) ──────────────────────────────────────────────────
|
||||
function renderShelves() {
|
||||
var host = $('[data-vdsc-shelves]'); if (!host) return;
|
||||
|
|
@ -322,6 +402,11 @@
|
|||
}));
|
||||
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');
|
||||
|
|
@ -386,6 +471,7 @@
|
|||
if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; }
|
||||
renderGenreChips();
|
||||
renderShelves();
|
||||
loadMoreLike(); // prepend personalized 'More like…' rails when ready
|
||||
requestAnimationFrame(positionSegs);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2351,3 +2351,37 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdsc-seg::before { transition: none; }
|
||||
.vdsc-chip, .vdsc-chip:hover, .vdsc-chip--on { transition: color 0.15s ease, background 0.15s ease; transform: none; }
|
||||
}
|
||||
|
||||
/* ── hero actions: View CTA + Trailer button ──────────────────────────────── */
|
||||
.vdsc-hero-actions { display: flex; align-items: center; gap: 12px; margin-top: 18px; flex-wrap: wrap; }
|
||||
.vdsc-hero-actions .vdsc-hero-cta { margin-top: 0; }
|
||||
.vdsc-hero-trailer { display: inline-flex; align-items: center; gap: 8px; padding: 11px 19px; border-radius: 12px;
|
||||
font-size: 13px; font-weight: 800; cursor: pointer; color: #fff; backdrop-filter: blur(6px);
|
||||
background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; }
|
||||
.vdsc-hero-trailer:hover { background: rgba(255, 255, 255, 0.18); border-color: rgba(255, 255, 255, 0.42); transform: translateY(-1px); }
|
||||
.vdsc-tr-ic { font-size: 10px; }
|
||||
|
||||
/* "More like…" rails get a gradient title as a personalized cue */
|
||||
.vdsc-shelf--ml .vdsc-shelf-title { color: #fff;
|
||||
background: linear-gradient(90deg, #fff 35%, hsl(265 85% 78%) 100%);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
/* ── trailer lightbox (in-app YouTube embed) ──────────────────────────────── */
|
||||
.vdsc-tr-overlay { position: fixed; inset: 0; z-index: 9200; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; background: rgba(3, 3, 6, 0.86); backdrop-filter: blur(10px); opacity: 0; transition: opacity 0.2s ease; }
|
||||
.vdsc-tr-overlay.vdsc-tr-open { opacity: 1; }
|
||||
.vdsc-tr-box { position: relative; width: min(960px, 100%); }
|
||||
.vdsc-tr-close { position: absolute; top: -46px; right: 0; width: 38px; height: 38px; border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.5); color: #fff; font-size: 22px; line-height: 1;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s ease, transform 0.25s ease; }
|
||||
.vdsc-tr-close:hover { background: rgba(0, 0, 0, 0.82); transform: rotate(90deg); }
|
||||
.vdsc-tr-frame { position: relative; aspect-ratio: 16 / 9; border-radius: 14px; overflow: hidden; background: #000;
|
||||
box-shadow: 0 40px 120px rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center;
|
||||
transform: scale(0.97); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
.vdsc-tr-open .vdsc-tr-frame { transform: none; }
|
||||
.vdsc-tr-frame iframe { width: 100%; height: 100%; border: 0; }
|
||||
.vdsc-tr-none { color: rgba(255, 255, 255, 0.6); font-size: 15px; font-weight: 600; }
|
||||
.vdsc-tr-title { margin-top: 14px; text-align: center; font-size: 15px; font-weight: 700; color: rgba(255, 255, 255, 0.85); }
|
||||
@media (prefers-reduced-motion: reduce) { .vdsc-tr-frame { transition: none; } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue