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:
parent
e94ea39187
commit
81546cff69
4 changed files with 78 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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… 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>
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue