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:
parent
20f1214b68
commit
c450fa1f9a
7 changed files with 298 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue