video: TV-show detail page (hero + season/episode tree), isolated

Drill-in from a show card: full-bleed backdrop + poster + title/badges/overview +
stat tiles, then seasons->episodes as collapsible accordions with owned/missing
state and per-season coverage bars (season = album, episode = track — inspired by
the music artist page, premium vibe).

- video-detail.js (isolated IIFE) renders from /api/video/detail/show/<id>;
  backdrop/poster via the proxy.
- Show cards dispatch soulsync:video-open-detail; video-side.js navigates to the
  (non-nav) detail subpage; back button reuses data-video-goto.
- Movies stay non-clickable until the movie-detail page lands next.
- .vd-* CSS scoped to the detail page; music untouched. Shell + isolation tests.
This commit is contained in:
BoulderBadgeDad 2026-06-14 15:59:29 -07:00
parent 50632f6392
commit 5a2113bd03
6 changed files with 382 additions and 1 deletions

View file

@ -327,6 +327,39 @@ def test_video_worker_orbs_referenced_and_isolated():
assert "/api/enrichment/" not in src
def test_show_detail_subpage_present():
block = _block(
_INDEX, r'<section class="video-subpage" data-video-subpage="video-show-detail"', "</section>")
# Hero + season tree containers the renderer fills.
for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-badges',
'data-vd-overview', 'data-vd-stats', 'data-vd-seasons'):
assert hook in block, hook
# Back button reuses the shared data-video-goto nav (no inline handler).
assert 'data-video-goto="video-library"' in block
assert "onclick" not in block
def test_video_detail_module_referenced_and_isolated():
assert "video/video-detail.js" in _INDEX
src = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8")
assert "(function" in src and "})();" in src
assert "window." not in src # declares no globals
assert "/api/video/detail/" in src # video API only
assert "/api/enrichment/" not in src and "artist-detail" not in src
assert "soulsync:video-open-detail" in src # opened via the shared event
def test_library_cards_open_detail():
src = _LIB_JS
assert "soulsync:video-open-detail" in src # show cards drill in
assert "data-video-card-open" in src
def test_video_side_registers_detail_pages_and_open_event():
assert "video-show-detail" in _JS and "video-movie-detail" in _JS
assert "soulsync:video-open-detail" in _JS # navigates on the event
def test_music_worker_orbs_untouched_by_video():
# The video orbs are a separate file; the music orbs must not learn about
# the video side (one-way isolation — music never depends on video).

View file

@ -812,6 +812,34 @@
</div>
</div>
</section>
<!-- TV-show detail (drill-in from a show card; not a nav page).
Isolated: built by video/video-detail.js, styled by .vd-* in
video-side.css. Inspired by the music artist-detail vibe. -->
<section class="video-subpage" data-video-subpage="video-show-detail" hidden>
<div class="vd-page" data-video-detail="show">
<div class="vd-backdrop" data-vd-backdrop aria-hidden="true"></div>
<div class="vd-scrim" aria-hidden="true"></div>
<div class="vd-content">
<button class="vd-back" type="button" data-video-goto="video-library">
<span aria-hidden="true">&larr;</span> Library
</button>
<div class="vd-hero">
<div class="vd-poster">
<img data-vd-poster alt="" />
<div class="vd-poster-fallback" data-vd-poster-fallback>📺</div>
</div>
<div class="vd-hero-main">
<h1 class="vd-title" data-vd-title></h1>
<div class="vd-badges" data-vd-badges></div>
<p class="vd-overview" data-vd-overview></p>
<div class="vd-stats" data-vd-stats></div>
</div>
</div>
<div class="vd-loading" data-vd-loading hidden>Loading…</div>
<div class="vd-seasons" data-vd-seasons></div>
</div>
</div>
</section>
<section class="video-subpage" data-video-subpage="video-tools" hidden>
<div class="page-shell tools-page-container">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
@ -9067,6 +9095,8 @@
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
<!-- Video detail page (isolated; show drill-in: hero + season/episode tree) -->
<script src="{{ url_for('static', filename='video/video-detail.js', v=static_v) }}"></script>
<!-- Video worker orbs (isolated copy of the music orbs; idles into the
floating-orb animation around the SoulSync logo on the video dashboard) -->
<script src="{{ url_for('static', filename='video/video-worker-orbs.js', v=static_v) }}"></script>

View file

@ -0,0 +1,183 @@
/*
* SoulSync Video detail page (isolated).
*
* Drill-in TV-show detail: hero (backdrop + poster + title + badges + stats) and
* the seasons episodes tree (season = album, episode = track inspired by the
* music artist page). Opened by a card via the soulsync:video-open-detail event;
* video-side.js handles the navigation, this loads + renders the data.
*
* Self-contained IIFE, no globals, event-delegated, no inline handlers. Talks
* only to /api/video/* the music side is never touched.
*/
(function () {
'use strict';
var DETAIL_URL = '/api/video/detail/';
var loaded = { show: null }; // remember the last show id we rendered
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function root() { return document.querySelector('[data-video-detail="show"]'); }
function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; }
function setText(sel, text) { var n = q(sel); if (n) n.textContent = text || ''; }
function pill(label, cls) {
return '<span class="vd-pill' + (cls ? ' ' + cls : '') + '">' + esc(label) + '</span>';
}
function runtimeLabel(mins) {
if (!mins) return '';
var h = Math.floor(mins / 60), m = mins % 60;
return h ? (h + 'h' + (m ? ' ' + m + 'm' : '')) : (m + 'm');
}
// ── hero ────────────────────────────────────────────────────────────────
function renderHero(d) {
setText('[data-vd-title]', d.title);
setText('[data-vd-overview]', d.overview);
var backdrop = q('[data-vd-backdrop]');
if (backdrop) {
backdrop.style.backgroundImage = d.has_backdrop
? "url('/api/video/backdrop/show/" + d.id + "')"
: (d.has_poster ? "url('/api/video/poster/show/" + d.id + "')" : '');
backdrop.classList.toggle('vd-backdrop--empty', !d.has_backdrop && !d.has_poster);
}
var poster = q('[data-vd-poster]');
var fallback = q('[data-vd-poster-fallback]');
if (poster && fallback) {
if (d.has_poster) {
poster.src = '/api/video/poster/show/' + d.id;
poster.style.display = '';
fallback.style.display = 'none';
poster.onerror = function () { poster.style.display = 'none'; fallback.style.display = ''; };
} else {
poster.style.display = 'none';
fallback.style.display = '';
}
}
var badges = [];
if (d.year) badges.push(pill(d.year));
if (d.content_rating) badges.push(pill(d.content_rating, 'vd-pill--rating'));
if (d.status) badges.push(pill(d.status === 'continuing' ? 'Continuing'
: d.status === 'ended' ? 'Ended' : d.status));
if (d.network) badges.push(pill(d.network));
var rt = runtimeLabel(d.runtime_minutes);
if (rt) badges.push(pill(rt));
var b = q('[data-vd-badges]');
if (b) b.innerHTML = badges.join('');
// Stat tiles — seasons / episodes / owned coverage.
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
var stats = [
['Seasons', d.season_count],
['Episodes', d.episode_total],
['Owned', d.episode_owned + ' / ' + d.episode_total],
['Collected', ownedPct + '%'],
];
var s = q('[data-vd-stats]');
if (s) {
s.innerHTML = stats.map(function (st) {
return '<div class="vd-stat"><span class="vd-stat-num">' + esc(st[1]) +
'</span><span class="vd-stat-label">' + esc(st[0]) + '</span></div>';
}).join('');
}
}
// ── seasons → episodes tree ───────────────────────────────────────────────
function episodeRow(ep) {
var num = ep.episode_number != null ? ('E' + ep.episode_number) : '';
var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing';
var meta = [];
if (ep.air_date) meta.push(ep.air_date);
var rt = runtimeLabel(ep.runtime_minutes);
if (rt) meta.push(rt);
return '<div class="vd-ep ' + owned + '">' +
'<span class="vd-ep-num">' + esc(num) + '</span>' +
'<span class="vd-ep-body"><span class="vd-ep-title">' + esc(ep.title || 'Episode ' + ep.episode_number) + '</span>' +
(meta.length ? '<span class="vd-ep-meta">' + esc(meta.join(' · ')) + '</span>' : '') +
'</span>' +
'<span class="vd-ep-state">' + (ep.owned ? 'Owned' : 'Missing') + '</span>' +
'</div>';
}
function seasonBlock(season, idx) {
var pct = season.episode_total ? Math.round(season.episode_owned / season.episode_total * 100) : 0;
var open = idx === 0 ? ' vd-season--open' : '';
return '<div class="vd-season' + open + '" data-vd-season="' + season.season_number + '">' +
'<button class="vd-season-head" type="button" data-vd-season-toggle>' +
'<span class="vd-season-name">' + esc(season.title) + '</span>' +
'<span class="vd-season-meta">' + season.episode_owned + ' / ' + season.episode_total + ' · ' + pct + '%</span>' +
'<span class="vd-season-bar"><span class="vd-season-bar-fill" style="width:' + pct + '%"></span></span>' +
'<span class="vd-season-caret" aria-hidden="true">▾</span>' +
'</button>' +
'<div class="vd-season-eps">' + season.episodes.map(episodeRow).join('') + '</div>' +
'</div>';
}
function renderSeasons(d) {
var host = q('[data-vd-seasons]');
if (!host) return;
if (!d.seasons || !d.seasons.length) {
host.innerHTML = '<div class="vd-empty">No seasons found for this show yet.</div>';
return;
}
host.innerHTML = d.seasons.map(seasonBlock).join('');
}
function showLoading(on) {
var l = q('[data-vd-loading]');
if (l) l.hidden = !on;
}
function loadShow(id) {
if (!root()) return;
loaded.show = id;
showLoading(true);
var seasons = q('[data-vd-seasons]');
if (seasons) seasons.innerHTML = '';
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
renderHero(d);
renderSeasons(d);
})
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
}
// ── events ────────────────────────────────────────────────────────────────
function onOpen(e) {
if (!e || !e.detail || e.detail.kind !== 'show') return;
loadShow(e.detail.id);
}
function onClick(e) {
var r = root();
if (!r) return;
var toggle = e.target.closest('[data-vd-season-toggle]');
if (toggle && r.contains(toggle)) {
var block = toggle.closest('.vd-season');
if (block) block.classList.toggle('vd-season--open');
}
}
function init() {
document.addEventListener('soulsync:video-open-detail', onOpen);
document.addEventListener('click', onClick);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -76,7 +76,12 @@
if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
return '<div class="library-artist-card">' + img + badge +
// Shows drill into the detail page; movies aren't clickable until the
// movie-detail page lands (then this opens kind === 'movie' too).
var clickable = kind === 'show' ? ' video-card--clickable' : '';
var hook = kind === 'show'
? ' data-video-card-open="show" data-video-card-id="' + it.id + '"' : '';
return '<div class="library-artist-card' + clickable + '"' + hook + '>' + img + badge +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +
@ -138,6 +143,18 @@
function reload() { state.page = 1; load(); }
function wire() {
// Card click → drill into the detail page (event-delegated on the grid).
var grid = $('[data-video-lib-grid]');
if (grid) {
grid.addEventListener('click', function (e) {
var card = e.target.closest('[data-video-card-open]');
if (!card || !grid.contains(card)) return;
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: card.getAttribute('data-video-card-open'),
id: parseInt(card.getAttribute('data-video-card-id'), 10) },
}));
});
}
var tabs = document.querySelectorAll('[data-video-lib-tab]');
for (var i = 0; i < tabs.length; i++) {
(function (tab) {

View file

@ -437,3 +437,109 @@ body[data-side="video"] .dashboard-header-sweep {
border-radius: 5px;
backdrop-filter: blur(4px);
}
/* Video detail page (TV-show drill-in; isolated .vd-* inspired by the
music artist-detail vibe: full-bleed backdrop, glass panels, accent). */
.vd-page { position: relative; min-height: 100%; }
/* Full-bleed hero artwork, gently blurred, fading into the page. */
.vd-backdrop {
position: absolute; top: 0; left: 0; right: 0; height: 460px;
background-size: cover; background-position: center 18%;
filter: saturate(1.15);
opacity: 0.55;
transform: scale(1.04);
}
.vd-backdrop--empty { background: radial-gradient(120% 80% at 50% 0%, rgba(var(--accent-rgb, 88, 101, 242), 0.25), transparent 60%); }
.vd-scrim {
position: absolute; top: 0; left: 0; right: 0; height: 480px;
background:
linear-gradient(180deg, rgba(10,10,14,0.35) 0%, rgba(10,10,14,0.75) 55%, var(--bg-primary, #0c0c10) 100%),
linear-gradient(90deg, rgba(10,10,14,0.7) 0%, transparent 55%);
}
.vd-content { position: relative; z-index: 1; padding: 22px 30px 60px; max-width: 1180px; margin: 0 auto; }
.vd-back {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 15px; margin-bottom: 26px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12);
border-radius: 999px; color: var(--text-primary, #e8e8ea);
font-size: 13px; font-weight: 600; cursor: pointer;
backdrop-filter: blur(8px); transition: all 0.2s ease;
}
.vd-back:hover { background: rgba(var(--accent-rgb, 88,101,242), 0.18); border-color: rgba(var(--accent-rgb, 88,101,242), 0.5); transform: translateX(-2px); }
/* Hero */
.vd-hero { display: flex; gap: 28px; align-items: flex-end; }
.vd-poster {
position: relative; flex: 0 0 210px; width: 210px; aspect-ratio: 2 / 3;
border-radius: 14px; overflow: hidden;
box-shadow: 0 18px 50px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.08);
background: rgba(255,255,255,0.05);
}
.vd-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vd-poster-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 54px; opacity: 0.5; }
.vd-hero-main { flex: 1; min-width: 0; padding-bottom: 6px; }
.vd-title { font-size: 40px; line-height: 1.05; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.5px;
text-shadow: 0 2px 18px rgba(0,0,0,0.5); }
.vd-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.vd-pill {
padding: 4px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.14);
color: var(--text-primary, #e8e8ea); backdrop-filter: blur(6px);
}
.vd-pill--rating { border-color: rgba(var(--accent-rgb, 88,101,242), 0.55); color: #fff; }
.vd-overview { max-width: 720px; color: rgba(255,255,255,0.78); font-size: 14.5px; line-height: 1.6; margin: 0 0 18px;
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.vd-stats { display: flex; flex-wrap: wrap; gap: 12px; }
.vd-stat {
min-width: 92px; padding: 12px 16px; border-radius: 12px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.10);
backdrop-filter: blur(10px) saturate(1.2);
display: flex; flex-direction: column; gap: 2px;
}
.vd-stat-num { font-size: 20px; font-weight: 800; color: #fff; }
.vd-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255,255,255,0.5); }
.vd-loading, .vd-empty { padding: 40px 0; text-align: center; color: rgba(255,255,255,0.55); font-size: 14px; }
/* Seasons → episodes tree */
.vd-seasons { margin-top: 34px; display: flex; flex-direction: column; gap: 12px; }
.vd-season {
border-radius: 14px; overflow: hidden;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
}
.vd-season-head {
width: 100%; display: grid; grid-template-columns: 1fr auto 180px auto; align-items: center; gap: 16px;
padding: 15px 18px; background: transparent; border: 0; cursor: pointer;
color: var(--text-primary, #e8e8ea); text-align: left; transition: background 0.2s ease;
}
.vd-season-head:hover { background: rgba(255,255,255,0.04); }
.vd-season-name { font-size: 16px; font-weight: 700; }
.vd-season-meta { font-size: 12.5px; color: rgba(255,255,255,0.6); white-space: nowrap; }
.vd-season-bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,0.10); overflow: hidden; }
.vd-season-bar-fill { display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg, rgba(var(--accent-rgb, 88,101,242), 0.9), rgba(var(--accent-rgb, 88,101,242), 0.55)); }
.vd-season-caret { transition: transform 0.25s ease; opacity: 0.6; font-size: 12px; }
.vd-season--open .vd-season-caret { transform: rotate(180deg); }
.vd-season-eps { display: none; padding: 4px 10px 10px; }
.vd-season--open .vd-season-eps { display: block; }
.vd-ep {
display: grid; grid-template-columns: 44px 1fr auto; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 10px; transition: background 0.15s ease;
}
.vd-ep:hover { background: rgba(255,255,255,0.04); }
.vd-ep-num { font-size: 13px; font-weight: 700; color: rgba(255,255,255,0.45); text-align: center; }
.vd-ep-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.vd-ep-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vd-ep-meta { font-size: 11.5px; color: rgba(255,255,255,0.5); }
.vd-ep-state { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; padding: 3px 9px; border-radius: 999px; }
.vd-ep--owned .vd-ep-state { color: #4ade80; background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.3); }
.vd-ep--missing .vd-ep-state { color: rgba(255,255,255,0.45); background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); }
.vd-ep--missing .vd-ep-num, .vd-ep--missing .vd-ep-title { opacity: 0.7; }
/* Make show cards feel clickable. */
.video-card--clickable { cursor: pointer; }

View file

@ -38,6 +38,9 @@
{ id: 'video-settings', label: 'Settings' },
{ id: 'video-issues', label: 'Issues', shared: true },
{ id: 'video-help', label: 'Help & Docs', shared: true },
// Drill-in detail pages — reachable from cards, not the sidebar nav.
{ id: 'video-show-detail', label: 'Show' },
{ id: 'video-movie-detail', label: 'Movie' },
];
// "Shared" video pages reuse the REAL music page (shown identically on the
@ -181,6 +184,15 @@
})(gotos[k]);
}
// Drill-in: a card fires soulsync:video-open-detail {kind, id}. We just
// navigate to the matching detail subpage; video-detail.js (listening to
// the same event) loads the data. Keeps the two concerns decoupled.
document.addEventListener('soulsync:video-open-detail', function (e) {
var kind = e && e.detail && e.detail.kind;
if (kind === 'movie') navigate('video-movie-detail');
else if (kind === 'show') navigate('video-show-detail');
});
var defaultNav = document.querySelector(
'.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]');
if (defaultNav) defaultNav.classList.add('active');