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:
BoulderBadgeDad 2026-06-16 14:44:44 -07:00
parent 5aa792d399
commit a9ec025706
8 changed files with 363 additions and 1 deletions

View file

@ -51,6 +51,56 @@ def register_routes(bp):
logger.exception("discover taste failed") logger.exception("discover taste failed")
return jsonify({"movie": [], "show": []}) 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"]) @bp.route("/discover/genres", methods=["GET"])
def video_discover_genres(): def video_discover_genres():
"""Genre id→name maps for both kinds (powers the genre rails + filter).""" """Genre id→name maps for both kinds (powers the genre rails + filter)."""

View file

@ -544,6 +544,37 @@ class TMDBClient:
return [{"id": g["id"], "name": g["name"]} return [{"id": g["id"], "name": g["name"]}
for g in (r.json() or {}).get("genres") or [] if g.get("id")] 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"): def full_detail(self, kind, tmdb_id, region="US"):
"""Complete detail for a TMDB title NOT in the library — shaped like the """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 library detail payload but with direct image URLs (so the same detail UI

View file

@ -371,6 +371,38 @@ class VideoEnrichmentEngine:
return [] return []
return items 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: def tmdb_detail(self, kind, tmdb_id) -> dict | None:
"""Full detail for a TMDB title not in the library — same shape as the """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 library detail (source='tmdb', direct image URLs, nothing owned). If it IS

View file

@ -425,6 +425,31 @@ class VideoDatabase:
finally: finally:
conn.close() 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: def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced. """Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
Ratings are dynamic, so these overwrite (unlike gap-only metadata).""" Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""

View file

@ -58,6 +58,35 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/discover/genres" in rules assert "/api/video/discover/genres" in rules
assert "/api/video/discover/taste" in rules assert "/api/video/discover/taste" in rules
assert "/api/video/discover/list" 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): def test_discover_list_pages_concatenates_and_dedupes(tmp_path, monkeypatch):

View file

@ -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 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): def test_top_owned_genres_orders_by_count(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1, db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1,
"genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}}) "genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}})

View file

@ -148,9 +148,14 @@
'<div class="vdsc-hero-pills">' + pills.map(function (p) { '<div class="vdsc-hero-pills">' + pills.map(function (p) {
return '<span class="vdsc-hero-pill">' + esc(p) + '</span>'; }).join('') + '</div>' + return '<span class="vdsc-hero-pill">' + esc(p) + '</span>'; }).join('') + '</div>' +
(it.overview ? '<p class="vdsc-hero-ov">' + esc(it.overview) + '</p>' : '') + (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" ' + '<button class="discog-submit-btn vdsc-hero-cta" type="button" ' +
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' + '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) { function goHero(i) {
var items = state.hero.items; if (!items.length) return; 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; } } 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">&times;</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">Couldnt 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) ────────────────────────────────────────────────── // ── shelves (lazy rails) ──────────────────────────────────────────────────
function renderShelves() { function renderShelves() {
var host = $('[data-vdsc-shelves]'); if (!host) return; var host = $('[data-vdsc-shelves]'); if (!host) return;
@ -322,6 +402,11 @@
})); }));
return; 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]'); var seeall = e.target.closest('[data-vdsc-seeall]');
if (seeall) { if (seeall) {
var shelf = seeall.closest('.vdsc-shelf'); var shelf = seeall.closest('.vdsc-shelf');
@ -386,6 +471,7 @@
if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; } if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; }
renderGenreChips(); renderGenreChips();
renderShelves(); renderShelves();
loadMoreLike(); // prepend personalized 'More like…' rails when ready
requestAnimationFrame(positionSegs); requestAnimationFrame(positionSegs);
}); });
} }

View file

@ -2351,3 +2351,37 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdsc-seg::before { transition: 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; } .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; } }