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:
BoulderBadgeDad 2026-06-15 09:50:07 -07:00
parent f725235f44
commit 6eed3d7775
4 changed files with 146 additions and 18 deletions

View file

@ -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

View file

@ -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>

View file

@ -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(); }
})

View file

@ -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;
}