video: best-in-class detail billboards (Play CTA, collections, recs, next-ep)
Movie + TV detail pages get the things a premium app surfaces: - Primary 'Play on Plex/Jellyfin' button (white Netflix-style CTA with the server logo) in the billboard for owned items — deep-links straight to the item. - 'Directed by' (movies) / 'Created by' (shows) line in the hero. - Movies: a Collection/franchise row (the other films in the set), release-ordered. - 'More Like This' now uses TMDB recommendations (better curated), similar as fallback. - TV: a 'Next Episode' banner (S/E + name + air date) for continuing shows, and the selected season's overview under the season nav. All in-app (cards drill into library/preview detail). Shell tests updated.
This commit is contained in:
parent
f725235f44
commit
6eed3d7775
4 changed files with 146 additions and 18 deletions
|
|
@ -424,6 +424,12 @@ def test_detail_keeps_preview_items_in_app():
|
|||
# streaming providers.
|
||||
assert "vd-prov--server" in src and "Play on " in src
|
||||
assert "ex.providers_link" in src
|
||||
# Best-in-class billboard: primary Play CTA, director/creator line, collection
|
||||
# row, recommendations (with similar fallback), next-episode + season overview.
|
||||
assert "vd-play-btn" in src
|
||||
assert "renderCrewLine" in src and "renderNextEpisode" in src
|
||||
assert "data-vd-collection" in _INDEX and "renderSeasonOverview" in src
|
||||
assert "ex.recommendations" in src
|
||||
# The old external 'similar' link (themoviedb.org/<kind>/<id>) is gone — the
|
||||
# only remaining themoviedb.org ref is the TMDB badge logo asset for owned items.
|
||||
assert "www.themoviedb.org/' + (s.kind" not in src
|
||||
|
|
|
|||
|
|
@ -882,6 +882,8 @@
|
|||
<h1 class="vd-title" data-vd-title>—</h1>
|
||||
<div class="vd-tagline" data-vd-tagline hidden></div>
|
||||
<div class="vd-meta" data-vd-meta></div>
|
||||
<div class="vd-crew-line" data-vd-crew-line hidden></div>
|
||||
<div class="vd-next-ep" data-vd-next-ep hidden></div>
|
||||
<!-- External-source links reuse the artist-hero-badge style. -->
|
||||
<div class="artist-hero-badges vd-links" data-vd-links></div>
|
||||
<div class="vd-ratings" data-vd-ratings hidden></div>
|
||||
|
|
@ -905,6 +907,7 @@
|
|||
<!-- Season selector — view mode (rail/timeline/pills/dropdown)
|
||||
chosen by the toggle above; all drive the same selection. -->
|
||||
<div class="vd-season-nav" data-vd-season-nav></div>
|
||||
<p class="vd-season-overview" data-vd-season-overview hidden></p>
|
||||
<div class="vd-episodes" data-vd-episodes></div>
|
||||
<!-- Cast & crew (TMDB) — populated by video-detail.js. -->
|
||||
<div class="vd-cast-section" data-vd-cast-section hidden>
|
||||
|
|
@ -916,6 +919,10 @@
|
|||
<h2 class="vd-section-h">Where to Watch</h2>
|
||||
<div class="vd-providers" data-vd-providers></div>
|
||||
</div>
|
||||
<div class="vd-collection-section" data-vd-collection-section hidden>
|
||||
<h2 class="vd-section-h" data-vd-collection-title>Collection</h2>
|
||||
<div class="vd-similar" data-vd-collection></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>
|
||||
|
|
@ -938,6 +945,7 @@
|
|||
<h1 class="vd-title" data-vd-title>—</h1>
|
||||
<div class="vd-tagline" data-vd-tagline hidden></div>
|
||||
<div class="vd-meta" data-vd-meta></div>
|
||||
<div class="vd-crew-line" data-vd-crew-line hidden></div>
|
||||
<div class="artist-hero-badges vd-links" data-vd-links></div>
|
||||
<div class="vd-ratings" data-vd-ratings hidden></div>
|
||||
<p class="vd-overview" data-vd-overview></p>
|
||||
|
|
@ -958,6 +966,10 @@
|
|||
<h2 class="vd-section-h">Where to Watch</h2>
|
||||
<div class="vd-providers" data-vd-providers></div>
|
||||
</div>
|
||||
<div class="vd-collection-section" data-vd-collection-section hidden>
|
||||
<h2 class="vd-section-h" data-vd-collection-title>Collection</h2>
|
||||
<div class="vd-similar" data-vd-collection></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>
|
||||
|
|
|
|||
|
|
@ -198,9 +198,47 @@
|
|||
}).join('');
|
||||
}
|
||||
renderRatings(d);
|
||||
renderCrewLine(d);
|
||||
renderNextEpisode(d);
|
||||
renderCast(d);
|
||||
}
|
||||
|
||||
// "Directed by …" (movie) / "Created by …" (show) surfaced in the hero.
|
||||
function renderCrewLine(d) {
|
||||
var el = q('[data-vd-crew-line]');
|
||||
if (!el) return;
|
||||
var key = d.kind === 'movie' ? 'Director' : 'Creator';
|
||||
var names = (d.crew || []).filter(function (c) { return c.job === key; })
|
||||
.map(function (c) { return c.name; });
|
||||
if (!names.length) { el.hidden = true; el.innerHTML = ''; return; }
|
||||
var label = (d.kind === 'movie' ? 'Director' : 'Creator') + (names.length > 1 ? 's' : '');
|
||||
el.innerHTML = '<span class="vd-crew-line-k">' + label + '</span> ' +
|
||||
names.slice(0, 3).map(esc).join(', ');
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
var p = String(s).split('-');
|
||||
if (p.length < 3) return s;
|
||||
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return (months[parseInt(p[1], 10) - 1] || '') + ' ' + parseInt(p[2], 10) + ', ' + p[0];
|
||||
}
|
||||
|
||||
// "Next episode" banner for continuing shows (data.next_episode arrives w/ extras).
|
||||
function renderNextEpisode(d) {
|
||||
var el = q('[data-vd-next-ep]');
|
||||
if (!el) return;
|
||||
var ne = d.next_episode;
|
||||
if (d.kind !== 'show' || !ne || !ne.air_date) { el.hidden = true; el.innerHTML = ''; return; }
|
||||
var code = 'S' + ne.season_number + ' · E' + ne.episode_number;
|
||||
el.innerHTML = '<span class="vd-next-ep-badge">▸ Next Episode</span>' +
|
||||
'<span class="vd-next-ep-code">' + esc(code) + '</span>' +
|
||||
(ne.name ? '<span class="vd-next-ep-name">' + esc(ne.name) + '</span>' : '') +
|
||||
'<span class="vd-next-ep-when">' + esc(fmtDate(ne.air_date)) + '</span>';
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function renderRatings(d) {
|
||||
var host = q('[data-vd-ratings]');
|
||||
if (!host) return;
|
||||
|
|
@ -263,6 +301,14 @@
|
|||
if (!a) return;
|
||||
var watching = !!d.monitored;
|
||||
var html = '';
|
||||
// Primary CTA: play it on your media server (owned items; arrives with extras).
|
||||
if (d.server && d.server.url) {
|
||||
var sv = esc(d.server.server || 'Server');
|
||||
var slogo = SERVER_LOGOS[d.server.server];
|
||||
html += '<a class="vd-play-btn" href="' + esc(d.server.url) + '" target="_blank" rel="noopener">' +
|
||||
(slogo ? '<img class="vd-play-logo" src="' + esc(slogo) + '" alt="">' : '<span class="vd-play-ic">▶</span>') +
|
||||
'<span>Play on ' + sv + '</span></a>';
|
||||
}
|
||||
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>';
|
||||
|
|
@ -303,7 +349,8 @@
|
|||
|
||||
// ── live TMDB extras (trailer / where-to-watch / similar) ─────────────────
|
||||
function resetExtras() {
|
||||
['[data-vd-providers-section]', '[data-vd-similar-section]'].forEach(function (s) {
|
||||
['[data-vd-providers-section]', '[data-vd-similar-section]', '[data-vd-collection-section]',
|
||||
'[data-vd-next-ep]', '[data-vd-crew-line]', '[data-vd-season-overview]'].forEach(function (s) {
|
||||
var n = q(s); if (n) n.hidden = true;
|
||||
});
|
||||
}
|
||||
|
|
@ -313,10 +360,31 @@
|
|||
.then(function (ex) { if (ex) renderExtras(kind, id, ex); })
|
||||
.catch(function () { /* best-effort */ });
|
||||
}
|
||||
function simCard(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 simKind = s.kind === 'movie' ? 'movie' : 'show';
|
||||
var yr = s.year ? '<span class="vd-sim-year">' + esc(s.year) + '</span>' : '';
|
||||
return '<a class="vd-sim-card" href="/video-detail/tmdb/' + simKind + '/' + s.tmdb_id +
|
||||
'" data-vd-sim="' + simKind + '" data-vd-sim-id="' + s.tmdb_id + '">' +
|
||||
poster + '<span class="vd-sim-title">' + esc(s.title) + '</span>' + yr + '</a>';
|
||||
}
|
||||
function renderRow(sectionSel, hostSel, items) {
|
||||
var sec = q(sectionSel), host = q(hostSel);
|
||||
if (!sec || !host) return;
|
||||
if (!items || !items.length) { sec.hidden = true; return; }
|
||||
sec.hidden = false;
|
||||
host.innerHTML = items.map(simCard).join('');
|
||||
}
|
||||
|
||||
function renderExtras(kind, id, ex) {
|
||||
if (!data || data.id !== id || currentKind !== kind) return;
|
||||
data.trailer = ex.trailer || null;
|
||||
data.server = ex.server || null;
|
||||
data.next_episode = ex.next_episode || null;
|
||||
renderActions(data);
|
||||
renderNextEpisode(data);
|
||||
|
||||
var ps = q('[data-vd-providers-section]'), ph = q('[data-vd-providers]');
|
||||
if (ps && ph) {
|
||||
|
|
@ -349,23 +417,20 @@
|
|||
ps.hidden = !html;
|
||||
ph.innerHTML = html;
|
||||
}
|
||||
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>';
|
||||
// In-app: open the TMDB-backed detail (which redirects to the
|
||||
// library detail if we already own it). No external links.
|
||||
var simKind = s.kind === 'movie' ? 'movie' : 'show';
|
||||
return '<a class="vd-sim-card" href="/video-detail/tmdb/' + simKind + '/' + s.tmdb_id +
|
||||
'" data-vd-sim="' + simKind + '" data-vd-sim-id="' + s.tmdb_id + '">' +
|
||||
poster + '<span class="vd-sim-title">' + esc(s.title) + '</span></a>';
|
||||
}).join('');
|
||||
} else { ss.hidden = true; }
|
||||
// Franchise / collection (movies) — the other films in the set.
|
||||
var cs = q('[data-vd-collection-section]'), ch = q('[data-vd-collection]'), ct = q('[data-vd-collection-title]');
|
||||
var coll = ex.collection;
|
||||
if (cs && ch) {
|
||||
if (coll && coll.items && coll.items.length) {
|
||||
cs.hidden = false;
|
||||
if (ct) ct.textContent = coll.name || 'Collection';
|
||||
ch.innerHTML = coll.items.map(simCard).join('');
|
||||
} else { cs.hidden = true; }
|
||||
}
|
||||
|
||||
// "More Like This" — recommendations (better-curated), falling back to similar.
|
||||
var more = (ex.recommendations && ex.recommendations.length) ? ex.recommendations : ex.similar;
|
||||
renderRow('[data-vd-similar-section]', '[data-vd-similar]', more);
|
||||
}
|
||||
|
||||
// ── trailer modal (YouTube embed) ─────────────────────────────────────────
|
||||
|
|
@ -483,7 +548,17 @@
|
|||
'<div class="vd-ep-badge">' + (ep.owned ? 'Owned' : 'Missing') + '</div></div>';
|
||||
}
|
||||
|
||||
function renderSeasonOverview() {
|
||||
var el = q('[data-vd-season-overview]');
|
||||
if (!el) return;
|
||||
var s = seasonByNum(selectedSeason);
|
||||
var ov = s && s.overview;
|
||||
el.textContent = ov || '';
|
||||
el.hidden = !ov;
|
||||
}
|
||||
|
||||
function renderEpisodes() {
|
||||
renderSeasonOverview();
|
||||
var host = q('[data-vd-episodes]');
|
||||
if (!host) return;
|
||||
var season = seasonByNum(selectedSeason);
|
||||
|
|
@ -521,6 +596,7 @@
|
|||
if (se && se.episodes) {
|
||||
season.episodes = se.episodes;
|
||||
season.episode_total = se.episodes.length;
|
||||
if (se.overview) season.overview = se.overview;
|
||||
}
|
||||
if (currentId === sid && selectedSeason === sn) { renderSeasonNav(); renderEpisodes(); }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -743,7 +743,8 @@ body[data-side="video"] .dashboard-header-sweep {
|
|||
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-section, .vd-similar-section, .vd-collection-section { margin-top: 40px; }
|
||||
.vd-sim-year { display: block; font-size: 11px; color: rgba(255, 255, 255, 0.45); margin-top: 2px; }
|
||||
.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 {
|
||||
|
|
@ -1160,3 +1161,36 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
|
|||
.vsr-grid { grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 16px 14px; }
|
||||
.vp-photo-wrap { width: 150px; height: 150px; }
|
||||
}
|
||||
|
||||
/* ── billboard: Play CTA, crew line, next-episode, season overview ────────── */
|
||||
.vd-play-btn {
|
||||
display: inline-flex; align-items: center; gap: 9px; text-decoration: none;
|
||||
padding: 11px 22px; border-radius: 10px; font-size: 15px; font-weight: 800;
|
||||
background: #fff; color: #0b0b0f; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.vd-play-btn:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55); }
|
||||
.vd-play-logo { height: 17px; width: auto; }
|
||||
.vd-play-ic { font-size: 14px; }
|
||||
|
||||
.vd-crew-line { font-size: 14px; color: rgba(255, 255, 255, 0.84); margin: 2px 0 14px;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }
|
||||
.vd-crew-line-k { color: rgba(255, 255, 255, 0.5); font-weight: 600; margin-right: 4px; }
|
||||
|
||||
.vd-next-ep {
|
||||
display: inline-flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 2px 0 14px;
|
||||
font-size: 13.5px; color: rgba(255, 255, 255, 0.9); text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.vd-next-ep-badge {
|
||||
font-size: 11.5px; font-weight: 800; letter-spacing: 0.5px; text-transform: uppercase;
|
||||
color: rgb(var(--vd-accent-rgb)); padding: 4px 11px; border-radius: 999px;
|
||||
background: rgba(var(--vd-accent-rgb), 0.16); border: 1px solid rgba(var(--vd-accent-rgb), 0.36);
|
||||
}
|
||||
.vd-next-ep-code { font-weight: 800; }
|
||||
.vd-next-ep-name { color: rgba(255, 255, 255, 0.72); font-style: italic; }
|
||||
.vd-next-ep-when { color: rgba(255, 255, 255, 0.6); }
|
||||
|
||||
.vd-season-overview {
|
||||
font-size: 14px; line-height: 1.6; color: rgba(255, 255, 255, 0.7);
|
||||
max-width: 880px; margin: -8px 0 22px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue