video: detail-page feedback fixes (Play button style, crew links, ep-sync UX)

- Play button now matches the Trailer/Watchlist buttons exactly (same size/shape),
  just green — consistent hero buttons.
- Where to Watch: drop the duplicate streaming provider that matches your server
  (no more two 'Plex' entries). Providers still share TMDB's single JustWatch
  'where to watch' link (that's all TMDB gives).
- Director/Creator names (hero line + Cast & Crew section) are clickable → the
  in-app person page.
- Opening a show whose full episode list isn't cached yet now shows a 'Fetching
  the full episode list…' banner with a spinner, instead of a silent ~20s gap
  before missing episodes pop in.
This commit is contained in:
BoulderBadgeDad 2026-06-15 10:21:04 -07:00
parent e94ea39187
commit 81546cff69
4 changed files with 78 additions and 21 deletions

View file

@ -433,6 +433,9 @@ def test_detail_keeps_preview_items_in_app():
assert "renderCrewLine" in src and "renderNextEpisode" in src
assert "data-vd-collection" in _INDEX and "renderSeasonOverview" in src
assert "ex.recommendations" in src
# Director/crew names link to the person page; episode-sync has a banner.
assert "personName" in src and "data-vd-person" in src
assert "data-vd-ep-syncing" in _INDEX and "showEpSyncing" 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

@ -916,6 +916,10 @@
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-ep-syncing" data-vd-ep-syncing hidden>
<span class="vd-ep-syncing-spin" aria-hidden="true"></span>
<span>Fetching the full episode list from TMDB&hellip; missing episodes will fill in shortly.</span>
</div>
<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>

View file

@ -204,16 +204,23 @@
}
// "Directed by …" (movie) / "Created by …" (show) surfaced in the hero.
// A crew member's name, clickable → person page when we have a TMDB id.
function personName(c) {
return c.tmdb_id
? '<a class="vd-person-link" href="/video-detail/tmdb/person/' + c.tmdb_id +
'" data-vd-person="' + c.tmdb_id + '">' + esc(c.name) + '</a>'
: esc(c.name);
}
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' : '');
var people = (d.crew || []).filter(function (c) { return c.job === key; }).slice(0, 3);
if (!people.length) { el.hidden = true; el.innerHTML = ''; return; }
var label = (d.kind === 'movie' ? 'Director' : 'Creator') + (people.length > 1 ? 's' : '');
el.innerHTML = '<span class="vd-crew-line-k">' + label + '</span> ' +
names.slice(0, 3).map(esc).join(', ');
people.map(personName).join(', ');
el.hidden = false;
}
@ -270,12 +277,14 @@
var crewHost = q('[data-vd-crew]');
if (crewHost) {
// Group crew by job (Creator / Director / Writer …) → "Job: A, B".
// Group crew by job (Creator / Director / Writer …) → "Job: A, B" with
// each name clickable → person page.
var byJob = {};
crew.forEach(function (c) { (byJob[c.job || 'Crew'] = byJob[c.job || 'Crew'] || []).push(c.name); });
crew.forEach(function (c) { (byJob[c.job || 'Crew'] = byJob[c.job || 'Crew'] || []).push(c); });
crewHost.innerHTML = Object.keys(byJob).map(function (job) {
return '<span class="vd-crew-item"><span class="vd-crew-job">' + esc(job) +
(byJob[job].length > 1 ? 's' : '') + '</span> ' + esc(byJob[job].join(', ')) + '</span>';
(byJob[job].length > 1 ? 's' : '') + '</span> ' +
byJob[job].map(personName).join(', ') + '</span>';
}).join('');
}
var castHost = q('[data-vd-cast]');
@ -402,10 +411,17 @@
'" target="_blank" rel="noopener" title="Play on ' + sv + '">' +
sicon + '<span class="vd-prov-name">Play on ' + sv + '</span></a>';
}
// Streaming providers (JustWatch via TMDB) link to the where-to-watch page.
// Streaming providers (JustWatch via TMDB) link to the where-to-watch
// page. (TMDB only gives one aggregate link, so they share it.) Drop a
// provider that's the same service as your server tile (e.g. Plex), so
// it isn't listed twice.
var link = ex.providers_link || '';
if (ex.providers && ex.providers.length) {
html += ex.providers.map(function (p) {
var srvName = (ex.server && ex.server.server || '').toLowerCase();
var provs = (ex.providers || []).filter(function (p) {
return (p.name || '').toLowerCase() !== srvName;
});
if (provs.length) {
html += provs.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>';
var inner = img + '<span class="vd-prov-name">' + esc(p.name) + '</span>';
@ -692,6 +708,7 @@
currentId = id;
showLoading(true);
resetExtras();
showEpSyncing(false);
['[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');
fetch(detailURL('show', id, currentSource), { headers: { 'Accept': 'application/json' } })
@ -730,17 +747,29 @@
|| (data.imdb_id && !data.imdb_rating);
if (!needs) return;
artAttemptedFor = id;
// The full episode list (owned + missing) is being pulled from TMDB — this
// can take a while, so show the user it's happening instead of a silent gap.
if (!data.episodes_synced) showEpSyncing(true);
fetch(DETAIL_URL + 'show/' + id + '/refresh-art',
{ method: 'POST', headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) { if (res && res.ok && currentId === id) reloadDetail(id); })
.catch(function () { /* best-effort */ });
.then(function (res) {
if (res && res.ok && currentId === id) reloadDetail(id);
else showEpSyncing(false);
})
.catch(function () { showEpSyncing(false); });
}
function showEpSyncing(on) {
var el = q('[data-vd-ep-syncing]');
if (el) el.hidden = !on;
}
function reloadDetail(id) {
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showEpSyncing(false);
if (!d || d.error || currentId !== id) return;
data = d;
if (!seasonByNum(selectedSeason)) {
@ -748,7 +777,7 @@
}
renderBillboard(d); renderSeasonNav(); renderEpisodes();
})
.catch(function () { /* ignore */ });
.catch(function () { showEpSyncing(false); });
}
// ── events ────────────────────────────────────────────────────────────────

View file

@ -1174,15 +1174,18 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
}
/* ── billboard: Play CTA, crew line, next-episode, season overview ────────── */
/* Matches .vd-trailer-btn exactly (consistent with the other hero buttons)
just green to read as "owned / play". */
.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;
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; text-decoration: none;
color: #fff; background: #22c55e; border: 1px solid transparent;
box-shadow: 0 6px 18px rgba(34, 197, 94, 0.4); transition: all 0.2s 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-play-btn:hover { transform: translateY(-1px); filter: brightness(1.08);
box-shadow: 0 8px 22px rgba(34, 197, 94, 0.55); }
.vd-play-logo { height: 14px; width: auto; }
.vd-play-ic { font-size: 13px; }
.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); }
@ -1228,3 +1231,21 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
.vd-skel-content { left: 22px; right: 22px; width: auto; bottom: 60px; }
}
@media (prefers-reduced-motion: reduce) { .skel-shine { animation: none; } }
/* clickable crew / director names → person page */
.vd-person-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; transition: color 0.18s ease, border-color 0.18s ease; }
.vd-person-link:hover { color: rgb(var(--vd-accent-rgb)); border-bottom-color: rgba(var(--vd-accent-rgb), 0.5); }
/* "fetching full episode list" banner (shown while the TMDB cascade runs) */
.vd-ep-syncing {
display: flex; align-items: center; gap: 11px; margin: 0 0 18px; padding: 12px 16px;
border-radius: 10px; font-size: 13.5px; color: rgba(255, 255, 255, 0.78);
background: rgba(var(--vd-accent-rgb), 0.1); border: 1px solid rgba(var(--vd-accent-rgb), 0.26);
}
.vd-ep-syncing-spin {
flex: 0 0 auto; width: 16px; height: 16px; border-radius: 50%;
border: 2px solid rgba(var(--vd-accent-rgb), 0.3); border-top-color: rgb(var(--vd-accent-rgb));
animation: vd-ep-spin 0.8s linear infinite;
}
@keyframes vd-ep-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .vd-ep-syncing-spin { animation: none; } }