Wishlist TV: show synopsis + clickable cast bubbles in the expanded panel

Your idea — use the empty space when a show is expanded. Expanding an orb now
lazily loads the show detail and lays out an info bar: the show SYNOPSIS on the
left, a row of CAST bubbles on the right (circular photos / initials fallback,
clickable -> opens the person page). This surfaces reliable show-level context
even for reality shows whose episodes have no TMDB overview (e.g. Love Island).

Reuses the existing detail endpoints (owned -> /detail/show, else /tmdb/show);
one fetch per show, cached on the group. All scoped under .vwsh-nebula — music
wishlist untouched.
This commit is contained in:
BoulderBadgeDad 2026-06-16 18:36:05 -07:00
parent e8a0e1256b
commit 853dbcf40f
2 changed files with 67 additions and 7 deletions

View file

@ -2553,6 +2553,31 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vwsh-nebula .wl-album-tile.vwsh-tile--sel { border: 1px solid hsla(var(--orb-hue, 230), 70%, 60%, 0.6);
box-shadow: 0 0 0 1px hsla(var(--orb-hue, 230), 70%, 55%, 0.4), 0 8px 22px -10px hsla(var(--orb-hue, 230), 70%, 45%, 0.6); }
/* show info bar — synopsis (left) + clickable cast bubbles (right), lazy-loaded */
.vwsh-nebula .vwsh-info:empty { display: none; }
.vwsh-nebula .vwsh-info { display: flex; gap: 20px; align-items: flex-start; text-align: left; margin: 4px 0 18px; }
.vwsh-nebula .vwsh-info-syn { flex: 1 1 0; min-width: 0; font-size: 12.5px; line-height: 1.55; color: rgba(255, 255, 255, 0.6);
display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden; }
.vwsh-nebula .vwsh-info-cast { flex: 0 0 auto; max-width: 48%; display: flex; flex-wrap: wrap; gap: 12px 10px; justify-content: flex-end; }
.vwsh-nebula .vwsh-cast { width: 56px; padding: 0; border: none; background: none; cursor: pointer;
display: flex; flex-direction: column; align-items: center; gap: 5px; }
.vwsh-nebula .vwsh-cast-img { position: relative; width: 46px; height: 46px; border-radius: 50%; overflow: hidden;
background: #1c1c24; border: 1px solid rgba(255, 255, 255, 0.12);
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; }
.vwsh-nebula .vwsh-cast:hover .vwsh-cast-img { transform: translateY(-2px);
border-color: hsla(var(--orb-hue, 230), 70%, 62%, 0.75); box-shadow: 0 6px 16px -4px hsla(var(--orb-hue, 230), 70%, 50%, 0.6); }
.vwsh-nebula .vwsh-cast-img img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vwsh-nebula .vwsh-cast--ph .vwsh-cast-img img { display: none; }
.vwsh-nebula .vwsh-cast-ini { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 800; color: rgba(255, 255, 255, 0.45); }
.vwsh-nebula .vwsh-cast-name { font-size: 9.5px; font-weight: 600; color: rgba(255, 255, 255, 0.55);
text-align: center; line-height: 1.2; max-width: 56px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vwsh-nebula .vwsh-cast:hover .vwsh-cast-name { color: rgba(255, 255, 255, 0.85); }
@media (max-width: 640px) {
.vwsh-nebula .vwsh-info { flex-direction: column; }
.vwsh-nebula .vwsh-info-cast { max-width: 100%; justify-content: flex-start; }
}
/* #1/#2 — the picked season drops its episodes here, full-width, as 2-line cards */
.vwsh-nebula .vwsh-ep-area:empty { display: none; }
.vwsh-nebula .vwsh-ep-area { margin-top: 14px; }

View file

@ -100,7 +100,7 @@
var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue +
(sh.poster_url ? ";--vwsh-poster:url('" + esc(sh.poster_url) + "')" : '');
var prog = total ? Math.max(0, Math.min(1, (sh.done || 0) / total)) : 0; // #4 acquisition progress
return '<div class="wl-orb-group" data-vwsh-group style="' + gstyle + '">' +
return '<div class="wl-orb-group" data-vwsh-group data-vwsh-tmdb="' + esc(sh.tmdb_id) + '" style="' + gstyle + '">' +
'<button class="wl-orb-remove" type="button" data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '" title="Remove show">&#10005;</button>' +
'<div class="wl-orb-tooltip">' + esc(sh.title) + '<br><span>' + eps + '</span></div>' +
'<div class="wl-orb ' + orbSize(total) + '" data-vwsh-orb style="--vwsh-prog:' + prog + '">' +
@ -109,11 +109,42 @@
'</div>' +
'<div class="wl-orb-label" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '" title="' + esc(sh.title) + '">' + esc(sh.title) + '</div>' +
'<div class="wl-orb-meta">' + eps + (sh.done ? ' · ' + sh.done + ' done' : '') + '</div>' +
'<div class="wl-orb-expanded"><div class="wl-album-fan">' + tiles + '</div>' +
'<div class="wl-orb-expanded">' +
'<div class="vwsh-info" data-vwsh-info></div>' +
'<div class="wl-album-fan">' + tiles + '</div>' +
'<div class="vwsh-ep-area" data-vwsh-ep-area></div></div>' +
'</div>';
}
// Lazily load the show's synopsis + cast when its orb first expands, and lay
// them either side of the season fan (synopsis left, clickable cast right).
function loadShowInfo(group) {
if (!group || group.getAttribute('data-vwsh-info-loaded')) return;
group.setAttribute('data-vwsh-info-loaded', '1');
var tmdb = parseInt(group.getAttribute('data-vwsh-tmdb'), 10);
var sh = state.showData[tmdb]; if (!sh) return;
var url = sh.library_id != null ? '/api/video/detail/show/' + sh.library_id : '/api/video/tmdb/show/' + tmdb;
fetch(url, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var info = group.querySelector('[data-vwsh-info]');
if (!d || !info) return;
var cast = (d.cast || []).slice(0, 8).map(function (c) {
var photo = c.photo
? '<img src="' + esc(c.photo) + '" alt="" loading="lazy" onerror="this.parentNode.classList.add(\'vwsh-cast--ph\')">'
: '';
return '<button class="vwsh-cast' + (c.photo ? '' : ' vwsh-cast--ph') + '" type="button" ' +
'data-vwsh-open-person data-id="' + esc(c.tmdb_id) + '" ' +
'title="' + esc(c.name) + (c.character ? ' — ' + esc(c.character) : '') + '">' +
'<span class="vwsh-cast-img"><span class="vwsh-cast-ini">' + esc(initials(c.name)) + '</span>' + photo + '</span>' +
'<span class="vwsh-cast-name">' + esc(c.name) + '</span></button>';
}).join('');
info.innerHTML = (d.overview ? '<div class="vwsh-info-syn">' + esc(d.overview) + '</div>' : '') +
(cast ? '<div class="vwsh-info-cast">' + cast + '</div>' : '');
})
.catch(function () { /* best-effort */ });
}
// A single episode as a rich card: still + title + meta + synopsis. The card
// links to the show detail (episodes have no page of their own).
function epCard(sh, se, e) {
@ -278,12 +309,13 @@
var rm = e.target.closest('[data-vwsh-rm]');
if (rm) { e.preventDefault(); e.stopPropagation(); doRemove(rm); return; }
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var open = e.target.closest('[data-vwsh-open-show], [data-vwsh-open-movie]');
var open = e.target.closest('[data-vwsh-open-show], [data-vwsh-open-movie], [data-vwsh-open-person]');
if (open) {
var person = open.hasAttribute('data-vwsh-open-person');
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: open.hasAttribute('data-vwsh-open-show') ? 'show' : 'movie',
id: parseInt(open.getAttribute('data-vwsh-id'), 10),
source: open.getAttribute('data-vwsh-src') || 'tmdb' },
detail: { kind: person ? 'person' : (open.hasAttribute('data-vwsh-open-show') ? 'show' : 'movie'),
id: parseInt(person ? open.getAttribute('data-id') : open.getAttribute('data-vwsh-id'), 10),
source: person ? 'tmdb' : (open.getAttribute('data-vwsh-src') || 'tmdb') },
}));
return;
}
@ -298,7 +330,10 @@
return;
}
var orb = e.target.closest('[data-vwsh-orb]');
if (orb) { var g = orb.closest('.wl-orb-group'); if (g) g.classList.toggle('expanded'); } // show → seasons
if (orb) { // show → reveal seasons + lazily load the synopsis/cast info bar
var g = orb.closest('.wl-orb-group');
if (g && g.classList.toggle('expanded')) loadShowInfo(g);
}
}
function wire() {