From 11cc9aa789a4b8b7032dd429bfbd02732798c762 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 18:57:37 -0700 Subject: [PATCH] Wishlist TV: episode-select drives a contextual info bar + explicit View-show button Reworked per feedback: - Clicking an episode SELECTS it (highlights), no longer navigates. - A dedicated 'View show ->' button in the info bar is what opens the show. - Info bar is contextual: nothing selected -> show synopsis + show cast; episode selected -> that episode's synopsis (from the wishlist row) + its guest cast (lazy /episode///). Click the episode again to go back to show-level. - Changing/closing a season resets the bar to show-level. Cast bubbles + View-show navigate; everything scoped under .vwsh-nebula (music wishlist untouched). --- webui/static/video/video-side.css | 11 +++ webui/static/video/video-wishlist.js | 106 ++++++++++++++++++++------- 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index f5a43fc1..131a60d1 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2577,6 +2577,17 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vwsh-nebula .vwsh-info { flex-direction: column; } .vwsh-nebula .vwsh-info-cast { max-width: 100%; justify-content: flex-start; } } +.vwsh-nebula .vwsh-info-eyebrow { display: block; font-size: 10px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.05em; color: hsl(var(--orb-hue, 230) 80% 76%); margin-bottom: 6px; } +.vwsh-nebula .vwsh-info-viewshow { display: inline-block; margin-top: 12px; padding: 7px 14px; border-radius: 9px; + font-size: 11.5px; font-weight: 800; cursor: pointer; color: #fff; + border: 1px solid hsla(var(--orb-hue, 230), 70%, 55%, 0.5); background: hsla(var(--orb-hue, 230), 60%, 32%, 0.4); + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; } +.vwsh-nebula .vwsh-info-viewshow:hover { background: hsla(var(--orb-hue, 230), 65%, 42%, 0.6); + border-color: hsla(var(--orb-hue, 230), 70%, 64%, 0.8); transform: translateY(-1px); } +/* selected episode card */ +.vwsh-nebula .vwsh-epc.vwsh-epc--sel { background: hsla(var(--orb-hue, 230), 48%, 22%, 0.5); + border-color: hsla(var(--orb-hue, 230), 70%, 58%, 0.65); } /* #1/#2 — the picked season drops its episodes here, full-width, as 2-line cards */ .vwsh-nebula .vwsh-ep-area:empty { display: none; } diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index a76961a7..1f40c31b 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -13,7 +13,7 @@ var PAGE_ID = 'video-wishlist'; var LIMIT = 60; var state = { loaded: false, tab: 'movie', search: '', sort: 'added', page: 1, - counts: { movie: 0, show: 0, episode: 0 } }; + counts: { movie: 0, show: 0, episode: 0 }, showData: {}, showInfo: {} }; var searchTimer = null; function $(s, r) { return (r || document).querySelector(s); } @@ -116,40 +116,82 @@ ''; } - // 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). + // ── info bar: synopsis + clickable cast, contextual to the selected episode ─ + function castBubbles(arr) { + return (arr || []).slice(0, 8).map(function (c) { + var photo = c.photo + ? '' + : ''; + return ''; + }).join(''); + } + function findEpisode(tmdb, sNum, eNum) { + var sh = state.showData[tmdb], ep = null; + if (sh) (sh.seasons || []).forEach(function (se) { + if (se.season_number === sNum) (se.episodes || []).forEach(function (x) { if (x.episode_number === eNum) ep = x; }); + }); + return ep; + } + // sel = a selected episode object (episode synopsis + guest cast), or null (show synopsis + show cast). + function renderInfoBar(group, tmdb, sel) { + var info = group && group.querySelector('[data-vwsh-info]'); if (!info) return; + var sh = state.showData[tmdb] || {}, si = state.showInfo[tmdb] || {}; + var src = sh.library_id != null ? 'library' : 'tmdb'; + var openId = sh.library_id != null ? sh.library_id : tmdb; + var eyebrow, overview, castArr; + if (sel) { + eyebrow = 'S' + sel.season_number + ' · E' + sel.episode_number; + overview = sel.overview || 'No synopsis for this episode.'; + castArr = sel._guests || []; + } else { + eyebrow = ''; overview = si.overview || ''; castArr = si.cast || []; + } + var left = '
' + + (eyebrow ? '' + esc(eyebrow) + '' : '') + + esc(overview) + + '' + + '
'; + info.innerHTML = left + (castArr.length ? '
' + castBubbles(castArr) + '
' : ''); + // lazily fetch the episode's guest stars, then re-render if still selected + if (sel && sel._guests === undefined) { + sel._guests = null; + fetch('/api/video/episode/' + tmdb + '/' + sel.season_number + '/' + sel.episode_number, { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + sel._guests = (d && d.guest_stars) || []; + var cur = group.querySelector('.vwsh-epc--sel'); + if (cur && +cur.getAttribute('data-s') === sel.season_number && +cur.getAttribute('data-e') === sel.episode_number) + renderInfoBar(group, tmdb, sel); + }) + .catch(function () { sel._guests = []; }); + } + } + // Lazily load the show's synopsis + cast when its orb first expands. function loadShowInfo(group) { - if (!group || group.getAttribute('data-vwsh-info-loaded')) return; - group.setAttribute('data-vwsh-info-loaded', '1'); + if (!group) return; var tmdb = parseInt(group.getAttribute('data-vwsh-tmdb'), 10); + renderInfoBar(group, tmdb, null); // paint the View-show button immediately + if (group.getAttribute('data-vwsh-info-loaded')) return; + group.setAttribute('data-vwsh-info-loaded', '1'); 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 - ? '' - : ''; - return ''; - }).join(''); - info.innerHTML = (d.overview ? '
' + esc(d.overview) + '
' : '') + - (cast ? '
' + cast + '
' : ''); + if (!d) return; + state.showInfo[tmdb] = { overview: d.overview || '', cast: d.cast || [] }; + if (!group.querySelector('.vwsh-epc--sel')) renderInfoBar(group, tmdb, null); // unless an episode is selected }) .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). + // A single episode card. Clicking it SELECTS the episode (drives the info bar); + // the "View show" button in the info bar is what navigates. function epCard(sh, se, e) { - var src = sh.library_id != null ? 'library' : 'tmdb'; - var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id; var t = e.title || ('Episode ' + e.episode_number); var st = STATUS[e.status] ? e.status : 'wanted'; var date = fmtDate(e.air_date); @@ -157,12 +199,11 @@ ? '' : ''; - return '
' + thumb + + return '
' + thumb + '
' + '
' + esc(t) + '
' + '
' + 'S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : '') + '
' + - (e.overview ? '
' + esc(e.overview) + '
' : '') + '
' + '' + @@ -319,14 +360,27 @@ })); return; } + var epc = e.target.closest('[data-vwsh-ep]'); + if (epc) { // episode → SELECT it (drives the info bar); click again to deselect + var eg = epc.closest('.wl-orb-group'); + var etmdb = parseInt(epc.getAttribute('data-tmdb'), 10); + var eWasSel = epc.classList.contains('vwsh-epc--sel'); + if (eg) { var es = eg.querySelectorAll('.vwsh-epc'); for (var j = 0; j < es.length; j++) es[j].classList.remove('vwsh-epc--sel'); } + if (eWasSel) { renderInfoBar(eg, etmdb, null); return; } // back to show synopsis + cast + epc.classList.add('vwsh-epc--sel'); + renderInfoBar(eg, etmdb, findEpisode(etmdb, parseInt(epc.getAttribute('data-s'), 10), parseInt(epc.getAttribute('data-e'), 10))); + return; + } var tile = e.target.closest('[data-vwsh-tile]'); if (tile) { // season → render its episodes full-width below (single-select) var group = tile.closest('.wl-orb-group'); + var tmdb = parseInt(tile.getAttribute('data-tmdb'), 10); var wasSel = tile.classList.contains('vwsh-tile--sel'); if (group) { var all = group.querySelectorAll('.wl-album-tile'); for (var i = 0; i < all.length; i++) all[i].classList.remove('vwsh-tile--sel'); } + renderInfoBar(group, tmdb, null); // changing/closing a season resets the info bar to show-level if (wasSel) { var a = group && group.querySelector('[data-vwsh-ep-area]'); if (a) a.innerHTML = ''; return; } tile.classList.add('vwsh-tile--sel'); - renderEpisodeArea(group, parseInt(tile.getAttribute('data-tmdb'), 10), parseInt(tile.getAttribute('data-vwsh-season'), 10)); + renderEpisodeArea(group, tmdb, parseInt(tile.getAttribute('data-vwsh-season'), 10)); return; } var orb = e.target.closest('[data-vwsh-orb]');