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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 17:05:51 -07:00
parent 20f1214b68
commit c450fa1f9a
7 changed files with 298 additions and 92 deletions

View file

@ -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/<int:show_id>", methods=["GET"])
def video_show_detail(show_id):
from . import get_video_db

View file

@ -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."""

View file

@ -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:

View file

@ -332,7 +332,8 @@ def test_show_detail_subpage_present():
_INDEX, r'<section class="video-subpage" data-video-subpage="video-show-detail"', "</section>")
# 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

View file

@ -844,8 +844,14 @@
<div class="vd-body">
<div class="vd-ep-toolbar">
<h2 class="vd-ep-heading">Episodes</h2>
<div class="vd-season-select" data-vd-season-select></div>
<div class="vd-toolbar-right">
<button class="vd-missing-toggle" type="button" data-vd-missing-toggle hidden>Missing only</button>
<div class="vd-view-toggle" data-vd-view-toggle></div>
</div>
</div>
<!-- Season selector — view mode (rail/timeline/pills/dropdown)
chosen by the toggle above; all drive the same selection. -->
<div class="vd-season-nav" data-vd-season-nav></div>
<div class="vd-episodes" data-vd-episodes></div>
</div>
</div>

View file

@ -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
? '<img src="' + logo + '" alt="' + fallback + '" onerror="this.parentNode.textContent=\'' + fallback + '\'">'
: '<span style="font-size:9px;font-weight:700;">' + fallback + '</span>';
return url
? '<a class="artist-hero-badge" title="' + title + '" href="' + url + '" target="_blank" rel="noopener noreferrer">' + inner + '</a>'
: '<div class="artist-hero-badge" title="' + title + '">' + inner + '</div>';
}
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
? '<img src="' + logo + '" alt="' + fallback + '" onerror="this.parentNode.textContent=\'' + fallback + '\'">'
: '<span style="font-size:9px;font-weight:700;">' + fallback + '</span>';
return url
? '<a class="artist-hero-badge" title="' + title + '" href="' + url + '" target="_blank" rel="noopener noreferrer">' + inner + '</a>'
: '<div class="artist-hero-badge" title="' + title + '">' + inner + '</div>';
}
// ── 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('<span class="vd-match">' + ownedPct + '% in library</span>');
@ -116,18 +123,8 @@
if (d.network) meta.push('<span>' + esc(d.network) + '</span>');
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 =
'<button class="library-artist-watchlist-btn" type="button" data-vd-act="watchlist">' +
'<span class="watchlist-icon"></span><span class="watchlist-text">Watchlist</span></button>' +
'<button class="discog-download-btn discog-btn-compact" type="button" data-vd-act="download">' +
'<span class="discog-btn-icon">⭳</span><span class="discog-btn-text">Get Missing</span>' +
'<span class="discog-btn-shimmer"></span></button>';
}
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 =
'<button class="library-artist-watchlist-btn' + (watching ? ' watching' : '') +
'" type="button" data-vd-act="watchlist">' +
'<span class="watchlist-icon">' + (watching ? '✓' : '') + '</span>' +
'<span class="watchlist-text">' + (watching ? 'In Watchlist' : 'Watchlist') + '</span></button>' +
'<button class="discog-download-btn discog-btn-compact" type="button" data-vd-act="missing">' +
'<span class="discog-btn-icon">⭳</span><span class="discog-btn-text">Get Missing</span>' +
'<span class="discog-btn-shimmer"></span></button>';
}
// ── season selector (4 views) ─────────────────────────────────────────────
function renderViewToggle() {
var host = q('[data-vd-view-toggle]');
if (!host) return;
host.innerHTML = VIEWS.map(function (v) {
return '<button class="vd-vt-btn' + (v.id === seasonView ? ' vd-vt-btn--active' : '') +
'" type="button" data-vd-view="' + v.id + '" title="' + v.label + '">' +
'<span class="vd-vt-ic">' + v.ic + '</span></button>';
}).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 '<div class="vd-rail">' + data.seasons.map(function (s) {
var art = seasonArt(s), p = pct(s);
var on = s.season_number === selectedSeason ? ' vd-rcard--active' : '';
var img = art ? '<img class="vd-rcard-img" src="' + art + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">' : '';
return '<button class="vd-rcard' + on + '" type="button" data-vd-season="' + s.season_number + '">' +
'<div class="vd-rcard-art">' + img + '<div class="vd-rcard-fb">📺</div>' +
'<div class="vd-rcard-grad"></div><div class="vd-rcard-pct">' + p + '%</div></div>' +
'<div class="vd-rcard-info"><span class="vd-rcard-name">' + esc(s.title) + '</span>' +
'<span class="vd-rcard-sub">' + s.episode_owned + ' / ' + s.episode_total + ' eps</span>' +
'<span class="vd-rcard-bar"><span style="width:' + p + '%"></span></span></div></button>';
}).join('') + '</div>';
}
function timelineHTML() {
var total = data.seasons.reduce(function (a, s) { return a + Math.max(1, s.episode_total); }, 0) || 1;
return '<div class="vd-timeline">' + 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 '<button class="vd-tseg' + on + '" type="button" data-vd-season="' + s.season_number + '" ' +
'style="flex:' + grow + ' 1 0">' +
'<span class="vd-tseg-fill" style="width:' + p + '%"></span>' +
'<span class="vd-tseg-label"><span class="vd-tseg-name">' + esc(s.title) + '</span>' +
'<span class="vd-tseg-meta">' + s.episode_owned + '/' + s.episode_total + '</span></span></button>';
}).join('') + '</div>';
}
function pillsHTML() {
return '<div class="vd-pills">' + data.seasons.map(function (s) {
var on = s.season_number === selectedSeason ? ' vd-pill-btn--active' : '';
return '<button class="vd-pill-btn' + on + '" type="button" data-vd-season="' + s.season_number + '">' +
esc(s.title) + '<span class="vd-pill-meta">' + s.episode_owned + '/' + s.episode_total + '</span></button>';
}).join('') + '</div>';
}
function dropdownHTML() {
var cur = seasonByNum(selectedSeason);
host.innerHTML =
return '<div class="vd-season-select">' +
'<button class="vd-ss-btn" type="button" data-vd-ss-toggle>' +
'<span>' + esc(cur ? cur.title : 'Season') + '</span><span class="vd-ss-caret">▾</span></button>' +
'<div class="vd-ss-menu' + (menuOpen ? ' vd-ss-menu--open' : '') + '">' +
data.seasons.map(function (s) {
var on = s.season_number === selectedSeason ? ' vd-ss-opt--active' : '';
return '<button class="vd-ss-opt' + on + '" type="button" data-vd-ss-pick="' + s.season_number + '">' +
return '<button class="vd-ss-opt' + on + '" type="button" data-vd-season="' + s.season_number + '">' +
esc(s.title) + '<span class="vd-ss-opt-meta">' + s.episode_owned + '/' + s.episode_total + '</span></button>';
}).join('') + '</div>';
}).join('') + '</div></div>';
}
// ── 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 '<div class="vd-ep ' + owned + '">' +
'<div class="vd-ep-index">' + (ep.episode_number != null ? ep.episode_number : '') + '</div>' +
'<div class="vd-ep-thumb"><span class="vd-ep-thumb-ic">▶</span></div>' +
'<div class="vd-ep-info">' +
'<div class="vd-ep-top"><span class="vd-ep-title">' +
'<div class="vd-ep-info"><div class="vd-ep-top"><span class="vd-ep-title">' +
esc(ep.title || 'Episode ' + ep.episode_number) + '</span>' +
(meta.length ? '<span class="vd-ep-rt">' + esc(meta.join(' · ')) + '</span>' : '') + '</div>' +
(ep.overview ? '<p class="vd-ep-desc">' + esc(ep.overview) + '</p>' : '') +
'</div>' +
'<div class="vd-ep-badge">' + (ep.owned ? 'Owned' : 'Missing') + '</div>' +
'</div>';
(ep.overview ? '<p class="vd-ep-desc">' + esc(ep.overview) + '</p>' : '') + '</div>' +
'<div class="vd-ep-badge">' + (ep.owned ? 'Owned' : 'Missing') + '</div></div>';
}
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('')
: '<div class="vd-ep-empty">No ' + (missingOnly ? 'missing ' : '') + 'episodes here. 🎉</div>';
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();
})();

View file

@ -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;