From 519685fc32032201c807e98c82aa210b96ed3d8a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 16:13:30 -0700 Subject: [PATCH] video: rework TV-detail to match the artist-detail vibe + real season art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the 'feels basic' feedback: - Hero is now a contained glass card with the backdrop blurred INSIDE it + gradient overlay (same treatment as the music artist hero) — no more bare gaps around the top/sides. Bigger poster, accent external-link chips (IMDb/TMDB/TVDB), refined badges + stat tiles. - Seasons are a poster-art card grid (season = album) with coverage rings/bars and hover-lift, selecting one renders its episodes below (episode = track) — episode overviews now shown. Mirrors the artist album-grid -> tracklist. - Scan now captures real per-season posters (Plex sh.seasons() thumbs / Jellyfin /Seasons Primary), served via get_art_ref('season') + /api/video/poster/season. Falls back to the show poster until a re-scan populates them. Seam tests for the season art ref; shell markup tests still green. --- core/video/sources.py | 49 +++++++- database/video_database.py | 23 ++-- tests/test_video_database.py | 12 ++ webui/index.html | 26 +++-- webui/static/video/video-detail.js | 169 +++++++++++++++++---------- webui/static/video/video-side.css | 179 +++++++++++++++++------------ 6 files changed, 304 insertions(+), 154 deletions(-) diff --git a/core/video/sources.py b/core/video/sources.py index c1a0f8cc..8c9b9939 100644 --- a/core/video/sources.py +++ b/core/video/sources.py @@ -240,9 +240,28 @@ class PlexVideoSource: seasons_map.setdefault(snum, []).append(self._episode(ep, snum, enum)) except Exception: logger.exception("Plex: failed reading episodes for %s", getattr(sh, "title", "?")) - seasons = [{"server_id": None, "season_number": n, "title": None, - "overview": None, "poster_url": None, "episodes": eps} - for n, eps in sorted(seasons_map.items())] + # Season metadata (poster/title/overview) — one extra call per show gives + # real per-season art for the detail page. + season_meta = {} + try: + for se in sh.seasons(): + sidx = getattr(se, "index", None) + if sidx is None: + continue + season_meta[sidx] = { + "server_id": str(getattr(se, "ratingKey", "")) or None, + "title": getattr(se, "title", None), + "overview": getattr(se, "summary", None), + "poster_url": getattr(se, "thumb", None), + } + except Exception: + logger.exception("Plex: failed reading seasons for %s", getattr(sh, "title", "?")) + seasons = [] + for n, eps in sorted(seasons_map.items()): + meta = season_meta.get(n, {}) + seasons.append({"server_id": meta.get("server_id"), "season_number": n, + "title": meta.get("title"), "overview": meta.get("overview"), + "poster_url": meta.get("poster_url"), "episodes": eps}) d = { "server_id": str(sh.ratingKey), "title": sh.title, @@ -413,10 +432,28 @@ class JellyfinVideoSource: "tvdb_id": _parse_jf_providers(ep).get("tvdb_id"), "file": self._file(ep), }) + # Season metadata (poster/title/overview) — one extra call per show. + season_meta = {} + try: + seas = self._req(f"/Shows/{series_id}/Seasons", + {"UserId": self.uid, "Fields": "Overview"}) or {} + for se in seas.get("Items", []): + sidx = se.get("IndexNumber") + if sidx is None: + continue + season_meta[sidx] = { + "server_id": str(se["Id"]) if se.get("Id") else None, + "title": se.get("Name"), + "overview": se.get("Overview"), + "poster_url": (se.get("ImageTags") or {}).get("Primary"), + } + except Exception: + logger.exception("Jellyfin: failed reading seasons for %s", it.get("Name", "?")) for snum, eps in sorted(by_season.items()): - seasons.append({"server_id": None, "season_number": snum, - "title": None, "overview": None, - "poster_url": None, "episodes": eps}) + meta = season_meta.get(snum, {}) + seasons.append({"server_id": meta.get("server_id"), "season_number": snum, + "title": meta.get("title"), "overview": meta.get("overview"), + "poster_url": meta.get("poster_url"), "episodes": eps}) except Exception: logger.exception("Jellyfin: failed reading episodes for %s", it.get("Name", "?")) d = { diff --git a/database/video_database.py b/database/video_database.py index 823ac8f6..de4ab358 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -603,15 +603,24 @@ class VideoDatabase: return self.get_art_ref(kind, item_id, "poster") def get_art_ref(self, kind: str, item_id: int, art: str = "poster") -> dict | None: - """Server source/id + artwork path for one movie/show, for the image proxy. - ``art`` is 'poster' or 'backdrop'. Returns the path under 'poster_url' so - the proxy is artwork-agnostic.""" - table = {"movie": "movies", "show": "shows"}.get(kind) - col = {"poster": "poster_url", "backdrop": "backdrop_url"}.get(art) - if not table or not col: - return None + """Server source/id + artwork path for one movie/show/season, for the image + proxy. ``art`` is 'poster' or 'backdrop'. Returns the path under + 'poster_url' so the proxy is artwork-agnostic.""" conn = self._get_connection() try: + if kind == "season": + if art != "poster": + return None + # Seasons don't carry server_source — inherit the parent show's. + row = conn.execute( + "SELECT sh.server_source, se.server_id, se.poster_url " + "FROM seasons se JOIN shows sh ON sh.id = se.show_id WHERE se.id=?", + (item_id,)).fetchone() + return dict(row) if row else None + table = {"movie": "movies", "show": "shows"}.get(kind) + col = {"poster": "poster_url", "backdrop": "backdrop_url"}.get(art) + if not table or not col: + return None row = conn.execute( f"SELECT server_source, server_id, {col} AS poster_url FROM {table} WHERE id=?", (item_id,)).fetchone() diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 15cd5a28..3a9138f5 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -296,6 +296,18 @@ def test_get_art_ref_poster_vs_backdrop(db): assert db.get_art_ref("show", sid, "bogus") is None +def test_get_art_ref_season_inherits_show_server_source(db): + db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ + {"season_number": 1, "server_id": "se1", "poster_url": "/sp.jpg", + "episodes": [{"episode_number": 1}]}]}) + with db.connect() as c: + seid = c.execute("SELECT id FROM seasons WHERE season_number=1").fetchone()["id"] + ref = db.get_art_ref("season", seid, "poster") + # Season carries its own poster + server_id, but inherits the show's source. + assert ref["poster_url"] == "/sp.jpg" and ref["server_source"] == "plex" + assert ref["server_id"] == "se1" + + def test_prune_missing_skips_when_over_half_would_be_removed(db): # >100 movies; a scan that "sees" only a couple must NOT wipe the rest # (mirrors music's deep-scan 50% safety against partial server failures). diff --git a/webui/index.html b/webui/index.html index fb7d80d4..5896e6c7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -817,13 +817,16 @@ video-side.css. Inspired by the music artist-detail vibe. --> diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index b4662832..354605f2 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -1,10 +1,11 @@ /* * 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. + * Drill-in TV-show detail: a hero (contained glass card with the backdrop blurred + * inside it + overlay), then a poster-art SEASON grid (season = album) whose + * selected season renders its episodes below (episode = track). Inspired by the + * music artist page. Opened by a card via soulsync:video-open-detail; video-side.js + * navigates, this loads + renders. * * Self-contained IIFE, no globals, event-delegated, no inline handlers. Talks * only to /api/video/* — the music side is never touched. @@ -13,7 +14,8 @@ 'use strict'; var DETAIL_URL = '/api/video/detail/'; - var loaded = { show: null }; // remember the last show id we rendered + var data = null; // last loaded show payload + var selectedSeason = null; function esc(s) { return String(s == null ? '' : s) @@ -23,18 +25,20 @@ 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 '' + esc(label) + ''; } - function runtimeLabel(mins) { if (!mins) return ''; var h = Math.floor(mins / 60), m = mins % 60; return h ? (h + 'h' + (m ? ' ' + m + 'm' : '')) : (m + 'm'); } + function statusLabel(s) { + return s === 'continuing' ? 'Continuing' : s === 'ended' ? 'Ended' + : s === 'upcoming' ? 'Upcoming' : (s || ''); + } // ── hero ──────────────────────────────────────────────────────────────── function renderHero(d) { @@ -43,10 +47,9 @@ 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 bg = d.has_backdrop ? '/api/video/backdrop/show/' + d.id + : (d.has_poster ? '/api/video/poster/show/' + d.id : ''); + backdrop.style.backgroundImage = bg ? "url('" + bg + "')" : ''; } var poster = q('[data-vd-poster]'); @@ -54,27 +57,33 @@ if (poster && fallback) { if (d.has_poster) { poster.src = '/api/video/poster/show/' + d.id; - poster.style.display = ''; - fallback.style.display = 'none'; + 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 = ''; - } + } 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.status) badges.push(pill(statusLabel(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(''); + var b = q('[data-vd-badges]'); if (b) b.innerHTML = badges.join(''); + + // External-link badges (real, useful) — mirrors the artist hero's service row. + var links = []; + if (d.imdb_id) links.push(['IMDb', 'https://www.imdb.com/title/' + d.imdb_id + '/', 'vd-link--imdb']); + if (d.tmdb_id) links.push(['TMDB', 'https://www.themoviedb.org/tv/' + d.tmdb_id, 'vd-link--tmdb']); + if (d.tvdb_id) links.push(['TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series', 'vd-link--tvdb']); + var a = q('[data-vd-actions]'); + if (a) { + a.innerHTML = links.map(function (l) { + return '' + + esc(l[0]) + ''; + }).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], @@ -91,7 +100,50 @@ } } - // ── seasons → episodes tree ─────────────────────────────────────────────── + // ── season poster-card grid ─────────────────────────────────────────────── + function seasonArt(season) { + // Real per-season poster when the scan captured one; else the show poster. + return season.has_poster ? '/api/video/poster/season/' + season.id + : (data && data.has_poster ? '/api/video/poster/show/' + data.id : ''); + } + + function seasonCard(season) { + var pct = season.episode_total ? Math.round(season.episode_owned / season.episode_total * 100) : 0; + var art = seasonArt(season); + var sel = season.season_number === selectedSeason ? ' vd-scard--active' : ''; + var img = art + ? '' + : ''; + return ''; + } + + function renderSeasons(d) { + var title = q('[data-vd-seasons-title]'); + var host = q('[data-vd-seasons]'); + if (!host) return; + if (!d.seasons || !d.seasons.length) { + if (title) title.hidden = true; + host.innerHTML = ''; + var ep0 = q('[data-vd-episodes]'); if (ep0) ep0.innerHTML = ''; + return; + } + if (title) title.hidden = false; + host.innerHTML = d.seasons.map(seasonCard).join(''); + renderEpisodes(selectedSeason); + } + + // ── episodes panel (selected season) ────────────────────────────────────── function episodeRow(ep) { var num = ep.episode_number != null ? ('E' + ep.episode_number) : ''; var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing'; @@ -101,55 +153,59 @@ if (rt) meta.push(rt); return '
' + '' + esc(num) + '' + - '' + esc(ep.title || 'Episode ' + ep.episode_number) + '' + + '' + + esc(ep.title || 'Episode ' + ep.episode_number) + '' + + (ep.overview ? '' + esc(ep.overview) + '' : '') + (meta.length ? '' + esc(meta.join(' · ')) + '' : '') + '' + '' + (ep.owned ? 'Owned' : 'Missing') + '' + '
'; } - 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 '
' + - '' + - '
' + season.episodes.map(episodeRow).join('') + '
' + - '
'; - } - - function renderSeasons(d) { - var host = q('[data-vd-seasons]'); - if (!host) return; - if (!d.seasons || !d.seasons.length) { - host.innerHTML = '
No seasons found for this show yet.
'; - return; + function renderEpisodes(seasonNumber) { + var host = q('[data-vd-episodes]'); + if (!host || !data) return; + var season = null; + for (var i = 0; i < data.seasons.length; i++) { + if (data.seasons[i].season_number === seasonNumber) { season = data.seasons[i]; break; } } - host.innerHTML = d.seasons.map(seasonBlock).join(''); + if (!season) { host.innerHTML = ''; return; } + host.innerHTML = + '
' + esc(season.title) + '' + + '' + season.episode_owned + ' / ' + season.episode_total + + ' owned
' + + '
' + season.episodes.map(episodeRow).join('') + '
'; } - function showLoading(on) { - var l = q('[data-vd-loading]'); - if (l) l.hidden = !on; + function selectSeason(num) { + selectedSeason = num; + var r = root(); if (!r) return; + var cards = r.querySelectorAll('[data-vd-season]'); + for (var i = 0; i < cards.length; i++) { + cards[i].classList.toggle('vd-scard--active', + parseInt(cards[i].getAttribute('data-vd-season'), 10) === num); + } + renderEpisodes(num); } + 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 = ''; + ['[data-vd-seasons]', '[data-vd-episodes]'].forEach(function (sel) { + var n = q(sel); if (n) n.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; } + data = d; + selectedSeason = d.seasons && d.seasons.length ? d.seasons[0].season_number : null; renderHero(d); renderSeasons(d); + var r = root(); if (r) r.scrollTop = 0; }) .catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); }); } @@ -159,14 +215,11 @@ 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'); + var r = root(); if (!r) return; + var card = e.target.closest('[data-vd-season]'); + if (card && r.contains(card)) { + selectSeason(parseInt(card.getAttribute('data-vd-season'), 10)); } } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 49e91cc3..0b0862e3 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -438,108 +438,137 @@ body[data-side="video"] .dashboard-header-sweep { 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; } +/* ── Video detail page (TV-show drill-in) ──────────────────────────────────── + Isolated .vd-*, mirroring the music artist-detail language: a contained glass + hero with the backdrop blurred INSIDE it + gradient overlay, accent action + chips, and a poster-art season grid (season = album) → episodes (= tracks). */ +.vd-page { padding: 20px 20px 60px; max-width: 1240px; 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); + padding: 8px 15px; margin-bottom: 16px; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); 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; + font-size: 13px; font-weight: 600; cursor: pointer; 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); } +.vd-back:hover { background: rgba(var(--accent-rgb, 88,101,242), 0.16); border-color: rgba(var(--accent-rgb, 88,101,242), 0.45); transform: translateX(-2px); } + +/* Hero — a self-contained card; the backdrop lives INSIDE it (no bare gaps). */ +.vd-hero { + position: relative; overflow: hidden; border-radius: 18px; + border: 1px solid rgba(255,255,255,0.08); + box-shadow: 0 12px 40px rgba(0,0,0,0.4); + background: linear-gradient(135deg, rgba(26,26,30,0.9), rgba(16,16,20,0.96)); +} +.vd-hero-bg { + position: absolute; inset: -30px; + background-size: cover; background-position: center 20%; + filter: blur(46px) brightness(0.4) saturate(1.5); transform: scale(1.3); + z-index: 0; pointer-events: none; +} +.vd-hero-overlay { + position: absolute; inset: 0; z-index: 1; pointer-events: none; + background: + linear-gradient(180deg, rgba(10,10,14,0.35) 0%, rgba(12,12,16,0.78) 100%), + linear-gradient(90deg, rgba(12,12,16,0.55) 0%, transparent 70%); +} +.vd-hero-content { position: relative; z-index: 2; display: flex; gap: 30px; padding: 32px 36px; align-items: flex-start; } -/* 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; + position: relative; flex: 0 0 188px; width: 188px; 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); + border: 2px solid rgba(var(--accent-rgb, 88,101,242), 0.3); + box-shadow: 0 14px 40px rgba(0,0,0,0.55); + background: rgba(255,255,255,0.04); } .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-poster-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 48px; 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-hero-main { flex: 1; min-width: 0; } +.vd-title { font-size: 2.4em; line-height: 1.05; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.02em; + color: #fff; text-shadow: 0 2px 20px rgba(0,0,0,0.5); } +.vd-badges { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; } .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); + background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12); + color: rgba(255,255,255,0.92); 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; +.vd-overview { max-width: 760px; color: rgba(255,255,255,0.76); font-size: 14.5px; line-height: 1.6; margin: 0 0 16px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } -.vd-stats { display: flex; flex-wrap: wrap; gap: 12px; } +/* External-link chips — accent-gradient, like the artist hero actions. */ +.vd-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } +.vd-link { + display: inline-flex; align-items: center; padding: 7px 15px; border-radius: 8px; + font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-decoration: none; + color: rgb(var(--accent-light-rgb, 140, 150, 255)); + background: linear-gradient(135deg, rgba(var(--accent-rgb, 88,101,242), 0.16) 0%, rgba(var(--accent-rgb, 88,101,242), 0.05) 100%); + border: 1px solid rgba(var(--accent-rgb, 88,101,242), 0.28); transition: all 0.25s ease; +} +.vd-link:hover { transform: translateY(-1px); border-color: rgba(var(--accent-rgb, 88,101,242), 0.5); + box-shadow: 0 4px 16px rgba(var(--accent-rgb, 88,101,242), 0.22); } + +.vd-stats { display: flex; flex-wrap: wrap; gap: 10px; } .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; + min-width: 96px; padding: 12px 16px; border-radius: 12px; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); + backdrop-filter: blur(10px); 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-stat-label { font-size: 10.5px; 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; } +.vd-loading { padding: 40px 0; text-align: center; color: rgba(255,255,255,0.55); font-size: 14px; } +.vd-body { margin-top: 26px; } +.vd-section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; + color: rgba(255,255,255,0.4); margin: 0 4px 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); +/* Season poster cards (season = album) */ +.vd-seasons { + display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; } -.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-scard { + text-align: left; padding: 0; cursor: pointer; border-radius: 14px; overflow: hidden; + background: rgba(18,18,22,1); border: 1px solid rgba(255,255,255,0.07); + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + transition: transform 0.3s cubic-bezier(0.4,0,0.2,1), box-shadow 0.3s ease, border-color 0.3s 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-scard:hover { transform: translateY(-5px) scale(1.02); border-color: rgba(var(--accent-rgb, 88,101,242), 0.28); + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 24px rgba(var(--accent-rgb, 88,101,242), 0.14); } +.vd-scard--active { border-color: rgba(var(--accent-rgb, 88,101,242), 0.65); + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 26px rgba(var(--accent-rgb, 88,101,242), 0.3); } +.vd-scard-art { position: relative; aspect-ratio: 2 / 3; overflow: hidden; + background: linear-gradient(135deg, rgba(var(--accent-rgb, 88,101,242), 0.18), rgba(var(--accent-rgb, 88,101,242), 0.06)); } +.vd-scard-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.vd-scard-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 40px; opacity: 0.35; } +.vd-scard-grad { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 45%, rgba(10,10,14,0.85) 100%); } +.vd-scard-pct { position: absolute; top: 8px; right: 8px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 800; + color: #fff; background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); } +.vd-scard-info { padding: 11px 13px 13px; display: flex; flex-direction: column; gap: 5px; } +.vd-scard-name { font-size: 14px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vd-scard-sub { font-size: 11.5px; color: rgba(255,255,255,0.55); } +.vd-scard-bar { height: 5px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; } +.vd-scard-bar-fill { display: block; height: 100%; border-radius: 999px; + background: linear-gradient(90deg, rgba(var(--accent-rgb, 88,101,242), 0.95), rgba(var(--accent-rgb, 88,101,242), 0.5)); } -.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; -} +/* Episodes panel (selected season) */ +.vd-episodes { margin-top: 26px; } +.vd-ep-head { display: flex; align-items: baseline; gap: 12px; padding: 0 4px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 8px; } +.vd-ep-head-name { font-size: 17px; font-weight: 700; color: #fff; } +.vd-ep-head-meta { font-size: 12.5px; color: rgba(255,255,255,0.5); } +.vd-ep-list { display: flex; flex-direction: column; } +.vd-ep { display: grid; grid-template-columns: 46px 1fr auto; align-items: center; gap: 14px; + padding: 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-num { font-size: 13px; font-weight: 800; color: rgba(255,255,255,0.4); text-align: center; } +.vd-ep-body { min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.vd-ep-title { font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.95); } +.vd-ep-overview { font-size: 12.5px; color: rgba(255,255,255,0.55); line-height: 1.45; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.vd-ep-meta { font-size: 11.5px; color: rgba(255,255,255,0.42); } +.vd-ep-state { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; padding: 4px 10px; border-radius: 999px; white-space: nowrap; } .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; } +.vd-ep--missing .vd-ep-num, .vd-ep--missing .vd-ep-title { opacity: 0.72; } -/* Make show cards feel clickable. */ .video-card--clickable { cursor: pointer; }