video detail: deeper TMDB extras — trailer, where-to-watch, more like this
Phase 4: dynamic extras fetched LIVE per view (providers change, so not cached) via GET /detail/<kind>/<id>/extras → engine.item_extras → TMDB (videos + watch/providers + similar in one call). - Trailer: a '▶ Trailer' action that opens an in-app YouTube modal embed (Esc / click-away to close). - Where to Watch: provider logos for the region (JustWatch via TMDB). - More Like This: a poster row of similar titles linking out to TMDB. Both movie + show pages; all keyless (same TMDB key). Seam tests: extras parse (trailer priority, provider/similar shape), item_extras gating on tmdb_id, route registered, markup hooks. (RT/Metacritic via OMDb needs its own key — offered separately.)
This commit is contained in:
parent
efa0632883
commit
6e526d7745
9 changed files with 267 additions and 2 deletions
|
|
@ -66,3 +66,15 @@ def register_routes(bp):
|
||||||
logger.exception("refresh-art failed for movie %s", movie_id)
|
logger.exception("refresh-art failed for movie %s", movie_id)
|
||||||
res = {"ok": False, "reason": "error"}
|
res = {"ok": False, "reason": "error"}
|
||||||
return jsonify(res)
|
return jsonify(res)
|
||||||
|
|
||||||
|
@bp.route("/detail/<kind>/<int:item_id>/extras", methods=["GET"])
|
||||||
|
def video_detail_extras(kind, item_id):
|
||||||
|
"""Live TMDB extras (trailer / where-to-watch / similar) for the detail page."""
|
||||||
|
if kind not in ("movie", "show"):
|
||||||
|
return jsonify({}), 400
|
||||||
|
try:
|
||||||
|
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||||
|
return jsonify(get_video_enrichment_engine().item_extras(kind, item_id))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("extras failed for %s %s", kind, item_id)
|
||||||
|
return jsonify({})
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,58 @@ class TMDBClient:
|
||||||
if crew:
|
if crew:
|
||||||
meta["crew"] = crew
|
meta["crew"] = crew
|
||||||
|
|
||||||
|
POSTER_W = "https://image.tmdb.org/t/p/w300"
|
||||||
|
PROVIDER = "https://image.tmdb.org/t/p/original"
|
||||||
|
|
||||||
|
def extras(self, kind, tmdb_id, region="US"):
|
||||||
|
"""Live detail extras (not cached — providers change): a trailer, the
|
||||||
|
'where to watch' providers for a region, and similar titles."""
|
||||||
|
if not self.api_key or tmdb_id is None:
|
||||||
|
return {}
|
||||||
|
import requests
|
||||||
|
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id)
|
||||||
|
r = requests.get(self.BASE + path, params={
|
||||||
|
"api_key": self.api_key, "append_to_response": "videos,watch/providers,similar"}, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
d = r.json() or {}
|
||||||
|
out = {}
|
||||||
|
|
||||||
|
# Trailer — prefer a YouTube "Trailer", fall back to a teaser.
|
||||||
|
trailer = None
|
||||||
|
for v in (d.get("videos") or {}).get("results") or []:
|
||||||
|
if v.get("site") == "YouTube" and v.get("type") in ("Trailer", "Teaser") and v.get("key"):
|
||||||
|
trailer = {"key": v["key"], "name": v.get("name")}
|
||||||
|
if v.get("type") == "Trailer":
|
||||||
|
break
|
||||||
|
if trailer:
|
||||||
|
out["trailer"] = trailer
|
||||||
|
|
||||||
|
# Where to watch (one region; JustWatch-powered).
|
||||||
|
wp = ((d.get("watch/providers") or {}).get("results") or {}).get(region) or {}
|
||||||
|
provs, seen = [], set()
|
||||||
|
for grp in ("flatrate", "free", "ads", "rent", "buy"):
|
||||||
|
for p in (wp.get(grp) or []):
|
||||||
|
name = p.get("provider_name")
|
||||||
|
if name and name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
provs.append({"name": name,
|
||||||
|
"logo": (self.PROVIDER + p["logo_path"]) if p.get("logo_path") else None})
|
||||||
|
if provs:
|
||||||
|
out["providers"] = provs[:8]
|
||||||
|
out["providers_link"] = wp.get("link")
|
||||||
|
out["region"] = region
|
||||||
|
|
||||||
|
# More like this.
|
||||||
|
sim = []
|
||||||
|
for s in ((d.get("similar") or {}).get("results") or [])[:14]:
|
||||||
|
title = s.get("title") or s.get("name")
|
||||||
|
if title and s.get("id"):
|
||||||
|
sim.append({"title": title, "tmdb_id": s["id"], "kind": kind,
|
||||||
|
"poster": (self.POSTER_W + s["poster_path"]) if s.get("poster_path") else None})
|
||||||
|
if sim:
|
||||||
|
out["similar"] = sim
|
||||||
|
return out
|
||||||
|
|
||||||
def season_episodes(self, tv_id, season_number):
|
def season_episodes(self, tv_id, season_number):
|
||||||
"""Episode-level data for one season (still/overview/rating) — the show
|
"""Episode-level data for one season (still/overview/rating) — the show
|
||||||
worker cascades over a show's seasons to backfill episodes the media
|
worker cascades over a show's seasons to backfill episodes the media
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,22 @@ class VideoEnrichmentEngine:
|
||||||
external_id=result["id"], metadata=result.get("metadata"))
|
external_id=result["id"], metadata=result.get("metadata"))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
def item_extras(self, kind, item_id) -> dict:
|
||||||
|
"""Live TMDB extras (trailer / where-to-watch / similar) for the detail
|
||||||
|
page. Not cached — fetched per view so providers stay current."""
|
||||||
|
w = self.workers.get("tmdb")
|
||||||
|
if not w or not w.enabled:
|
||||||
|
return {}
|
||||||
|
info = (self.db.movie_match_info(item_id) if kind == "movie"
|
||||||
|
else self.db.show_match_info(item_id))
|
||||||
|
if not info or not info.get("tmdb_id"):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return w.client.extras(kind, info["tmdb_id"]) or {}
|
||||||
|
except Exception:
|
||||||
|
logger.exception("item_extras failed for %s %s", kind, item_id)
|
||||||
|
return {}
|
||||||
|
|
||||||
def worker(self, service):
|
def worker(self, service):
|
||||||
return self.workers.get(service)
|
return self.workers.get(service)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ def test_blueprint_exposes_dashboard_route():
|
||||||
assert "/api/video/detail/movie/<int:movie_id>" in rules
|
assert "/api/video/detail/movie/<int:movie_id>" in rules
|
||||||
assert "/api/video/detail/show/<int:show_id>/refresh-art" in rules
|
assert "/api/video/detail/show/<int:show_id>/refresh-art" in rules
|
||||||
assert "/api/video/detail/movie/<int:movie_id>/refresh-art" in rules
|
assert "/api/video/detail/movie/<int:movie_id>/refresh-art" in rules
|
||||||
|
assert "/api/video/detail/<kind>/<int:item_id>/extras" in rules
|
||||||
assert any(r.startswith("/api/video/backdrop/") for r in rules)
|
assert any(r.startswith("/api/video/backdrop/") for r in rules)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,39 @@ def test_get_stats_excludes_episode_coverage_from_pending(db):
|
||||||
assert stats["stats"]["pending"] == 0 # but it doesn't block "Complete"
|
assert stats["stats"]["pending"] == 0 # but it doesn't block "Complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_extras_parse(monkeypatch):
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, b): self._b = b
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self): return self._b
|
||||||
|
detail = {
|
||||||
|
"videos": {"results": [
|
||||||
|
{"site": "YouTube", "type": "Teaser", "key": "tease"},
|
||||||
|
{"site": "YouTube", "type": "Trailer", "key": "trail"}]},
|
||||||
|
"watch/providers": {"results": {"US": {"link": "http://w", "flatrate": [
|
||||||
|
{"provider_name": "Netflix", "logo_path": "/n.jpg"}]}}},
|
||||||
|
"similar": {"results": [{"id": 5, "title": "Other", "poster_path": "/o.jpg"}]}}
|
||||||
|
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
|
||||||
|
ex = TMDBClient("KEY").extras("movie", 438631)
|
||||||
|
assert ex["trailer"]["key"] == "trail" # Trailer beats Teaser
|
||||||
|
assert ex["providers"][0] == {"name": "Netflix", "logo": "https://image.tmdb.org/t/p/original/n.jpg"}
|
||||||
|
assert ex["providers_link"] == "http://w"
|
||||||
|
assert ex["similar"][0]["title"] == "Other" and ex["similar"][0]["kind"] == "movie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_extras_needs_tmdb_and_id(db):
|
||||||
|
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) # no tmdb_id
|
||||||
|
|
||||||
|
class C:
|
||||||
|
enabled = True
|
||||||
|
def extras(self, kind, tid, region="US"): return {"trailer": {"key": "x"}}
|
||||||
|
|
||||||
|
eng = VideoEnrichmentEngine(db, {"tmdb": C()})
|
||||||
|
assert eng.item_extras("show", sid) == {} # no tmdb_id → no call
|
||||||
|
sid2 = db.upsert_show_tree("plex", {"server_id": "s2", "title": "T", "tmdb_id": 1, "seasons": []})
|
||||||
|
assert eng.item_extras("show", sid2) == {"trailer": {"key": "x"}}
|
||||||
|
|
||||||
|
|
||||||
def test_tmdb_season_episodes_parses(monkeypatch):
|
def test_tmdb_season_episodes_parses(monkeypatch):
|
||||||
class _Resp:
|
class _Resp:
|
||||||
def __init__(self, b): self._b = b
|
def __init__(self, b): self._b = b
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,8 @@ def test_show_detail_subpage_present():
|
||||||
# Netflix billboard + episodes containers the renderer fills.
|
# Netflix billboard + episodes containers the renderer fills.
|
||||||
for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-meta',
|
for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-meta',
|
||||||
'data-vd-overview', 'data-vd-actions', 'data-vd-view-toggle',
|
'data-vd-overview', 'data-vd-actions', 'data-vd-view-toggle',
|
||||||
'data-vd-season-nav', 'data-vd-episodes', 'data-vd-cast', 'data-vd-crew'):
|
'data-vd-season-nav', 'data-vd-episodes', 'data-vd-cast', 'data-vd-crew',
|
||||||
|
'data-vd-logo', 'data-vd-providers', 'data-vd-similar'):
|
||||||
assert hook in block, hook
|
assert hook in block, hook
|
||||||
# Back button reuses the shared data-video-goto nav (no inline handler).
|
# Back button reuses the shared data-video-goto nav (no inline handler).
|
||||||
assert 'data-video-goto="video-library"' in block
|
assert 'data-video-goto="video-library"' in block
|
||||||
|
|
|
||||||
|
|
@ -861,6 +861,14 @@
|
||||||
<div class="vd-crew" data-vd-crew></div>
|
<div class="vd-crew" data-vd-crew></div>
|
||||||
<div class="vd-cast" data-vd-cast></div>
|
<div class="vd-cast" data-vd-cast></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="vd-providers-section" data-vd-providers-section hidden>
|
||||||
|
<h2 class="vd-section-h">Where to Watch</h2>
|
||||||
|
<div class="vd-providers" data-vd-providers></div>
|
||||||
|
</div>
|
||||||
|
<div class="vd-similar-section" data-vd-similar-section hidden>
|
||||||
|
<h2 class="vd-section-h">More Like This</h2>
|
||||||
|
<div class="vd-similar" data-vd-similar></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -894,6 +902,14 @@
|
||||||
<div class="vd-crew" data-vd-crew></div>
|
<div class="vd-crew" data-vd-crew></div>
|
||||||
<div class="vd-cast" data-vd-cast></div>
|
<div class="vd-cast" data-vd-cast></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="vd-providers-section" data-vd-providers-section hidden>
|
||||||
|
<h2 class="vd-section-h">Where to Watch</h2>
|
||||||
|
<div class="vd-providers" data-vd-providers></div>
|
||||||
|
</div>
|
||||||
|
<div class="vd-similar-section" data-vd-similar-section hidden>
|
||||||
|
<h2 class="vd-section-h">More Like This</h2>
|
||||||
|
<div class="vd-similar" data-vd-similar></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,12 @@
|
||||||
var a = q('[data-vd-actions]');
|
var a = q('[data-vd-actions]');
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
var watching = !!d.monitored;
|
var watching = !!d.monitored;
|
||||||
var html =
|
var html = '';
|
||||||
|
if (d.trailer && d.trailer.key) {
|
||||||
|
html += '<button class="vd-trailer-btn" type="button" data-vd-act="trailer">' +
|
||||||
|
'<span class="vd-trailer-ic">▶</span> Trailer</button>';
|
||||||
|
}
|
||||||
|
html +=
|
||||||
'<button class="library-artist-watchlist-btn' + (watching ? ' watching' : '') +
|
'<button class="library-artist-watchlist-btn' + (watching ? ' watching' : '') +
|
||||||
'" type="button" data-vd-act="watchlist">' +
|
'" type="button" data-vd-act="watchlist">' +
|
||||||
'<span class="watchlist-icon">' + (watching ? '✓' : '+') + '</span>' +
|
'<span class="watchlist-icon">' + (watching ? '✓' : '+') + '</span>' +
|
||||||
|
|
@ -239,6 +244,75 @@
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── live TMDB extras (trailer / where-to-watch / similar) ─────────────────
|
||||||
|
function resetExtras() {
|
||||||
|
['[data-vd-providers-section]', '[data-vd-similar-section]'].forEach(function (s) {
|
||||||
|
var n = q(s); if (n) n.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadExtras(kind, id) {
|
||||||
|
fetch(DETAIL_URL + kind + '/' + id + '/extras', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (ex) { if (ex) renderExtras(kind, id, ex); })
|
||||||
|
.catch(function () { /* best-effort */ });
|
||||||
|
}
|
||||||
|
function renderExtras(kind, id, ex) {
|
||||||
|
if (!data || data.id !== id || currentKind !== kind) return;
|
||||||
|
data.trailer = ex.trailer || null;
|
||||||
|
renderActions(data);
|
||||||
|
|
||||||
|
var ps = q('[data-vd-providers-section]'), ph = q('[data-vd-providers]');
|
||||||
|
if (ps && ph) {
|
||||||
|
if (ex.providers && ex.providers.length) {
|
||||||
|
ps.hidden = false;
|
||||||
|
ph.innerHTML = ex.providers.map(function (p) {
|
||||||
|
var img = p.logo ? '<img src="' + esc(p.logo) + '" alt="' + esc(p.name) + '" loading="lazy">'
|
||||||
|
: '<span class="vd-prov-ph">' + esc((p.name || '?').charAt(0)) + '</span>';
|
||||||
|
return '<div class="vd-prov" title="' + esc(p.name) + '">' + img +
|
||||||
|
'<span class="vd-prov-name">' + esc(p.name) + '</span></div>';
|
||||||
|
}).join('');
|
||||||
|
} else { ps.hidden = true; }
|
||||||
|
}
|
||||||
|
var ss = q('[data-vd-similar-section]'), sh = q('[data-vd-similar]');
|
||||||
|
if (ss && sh) {
|
||||||
|
if (ex.similar && ex.similar.length) {
|
||||||
|
ss.hidden = false;
|
||||||
|
sh.innerHTML = ex.similar.map(function (s) {
|
||||||
|
var poster = s.poster
|
||||||
|
? '<img class="vd-sim-poster" src="' + esc(s.poster) + '" alt="" loading="lazy">'
|
||||||
|
: '<span class="vd-sim-poster vd-sim-poster--ph">🎬</span>';
|
||||||
|
var url = 'https://www.themoviedb.org/' + (s.kind === 'movie' ? 'movie' : 'tv') + '/' + s.tmdb_id;
|
||||||
|
return '<a class="vd-sim-card" href="' + url + '" target="_blank" rel="noopener">' +
|
||||||
|
poster + '<span class="vd-sim-title">' + esc(s.title) + '</span></a>';
|
||||||
|
}).join('');
|
||||||
|
} else { ss.hidden = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── trailer modal (YouTube embed) ─────────────────────────────────────────
|
||||||
|
function openTrailer(key) {
|
||||||
|
if (!key) return;
|
||||||
|
var ov = document.getElementById('vd-trailer-overlay');
|
||||||
|
if (!ov) {
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.id = 'vd-trailer-overlay';
|
||||||
|
ov.className = 'vd-trailer-overlay';
|
||||||
|
ov.addEventListener('click', function (e) {
|
||||||
|
if (e.target === ov || e.target.closest('[data-vd-trailer-close]')) closeTrailer();
|
||||||
|
});
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
}
|
||||||
|
ov.innerHTML = '<div class="vd-trailer-box">' +
|
||||||
|
'<button class="vd-trailer-close" type="button" data-vd-trailer-close aria-label="Close">×</button>' +
|
||||||
|
'<iframe src="https://www.youtube.com/embed/' + encodeURIComponent(key) +
|
||||||
|
'?autoplay=1&rel=0" allow="autoplay; encrypted-media; fullscreen" allowfullscreen></iframe></div>';
|
||||||
|
ov.classList.add('vd-trailer-overlay--open');
|
||||||
|
}
|
||||||
|
function closeTrailer() {
|
||||||
|
var ov = document.getElementById('vd-trailer-overlay');
|
||||||
|
if (ov) { ov.classList.remove('vd-trailer-overlay--open'); ov.innerHTML = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── season selector (4 views) ─────────────────────────────────────────────
|
// ── season selector (4 views) ─────────────────────────────────────────────
|
||||||
function renderViewToggle() {
|
function renderViewToggle() {
|
||||||
var host = q('[data-vd-view-toggle]');
|
var host = q('[data-vd-view-toggle]');
|
||||||
|
|
@ -371,6 +445,7 @@
|
||||||
if (currentId !== id) artAttemptedFor = null;
|
if (currentId !== id) artAttemptedFor = null;
|
||||||
currentId = id;
|
currentId = id;
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
|
resetExtras();
|
||||||
var dh = q('[data-vd-details]'); if (dh) dh.innerHTML = '';
|
var dh = q('[data-vd-details]'); if (dh) dh.innerHTML = '';
|
||||||
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
||||||
fetch(DETAIL_URL + 'movie/' + id, { headers: { 'Accept': 'application/json' } })
|
fetch(DETAIL_URL + 'movie/' + id, { headers: { 'Accept': 'application/json' } })
|
||||||
|
|
@ -384,6 +459,7 @@
|
||||||
var sub = document.querySelector('.video-subpage[data-video-subpage="video-movie-detail"]');
|
var sub = document.querySelector('.video-subpage[data-video-subpage="video-movie-detail"]');
|
||||||
if (sub) sub.scrollTop = 0;
|
if (sub) sub.scrollTop = 0;
|
||||||
maybeRefreshMovie(id);
|
maybeRefreshMovie(id);
|
||||||
|
loadExtras('movie', id);
|
||||||
})
|
})
|
||||||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); });
|
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); });
|
||||||
}
|
}
|
||||||
|
|
@ -415,6 +491,7 @@
|
||||||
if (currentId !== id) artAttemptedFor = null;
|
if (currentId !== id) artAttemptedFor = null;
|
||||||
currentId = id;
|
currentId = id;
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
|
resetExtras();
|
||||||
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
|
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
|
||||||
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
||||||
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
|
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
|
||||||
|
|
@ -431,6 +508,7 @@
|
||||||
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
|
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
|
||||||
if (sub) sub.scrollTop = 0;
|
if (sub) sub.scrollTop = 0;
|
||||||
maybeRefreshArt(id);
|
maybeRefreshArt(id);
|
||||||
|
loadExtras('show', id);
|
||||||
})
|
})
|
||||||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
|
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
|
||||||
}
|
}
|
||||||
|
|
@ -483,6 +561,7 @@
|
||||||
var which = act.getAttribute('data-vd-act');
|
var which = act.getAttribute('data-vd-act');
|
||||||
if (which === 'watchlist') toggleWatchlist();
|
if (which === 'watchlist') toggleWatchlist();
|
||||||
else if (which === 'missing') toggleMissing();
|
else if (which === 'missing') toggleMissing();
|
||||||
|
else if (which === 'trailer' && data && data.trailer) openTrailer(data.trailer.key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var mt = e.target.closest('[data-vd-missing-toggle]');
|
var mt = e.target.closest('[data-vd-missing-toggle]');
|
||||||
|
|
@ -500,6 +579,9 @@
|
||||||
function init() {
|
function init() {
|
||||||
document.addEventListener('soulsync:video-open-detail', onOpen);
|
document.addEventListener('soulsync:video-open-detail', onOpen);
|
||||||
document.addEventListener('click', onClick);
|
document.addEventListener('click', onClick);
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closeTrailer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
|
||||||
|
|
@ -727,3 +727,55 @@ body[data-side="video"] .dashboard-header-sweep {
|
||||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||||
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Detail extras: trailer button, providers, More Like This, trailer modal ── */
|
||||||
|
.vd-trailer-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; padding: 7px 18px; border-radius: 8px;
|
||||||
|
font-size: 12.5px; font-weight: 800; letter-spacing: 0.02em; cursor: pointer;
|
||||||
|
color: #fff; background: rgb(var(--vd-accent-rgb)); border: 1px solid transparent;
|
||||||
|
box-shadow: 0 6px 18px rgba(var(--vd-accent-rgb), 0.4); transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.vd-trailer-btn:hover { transform: translateY(-1px); filter: brightness(1.08);
|
||||||
|
box-shadow: 0 8px 22px rgba(var(--vd-accent-rgb), 0.55); }
|
||||||
|
.vd-trailer-ic { font-size: 12px; }
|
||||||
|
|
||||||
|
.vd-providers-section, .vd-similar-section { margin-top: 40px; }
|
||||||
|
.vd-providers { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||||
|
.vd-prov { display: flex; flex-direction: column; align-items: center; gap: 7px; width: 78px; text-align: center; }
|
||||||
|
.vd-prov img, .vd-prov-ph {
|
||||||
|
width: 56px; height: 56px; border-radius: 14px; object-fit: cover;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.vd-prov-ph { display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800;
|
||||||
|
color: #fff; background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.4), rgba(var(--vd-accent-rgb), 0.1)); }
|
||||||
|
.vd-prov-name { font-size: 11.5px; color: rgba(255, 255, 255, 0.7); line-height: 1.25;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.vd-similar { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 12px; scroll-snap-type: x proximity; }
|
||||||
|
.vd-similar::-webkit-scrollbar { height: 8px; }
|
||||||
|
.vd-similar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
|
||||||
|
.vd-sim-card { flex: 0 0 130px; width: 130px; scroll-snap-align: start; text-decoration: none; color: inherit;
|
||||||
|
transition: transform 0.25s ease; }
|
||||||
|
.vd-sim-card:hover { transform: translateY(-4px); }
|
||||||
|
.vd-sim-poster { width: 130px; aspect-ratio: 2/3; border-radius: 10px; object-fit: cover; display: block; margin-bottom: 8px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||||
|
.vd-sim-poster--ph { display: flex; align-items: center; justify-content: center; font-size: 34px;
|
||||||
|
background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.28), rgba(0, 0, 0, 0.5)); }
|
||||||
|
.vd-sim-title { display: block; font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.85); line-height: 1.3;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
|
||||||
|
.vd-trailer-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 9000; display: none; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.vd-trailer-overlay--open { display: flex; animation: vdFade 0.2s ease both; }
|
||||||
|
@keyframes vdFade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.vd-trailer-box { position: relative; width: min(1100px, 92vw); aspect-ratio: 16/9;
|
||||||
|
border-radius: 12px; overflow: hidden; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); }
|
||||||
|
.vd-trailer-box iframe { width: 100%; height: 100%; border: 0; display: block; }
|
||||||
|
.vd-trailer-close {
|
||||||
|
position: absolute; top: -44px; right: 0; width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.2); color: #fff;
|
||||||
|
font-size: 22px; line-height: 1; cursor: pointer; transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.vd-trailer-close:hover { background: rgba(255, 255, 255, 0.25); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue