From c450fa1f9a2f1e399c8f929ddf27461e277da806 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 17:05:51 -0700 Subject: [PATCH] video detail: 4 season-nav views + view toggle, real Watchlist, Missing filter Season selection is now switchable via a view toggle (persisted): poster RAIL (scrollable season cards w/ coverage), TIMELINE band (segments sized by episode count, filled by owned), TABS (pills), and the LIST dropdown. All drive the same selection; episodes fade in on change. - Watchlist button is now REAL: toggles shows.monitored via POST /api/video/monitor (set_monitored), reflects 'In Watchlist' state. show_detail returns monitored. - 'Get Missing' + a 'Missing only' toolbar toggle filter the episode list to unowned episodes (actual downloading is the future acquisition subsystem). Seam tests for the monitor endpoint + bad-input guards; shell hooks updated. --- api/video/detail.py | 14 +- database/video_database.py | 16 ++ tests/test_video_api.py | 16 ++ tests/test_video_side_shell.py | 3 +- webui/index.html | 8 +- webui/static/video/video-detail.js | 261 +++++++++++++++++++---------- webui/static/video/video-side.css | 72 +++++++- 7 files changed, 298 insertions(+), 92 deletions(-) diff --git a/api/video/detail.py b/api/video/detail.py index 6b56f934..44fec4ac 100644 --- a/api/video/detail.py +++ b/api/video/detail.py @@ -8,7 +8,7 @@ Reads only video.db; isolated from the music API. from __future__ import annotations -from flask import jsonify +from flask import jsonify, request from utils.logging_config import get_logger @@ -16,6 +16,18 @@ logger = get_logger("video_api.detail") def register_routes(bp): + @bp.route("/monitor", methods=["POST"]) + def video_set_monitor(): + from . import get_video_db + body = request.get_json(silent=True) or {} + kind, item_id = body.get("kind"), body.get("id") + if kind not in ("movie", "show") or not isinstance(item_id, int): + return jsonify({"error": "bad request"}), 400 + ok = get_video_db().set_monitored(kind, item_id, bool(body.get("monitored"))) + if not ok: + return jsonify({"error": "not found"}), 404 + return jsonify({"success": True, "monitored": bool(body.get("monitored"))}) + @bp.route("/detail/show/", methods=["GET"]) def video_show_detail(show_id): from . import get_video_db diff --git a/database/video_database.py b/database/video_database.py index de4ab358..6743e779 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -688,11 +688,27 @@ class VideoDatabase: "content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"], "tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"], "has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]), + "monitored": bool(show["monitored"]), "season_count": len(out_seasons), "episode_total": total, "episode_owned": owned_total, "seasons": out_seasons, } + def set_monitored(self, kind: str, item_id: int, monitored: bool) -> bool: + """Toggle the 'follow/watchlist' flag on a movie or show. Returns True if a + row was updated.""" + table = {"movie": "movies", "show": "shows"}.get(kind) + if not table: + return False + conn = self._get_connection() + try: + cur = conn.execute(f"UPDATE {table} SET monitored=? WHERE id=?", + (1 if monitored else 0, item_id)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + def movie_detail(self, movie_id: int) -> dict | None: """Full movie detail: the movie + owned/file info. Drives the (isolated) video movie-detail page.""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py index ec43cdb3..1df42319 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -63,6 +63,22 @@ def test_show_detail_endpoint(tmp_path): videoapi._video_db = None +def test_monitor_toggle_endpoint(tmp_path): + client, videoapi = _make_client(tmp_path) + try: + sid = videoapi._video_db.upsert_show_tree("plex", {"server_id": "s1", "title": "S"}) + r = client.post("/api/video/monitor", json={"kind": "show", "id": sid, "monitored": False}) + assert r.status_code == 200 and r.get_json()["monitored"] is False + assert videoapi._video_db.show_detail(sid)["monitored"] is False + r2 = client.post("/api/video/monitor", json={"kind": "show", "id": sid, "monitored": True}) + assert r2.status_code == 200 and videoapi._video_db.show_detail(sid)["monitored"] is True + # bad inputs + assert client.post("/api/video/monitor", json={"kind": "bogus", "id": sid}).status_code == 400 + assert client.post("/api/video/monitor", json={"kind": "show", "id": 999999, "monitored": True}).status_code == 404 + finally: + videoapi._video_db = None + + def test_movie_detail_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 67320f3a..eae398f3 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -332,7 +332,8 @@ def test_show_detail_subpage_present(): _INDEX, r'
") # Netflix billboard + episodes containers the renderer fills. for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-meta', - 'data-vd-overview', 'data-vd-actions', 'data-vd-season-select', 'data-vd-episodes'): + 'data-vd-overview', 'data-vd-actions', 'data-vd-view-toggle', + 'data-vd-season-nav', 'data-vd-episodes'): 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 diff --git a/webui/index.html b/webui/index.html index 8f56489b..bd798b4c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -844,8 +844,14 @@

Episodes

-
+
+ +
+
+ +
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 3ae8da2a..abaf6af0 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -2,10 +2,11 @@ * SoulSync — Video detail page (isolated, NETFLIX-style — deliberately NOT the * music/Spotify layout). * - * A cinematic billboard (full-bleed backdrop, content anchored bottom-left), a - * per-show accent colour sampled from the poster, a custom season dropdown, and - * rich episode rows that fade in on season change. Opened by a card via - * soulsync:video-open-detail; video-side.js navigates, this loads + renders. + * A cinematic billboard (full-bleed backdrop, content bottom-left) with a + * per-show accent sampled from the poster, and a SEASON selector with four + * switchable views — poster rail / timeline / pills / dropdown — plus a + * "Missing only" episode filter. 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. @@ -16,19 +17,21 @@ var DETAIL_URL = '/api/video/detail/'; var TMDB_LOGO = 'https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg'; var TVDB_LOGO = 'https://www.svgrepo.com/show/443500/brand-tvdb.svg'; + var VIEW_KEY = 'soulsync_vd_season_view'; + var VIEWS = [ + { id: 'rail', label: 'Rail', ic: '▦' }, + { id: 'timeline', label: 'Timeline', ic: '▭' }, + { id: 'pills', label: 'Tabs', ic: '◉' }, + { id: 'dropdown', label: 'List', ic: '▾' }, + ]; + var data = null; var selectedSeason = null; + var seasonView = 'rail'; var menuOpen = false; + var missingOnly = false; - // Mirrors the music artist-hero badge: logo img with a short text fallback. - function badge(logo, fallback, title, url) { - var inner = logo - ? '' + fallback + '' - : '' + fallback + ''; - return url - ? '' + inner + '' - : '
' + inner + '
'; - } + try { var sv = localStorage.getItem(VIEW_KEY); if (sv) seasonView = sv; } catch (e) { /* ignore */ } function esc(s) { return String(s == null ? '' : s) @@ -52,32 +55,39 @@ for (var i = 0; i < data.seasons.length; i++) if (data.seasons[i].season_number === n) return data.seasons[i]; return null; } + function seasonArt(s) { + return s.has_poster ? '/api/video/poster/season/' + s.id + : (data && data.has_poster ? '/api/video/poster/show/' + data.id : ''); + } + function pct(s) { return s.episode_total ? Math.round(s.episode_owned / s.episode_total * 100) : 0; } + + function badge(logo, fallback, title, url) { + var inner = logo + ? '' + fallback + '' + : '' + fallback + ''; + return url + ? '' + inner + '' + : '
' + inner + '
'; + } // ── accent extraction (poster → dominant vibrant colour) ────────────────── function applyAccent(img) { try { - var w = 24, h = 24; - var c = document.createElement('canvas'); c.width = w; c.height = h; - var ctx = c.getContext('2d'); - ctx.drawImage(img, 0, 0, w, h); + var w = 24, h = 24, c = document.createElement('canvas'); c.width = w; c.height = h; + var ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); var px = ctx.getImageData(0, 0, w, h).data; var best = null, bestScore = -1, fr = 0, fg = 0, fb = 0, n = 0; for (var i = 0; i < px.length; i += 4) { var r = px[i], g = px[i + 1], b = px[i + 2], a = px[i + 3]; if (a < 128) continue; - var mx = Math.max(r, g, b), mn = Math.min(r, g, b); - var light = (mx + mn) / 2; + var mx = Math.max(r, g, b), mn = Math.min(r, g, b), light = (mx + mn) / 2; fr += r; fg += g; fb += b; n++; - if (light < 35 || light > 225) continue; // skip near-black/white - var sat = mx === 0 ? 0 : (mx - mn) / mx; - var score = sat * (mx / 255); // vibrant + bright + if (light < 35 || light > 225) continue; + var sat = mx === 0 ? 0 : (mx - mn) / mx, score = sat * (mx / 255); if (score > bestScore) { bestScore = score; best = [r, g, b]; } } if (!best && n) best = [Math.round(fr / n), Math.round(fg / n), Math.round(fb / n)]; - if (best) { - var r0 = root(); - if (r0) r0.style.setProperty('--vd-accent-rgb', best[0] + ', ' + best[1] + ', ' + best[2]); - } + if (best) { var r0 = root(); if (r0) r0.style.setProperty('--vd-accent-rgb', best[0] + ', ' + best[1] + ', ' + best[2]); } } catch (e) { /* tainted/no image — keep theme accent */ } } @@ -94,15 +104,12 @@ bg.classList.toggle('vd-bb-bg--poster', !d.has_backdrop && !!d.has_poster); bg.classList.toggle('vd-bb-bg--empty', !d.has_backdrop && !d.has_poster); } - - // offscreen poster → accent colour var poster = q('[data-vd-poster]'); if (poster && d.has_poster) { poster.onload = function () { applyAccent(poster); }; poster.src = '/api/video/poster/show/' + d.id; } - // meta row (Netflix style): owned% · year · rating · seasons · runtime · status var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0; var meta = []; meta.push('' + ownedPct + '% in library'); @@ -116,18 +123,8 @@ if (d.network) meta.push('' + esc(d.network) + ''); var m = q('[data-vd-meta]'); if (m) m.innerHTML = meta.join(''); - // Action buttons — reuse the EXACT music artist hero button styles. - var a = q('[data-vd-actions]'); - if (a) { - a.innerHTML = - '' + - ''; - } + renderActions(d); - // External-source links as artist-hero-badge chips (logo + text fallback). var l = q('[data-vd-links]'); if (l) { var badges = []; @@ -136,45 +133,106 @@ if (d.tvdb_id) badges.push(badge(TVDB_LOGO, 'TVDB', 'TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series')); l.innerHTML = badges.join(''); } - - var g = q('[data-vd-genres]'); - if (g) g.innerHTML = ''; // genres land with "capture everything" + var g = q('[data-vd-genres]'); if (g) g.innerHTML = ''; } - // ── season dropdown ─────────────────────────────────────────────────────── - function renderSeasonSelect() { - var host = q('[data-vd-season-select]'); - if (!host || !data) return; + function renderActions(d) { + var a = q('[data-vd-actions]'); + if (!a) return; + var watching = !!d.monitored; + a.innerHTML = + '' + + ''; + } + + // ── season selector (4 views) ───────────────────────────────────────────── + function renderViewToggle() { + var host = q('[data-vd-view-toggle]'); + if (!host) return; + host.innerHTML = VIEWS.map(function (v) { + return ''; + }).join(''); + } + + function renderSeasonNav() { + var host = q('[data-vd-season-nav]'); + if (!host || !data || !data.seasons.length) { if (host) host.innerHTML = ''; return; } + host.className = 'vd-season-nav vd-season-nav--' + seasonView; + if (seasonView === 'rail') host.innerHTML = railHTML(); + else if (seasonView === 'timeline') host.innerHTML = timelineHTML(); + else if (seasonView === 'pills') host.innerHTML = pillsHTML(); + else host.innerHTML = dropdownHTML(); + } + + function railHTML() { + return '
' + data.seasons.map(function (s) { + var art = seasonArt(s), p = pct(s); + var on = s.season_number === selectedSeason ? ' vd-rcard--active' : ''; + var img = art ? '' : ''; + return ''; + }).join('') + '
'; + } + + function timelineHTML() { + var total = data.seasons.reduce(function (a, s) { return a + Math.max(1, s.episode_total); }, 0) || 1; + return '
' + data.seasons.map(function (s) { + var p = pct(s), grow = Math.max(1, s.episode_total); + var on = s.season_number === selectedSeason ? ' vd-tseg--active' : ''; + return ''; + }).join('') + '
'; + } + + function pillsHTML() { + return '
' + data.seasons.map(function (s) { + var on = s.season_number === selectedSeason ? ' vd-pill-btn--active' : ''; + return ''; + }).join('') + '
'; + } + + function dropdownHTML() { var cur = seasonByNum(selectedSeason); - host.innerHTML = + return '
' + '' + '
' + data.seasons.map(function (s) { var on = s.season_number === selectedSeason ? ' vd-ss-opt--active' : ''; - return ''; - }).join('') + '
'; + }).join('') + '
'; } // ── episodes ────────────────────────────────────────────────────────────── function episodeRow(ep) { var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing'; var meta = []; - var rt = runtimeLabel(ep.runtime_minutes); - if (rt) meta.push(rt); + var rt = runtimeLabel(ep.runtime_minutes); if (rt) meta.push(rt); if (ep.air_date) meta.push(ep.air_date); return '
' + '
' + (ep.episode_number != null ? ep.episode_number : '') + '
' + '
' + - '
' + - '
' + + '
' + esc(ep.title || 'Episode ' + ep.episode_number) + '' + (meta.length ? '' + esc(meta.join(' · ')) + '' : '') + '
' + - (ep.overview ? '

' + esc(ep.overview) + '

' : '') + - '
' + - '
' + (ep.owned ? 'Owned' : 'Missing') + '
' + - '
'; + (ep.overview ? '

' + esc(ep.overview) + '

' : '') + '
' + + '
' + (ep.owned ? 'Owned' : 'Missing') + '
'; } function renderEpisodes() { @@ -182,39 +240,54 @@ if (!host) return; var season = seasonByNum(selectedSeason); if (!season) { host.innerHTML = ''; return; } - host.innerHTML = season.episodes.map(episodeRow).join(''); - // re-trigger the fade/slide-in animation - host.classList.remove('vd-ep-anim'); - void host.offsetWidth; - host.classList.add('vd-ep-anim'); + var eps = missingOnly ? season.episodes.filter(function (e) { return !e.owned; }) : season.episodes; + host.innerHTML = eps.length + ? eps.map(episodeRow).join('') + : '
No ' + (missingOnly ? 'missing ' : '') + 'episodes here. 🎉
'; + host.classList.remove('vd-ep-anim'); void host.offsetWidth; host.classList.add('vd-ep-anim'); } function selectSeason(n) { - selectedSeason = n; - menuOpen = false; - renderSeasonSelect(); - renderEpisodes(); + selectedSeason = n; menuOpen = false; + renderSeasonNav(); renderEpisodes(); + } + function setView(v) { + seasonView = v; menuOpen = false; + try { localStorage.setItem(VIEW_KEY, v); } catch (e) { /* ignore */ } + renderViewToggle(); renderSeasonNav(); } function showLoading(on) { var l = q('[data-vd-loading]'); if (l) l.hidden = !on; } + // ── watchlist (real monitor toggle) ─────────────────────────────────────── + function toggleWatchlist() { + if (!data) return; + var next = data.monitored ? 0 : 1; + fetch('/api/video/monitor', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ kind: 'show', id: data.id, monitored: next }), + }).then(function (r) { return r.ok ? r.json() : null; }) + .then(function (res) { + if (res && !res.error) { data.monitored = !!next; renderActions(data); } + }).catch(function () { /* ignore */ }); + } + function loadShow(id) { if (!root()) return; showLoading(true); - var ep = q('[data-vd-episodes]'); if (ep) ep.innerHTML = ''; - var ss = q('[data-vd-season-select]'); if (ss) ss.innerHTML = ''; + ['[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(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; - menuOpen = false; + data = d; menuOpen = false; missingOnly = false; selectedSeason = d.seasons && d.seasons.length ? d.seasons[0].season_number : null; + var mt = q('[data-vd-missing-toggle]'); + if (mt) { mt.hidden = !(d.seasons && d.seasons.length); mt.classList.remove('vd-missing-toggle--on'); } renderBillboard(d); - renderSeasonSelect(); - renderEpisodes(); + renderViewToggle(); renderSeasonNav(); renderEpisodes(); var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]'); if (sub) sub.scrollTop = 0; }) @@ -222,18 +295,33 @@ } // ── events ──────────────────────────────────────────────────────────────── - function onOpen(e) { - if (!e || !e.detail || e.detail.kind !== 'show') return; - loadShow(e.detail.id); - } + function onOpen(e) { if (e && e.detail && e.detail.kind === 'show') loadShow(e.detail.id); } + function onClick(e) { var r = root(); if (!r) return; - var toggle = e.target.closest('[data-vd-ss-toggle]'); - if (toggle && r.contains(toggle)) { menuOpen = !menuOpen; renderSeasonSelect(); return; } - var pick = e.target.closest('[data-vd-ss-pick]'); - if (pick && r.contains(pick)) { selectSeason(parseInt(pick.getAttribute('data-vd-ss-pick'), 10)); return; } - // click-away closes the menu - if (menuOpen && !e.target.closest('[data-vd-season-select]')) { menuOpen = false; renderSeasonSelect(); } + var seasonBtn = e.target.closest('[data-vd-season]'); + if (seasonBtn && r.contains(seasonBtn)) { selectSeason(parseInt(seasonBtn.getAttribute('data-vd-season'), 10)); return; } + var viewBtn = e.target.closest('[data-vd-view]'); + if (viewBtn && r.contains(viewBtn)) { setView(viewBtn.getAttribute('data-vd-view')); return; } + var ssToggle = e.target.closest('[data-vd-ss-toggle]'); + if (ssToggle && r.contains(ssToggle)) { menuOpen = !menuOpen; renderSeasonNav(); return; } + var act = e.target.closest('[data-vd-act]'); + if (act && r.contains(act)) { + var which = act.getAttribute('data-vd-act'); + if (which === 'watchlist') toggleWatchlist(); + else if (which === 'missing') toggleMissing(); + return; + } + var mt = e.target.closest('[data-vd-missing-toggle]'); + if (mt && r.contains(mt)) { toggleMissing(); return; } + if (menuOpen && !e.target.closest('[data-vd-season-nav]')) { menuOpen = false; renderSeasonNav(); } + } + + function toggleMissing() { + missingOnly = !missingOnly; + var mt = q('[data-vd-missing-toggle]'); + if (mt) mt.classList.toggle('vd-missing-toggle--on', missingOnly); + renderEpisodes(); } function init() { @@ -241,9 +329,6 @@ document.addEventListener('click', onClick); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); })(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 96803130..68c13b18 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -505,8 +505,78 @@ body[data-side="video"] .dashboard-header-sweep { .vd-ep-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } .vd-ep-heading { font-size: 22px; font-weight: 800; color: #fff; margin: 0; } +.vd-toolbar-right { display: flex; align-items: center; gap: 12px; } +.vd-missing-toggle { + padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 12.5px; font-weight: 700; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.8); + transition: all 0.2s ease; +} +.vd-missing-toggle:hover { background: rgba(255,255,255,0.1); } +.vd-missing-toggle--on { background: rgba(var(--vd-accent-rgb), 0.22); border-color: rgba(var(--vd-accent-rgb), 0.6); color: #fff; } + +/* view toggle (rail / timeline / pills / dropdown) */ +.vd-view-toggle { display: inline-flex; gap: 2px; padding: 3px; border-radius: 9px; + background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); } +.vd-vt-btn { width: 34px; height: 30px; border: 0; border-radius: 6px; background: transparent; cursor: pointer; + color: rgba(255,255,255,0.55); font-size: 14px; transition: all 0.18s ease; } +.vd-vt-btn:hover { background: rgba(255,255,255,0.08); color: #fff; } +.vd-vt-btn--active { background: rgba(var(--vd-accent-rgb), 0.85); color: #fff; box-shadow: 0 2px 10px rgba(var(--vd-accent-rgb), 0.4); } + +.vd-season-nav { margin-bottom: 26px; } +.vd-season-nav:empty { margin: 0; } + +/* — rail — */ +.vd-rail { display: flex; gap: 14px; overflow-x: auto; padding: 6px 2px 14px; scroll-snap-type: x proximity; } +.vd-rail::-webkit-scrollbar { height: 8px; } +.vd-rail::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } +.vd-rcard { flex: 0 0 150px; width: 150px; text-align: left; padding: 0; cursor: pointer; scroll-snap-align: start; + border-radius: 12px; 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-rcard:hover { transform: translateY(-5px); border-color: rgba(var(--vd-accent-rgb), 0.3); + box-shadow: 0 12px 36px rgba(0,0,0,0.5), 0 0 22px rgba(var(--vd-accent-rgb), 0.16); } +.vd-rcard--active { border-color: rgba(var(--vd-accent-rgb), 0.7); box-shadow: 0 12px 36px rgba(0,0,0,0.5), 0 0 26px rgba(var(--vd-accent-rgb), 0.32); } +.vd-rcard-art { position: relative; aspect-ratio: 2/3; overflow: hidden; + background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.2), rgba(var(--vd-accent-rgb), 0.05)); } +.vd-rcard-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.vd-rcard-fb { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 36px; opacity: 0.32; } +.vd-rcard-grad { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 50%, rgba(10,10,14,0.85)); } +.vd-rcard-pct { position: absolute; top: 7px; right: 7px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 800; + color: #fff; background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); } +.vd-rcard-info { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 5px; } +.vd-rcard-name { font-size: 13.5px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vd-rcard-sub { font-size: 11.5px; color: rgba(255,255,255,0.55); } +.vd-rcard-bar { height: 5px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; } +.vd-rcard-bar > span { display: block; height: 100%; border-radius: 999px; + background: linear-gradient(90deg, rgb(var(--vd-accent-rgb)), rgba(var(--vd-accent-rgb), 0.5)); } + +/* — timeline — */ +.vd-timeline { display: flex; gap: 6px; align-items: stretch; } +.vd-tseg { position: relative; min-width: 84px; height: 76px; padding: 0; cursor: pointer; overflow: hidden; + border-radius: 10px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.04); + transition: all 0.25s ease; } +.vd-tseg:hover { border-color: rgba(var(--vd-accent-rgb), 0.45); transform: translateY(-2px); } +.vd-tseg--active { border-color: rgba(var(--vd-accent-rgb), 0.75); box-shadow: 0 6px 22px rgba(var(--vd-accent-rgb), 0.28); } +.vd-tseg-fill { position: absolute; left: 0; bottom: 0; top: 0; border-radius: 9px 0 0 9px; + background: linear-gradient(180deg, rgba(var(--vd-accent-rgb), 0.45), rgba(var(--vd-accent-rgb), 0.18)); } +.vd-tseg-label { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 3px; padding: 12px 14px; height: 100%; justify-content: center; } +.vd-tseg-name { font-size: 14px; font-weight: 700; color: #fff; white-space: nowrap; } +.vd-tseg-meta { font-size: 12px; color: rgba(255,255,255,0.7); } + +/* — pills — */ +.vd-pills { display: flex; flex-wrap: wrap; gap: 9px; } +.vd-pill-btn { display: inline-flex; align-items: center; gap: 9px; padding: 9px 16px; border-radius: 999px; cursor: pointer; + background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.82); + font-size: 13.5px; font-weight: 700; transition: all 0.2s ease; } +.vd-pill-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(var(--vd-accent-rgb), 0.4); } +.vd-pill-btn--active { background: rgba(var(--vd-accent-rgb), 0.9); border-color: transparent; color: #fff; + box-shadow: 0 4px 16px rgba(var(--vd-accent-rgb), 0.4); } +.vd-pill-meta { font-size: 11.5px; opacity: 0.8; } +.vd-pill-btn--active .vd-pill-meta { opacity: 0.95; } + +.vd-ep-empty { padding: 40px 0; text-align: center; color: rgba(255,255,255,0.5); } + /* season dropdown */ -.vd-season-select { position: relative; } +.vd-season-select { position: relative; display: inline-block; } .vd-ss-btn { display: inline-flex; align-items: center; gap: 12px; padding: 10px 16px; min-width: 150px; justify-content: space-between; border-radius: 8px; cursor: pointer;