Video Calendar: 7-day week grid with air times + episode modal

A new isolated Calendar page (/api/video/calendar) — every upcoming episode for
your owned shows across a real 7-day week (today first), as art cards sorted by
air time with a per-cell breathing colour glow.

- Air times: enrich shows with TVDB airsTime (new shows.airs_time column +
  migration); cells show + sort by time, streaming (untimed/00:00) = "Anytime".
  One-time background backfill re-queues already-matched shows for the time.
- Click an episode → styled modal (show backdrop hero, episode still/synopsis,
  air date+time, owned badge, genres, "about the show"), with an explicit
  "Open full show page" action instead of navigating on click.
- Isolated: reads only video_library.db, writes nothing to the music side.
This commit is contained in:
BoulderBadgeDad 2026-06-15 18:28:02 -07:00
parent 5b6ef295f8
commit 8e00670491
8 changed files with 574 additions and 1 deletions

View file

@ -45,6 +45,7 @@ def create_video_blueprint() -> Blueprint:
from .enrichment import register_routes as reg_enrichment
from .detail import register_routes as reg_detail
from .search import register_routes as reg_search
from .calendar import register_routes as reg_calendar
reg_dashboard(bp)
reg_scan(bp)
reg_library(bp)
@ -53,5 +54,6 @@ def create_video_blueprint() -> Blueprint:
reg_enrichment(bp)
reg_detail(bp)
reg_search(bp)
reg_calendar(bp)
return bp

62
api/video/calendar.py Normal file
View file

@ -0,0 +1,62 @@
"""Video Calendar — upcoming TV episodes for OWNED shows.
GET /api/video/calendar?days=N episodes airing from today through today+N-1,
grouped client-side into the agenda view. Isolated: reads only video_library.db
via VideoDatabase, writes nothing, never touches the music side.
"""
from __future__ import annotations
from datetime import date, timedelta
from flask import jsonify, request
from utils.logging_config import get_logger
logger = get_logger("video.calendar")
def register_routes(bp):
@bp.route("/calendar", methods=["GET"])
def video_calendar():
from . import get_video_db
try:
days = request.args.get("days", default=7, type=int) or 7
days = max(1, min(days, 90)) # clamp: today .. +90d
start = date.today()
end = start + timedelta(days=days - 1)
db = get_video_db()
# One-time backfill: existing shows matched TVDB before air time was
# captured, so re-queue them once (background, only those missing it).
try:
if (db.get_setting("airs_time_backfill") or "") != "1":
n = db.requeue_shows_for_airtime()
db.set_setting("airs_time_backfill", "1")
if n:
logger.info("calendar: queued %d shows for TVDB air-time backfill", n)
except Exception:
logger.exception("airs_time backfill queue failed")
eps = db.calendar_upcoming(start.isoformat(), end.isoformat())
# Per-date counts drive the day-strip dots without a second query.
counts: dict[str, int] = {}
owned = 0
for e in eps:
counts[e["air_date"]] = counts.get(e["air_date"], 0) + 1
if e.get("has_file"):
owned += 1
return jsonify({
"today": start.isoformat(),
"start": start.isoformat(),
"end": end.isoformat(),
"days": days,
"counts_by_date": counts,
"total": len(eps),
"owned": owned,
"episodes": eps,
})
except Exception:
logger.exception("video calendar failed")
return jsonify({"error": "calendar failed"}), 500

View file

@ -658,6 +658,9 @@ class TVDBClient:
gs = [g.get("name") for g in (sd.get("genres") or []) if g.get("name")]
if gs:
meta["genres"] = gs
# Show-level air time (network local time, e.g. "21:00") — drives
# the Calendar's per-day time sort. Streaming shows have none.
meta["airs_time"] = (sd.get("airsTime") or "").strip() or None
except Exception:
logger.exception("TVDB details fetch failed for %s", title or tvdb_id)
if tvdb_id is None:

View file

@ -66,7 +66,7 @@ _ENRICH_META_COLS = {
"runtime_minutes", "studio", "tagline", "rating", "rating_critic",
"imdb_id", "tmdb_id"},
"shows": {"overview", "backdrop_url", "logo_url", "status", "network", "content_rating",
"tagline", "rating", "first_air_date", "last_air_date",
"tagline", "rating", "first_air_date", "last_air_date", "airs_time",
"imdb_id", "tmdb_id", "tvdb_id"},
}
@ -97,6 +97,7 @@ _COLUMN_MIGRATIONS = [
("shows", "metacritic", "INTEGER"),
("movies", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
("shows", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
("shows", "airs_time", "TEXT"), # TVDB show air time, e.g. "21:00" (network local)
]
@ -627,6 +628,20 @@ class VideoDatabase:
finally:
conn.close()
def requeue_shows_for_airtime(self) -> int:
"""One-time backfill: re-queue TVDB enrichment for shows that have a
tvdb_id but no air time yet, so the worker re-fetches `airsTime`. Only
touches shows missing the time idempotent, fills in the background."""
conn = self._get_connection()
try:
cur = conn.execute(
"UPDATE shows SET tvdb_match_status=NULL, tvdb_last_attempted=NULL "
"WHERE tvdb_id IS NOT NULL AND (airs_time IS NULL OR airs_time='')")
conn.commit()
return cur.rowcount
finally:
conn.close()
@property
def schema_version(self) -> int:
conn = self._get_connection()
@ -1008,6 +1023,31 @@ class VideoDatabase:
finally:
conn.close()
def calendar_upcoming(self, start_date: str, end_date: str) -> list[dict]:
"""Episodes airing in [start_date, end_date] (ISO) for OWNED shows — the
Calendar feed. Owned = the show is on a media server (server_source set),
so we surface upcoming episodes for things the user actually follows. Each
row carries owned/missing (has_file), a still flag, and show network/year
for the card. Ordered by air date then show then episode."""
conn = self._get_connection()
try:
rows = conn.execute(
"SELECT e.id, e.show_id, e.season_number, e.episode_number, e.title, "
"e.overview, e.air_date, e.runtime_minutes, e.rating, e.has_file, e.monitored, "
"(e.still_url IS NOT NULL AND e.still_url<>'') AS has_still, "
"s.title AS show_title, s.network, s.airs_time, s.year AS show_year, s.status AS show_status, "
"(s.poster_url IS NOT NULL AND s.poster_url<>'') AS show_has_poster, "
"(s.backdrop_url IS NOT NULL AND s.backdrop_url<>'') AS show_has_backdrop "
"FROM episodes e JOIN shows s ON s.id = e.show_id "
"WHERE s.server_source IS NOT NULL "
"AND e.air_date IS NOT NULL AND e.air_date >= ? AND e.air_date <= ? "
"ORDER BY e.air_date, COALESCE(s.sort_title, s.title) COLLATE NOCASE, "
"e.season_number, e.episode_number",
(start_date, end_date)).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def get_poster_ref(self, kind: str, item_id: int) -> dict | None:
"""Server source/id/poster path for one movie or show, for the poster proxy."""
return self.get_art_ref(kind, item_id, "poster")

View file

@ -120,6 +120,7 @@ CREATE TABLE IF NOT EXISTS shows (
overview TEXT,
status TEXT, -- continuing | ended | upcoming
network TEXT,
airs_time TEXT, -- TVDB show air time, e.g. "21:00" (network local)
runtime_minutes INTEGER,
content_rating TEXT,
tagline TEXT,

View file

@ -862,6 +862,32 @@
</div>
</div>
</section>
<!-- Calendar — upcoming TV episodes for owned shows (agenda + day
strip). Built by video/video-calendar.js, styled by .vcal-* in
video-side.css. -->
<section class="video-subpage" data-video-subpage="video-calendar" hidden>
<div class="vcal-page">
<div class="vcal-head">
<div class="vcal-head-glow" aria-hidden="true"></div>
<h1 class="vcal-title">This Week</h1>
<p class="vcal-sub" data-video-cal-sub>Upcoming episodes from your shows</p>
</div>
<div class="vcal-body">
<div class="vcal-loading hidden" data-video-cal-loading>
<div class="loading-spinner"></div>
<div class="loading-text">Loading calendar&hellip;</div>
</div>
<div class="vcal-grid" data-video-cal-grid>
<div class="vcal-cols" data-video-cal-cols></div>
</div>
<div class="vcal-state hidden" data-video-cal-empty>
<div class="vcal-state-ic">&#128250;</div>
<div class="vcal-state-title">Nothing scheduled this week</div>
<div class="vcal-state-sub">No upcoming episodes for your shows in the next 7 days.</div>
</div>
</div>
</div>
</section>
<!-- TV-show detail (drill-in from a show card; not a nav page).
Isolated: built by video/video-detail.js, styled by .vd-* in
video-side.css. Inspired by the music artist-detail vibe. -->
@ -9470,6 +9496,7 @@
<script src="{{ url_for('static', filename='video/video-detail.js', v=static_v) }}"></script>
<!-- Video search (isolated; in-app multi-search → library/tmdb detail) -->
<script src="{{ url_for('static', filename='video/video-search.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='video/video-calendar.js', v=static_v) }}"></script>
<!-- Video person page (isolated; cast/crew drill-in → filmography) -->
<script src="{{ url_for('static', filename='video/video-person.js', v=static_v) }}"></script>
<!-- Video worker orbs (isolated copy of the music orbs; idles into the

View file

@ -0,0 +1,290 @@
/*
* SoulSync Video Calendar (isolated): the Week Grid.
*
* A real 7-column week (TODAY first). Every upcoming episode for your owned
* shows is shown no "+N more" each with its air time, sorted earliest
* latest per day (untimed streaming drops sink to the bottom). The "vibe" lives
* ON the grid: each cell softly breathes a glow in its show's colour. Cells open
* the show detail. Self-contained IIFE, fetches only /api/video/calendar.
*/
(function () {
'use strict';
var PAGE_ID = 'video-calendar';
var URL = '/api/video/calendar?days=7';
var COLS = 7;
var WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var WD_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var MO_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
'September', 'October', 'November', 'December'];
var state = { loaded: false, eps: {} };
function $(s) { return document.querySelector(s); }
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function parseISO(s) { var p = (s || '').split('-'); return new Date(+p[0], (+p[1] || 1) - 1, +p[2] || 1); }
function isoOf(d) {
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
}
function showHue(title) {
var h = 0, s = title || '';
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
// TVDB air time → minutes-from-midnight (null if absent). Handles "21:00",
// "21:00:00", "9:00 PM", "8:00pm".
function airMins(s) {
if (!s) return null;
s = String(s).trim();
var m = s.match(/^(\d{1,2}):(\d{2})/);
if (!m) return null;
var h = +m[1], mi = +m[2];
if (/pm/i.test(s) && h < 12) h += 12;
if (/am/i.test(s) && h === 12) h = 0;
if (h > 23 || mi > 59) return null;
if (h === 0 && mi === 0) return null; // 00:00 = TVDB streaming placeholder, not a real slot
return h * 60 + mi;
}
function fmtMins(mins) {
if (mins == null) return '';
var h = (mins / 60) | 0, mi = mins % 60, ap = h >= 12 ? 'PM' : 'AM', hh = h % 12 || 12;
return hh + ':' + ('0' + mi).slice(-2) + ' ' + ap;
}
function showLoading(on) { var el = $('[data-video-cal-loading]'); if (el) el.classList.toggle('hidden', !on); }
function showEmpty(on) {
var el = $('[data-video-cal-empty]'); if (el) el.classList.toggle('hidden', !on);
var g = $('[data-video-cal-grid]'); if (g) g.classList.toggle('hidden', !!on);
}
function setSub(d) {
var el = $('[data-video-cal-sub]'); if (!el) return;
var a = parseISO(d.start), b = parseISO(d.end);
var range = MO[a.getMonth()] + ' ' + a.getDate() + ' ' + MO[b.getMonth()] + ' ' + b.getDate();
var n = d.total || 0;
el.textContent = range + (n ? ' · ' + n + (n === 1 ? ' episode' : ' episodes') : ' · nothing scheduled');
}
function epCell(ep, idx) {
var hue = showHue(ep.show_title || '');
var art = ep.has_still ? ('/api/video/poster/episode/' + ep.id)
: (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id) : '');
var img = art ? '<img class="vcal-cell-img" src="' + art + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">' : '';
var se = 'S' + ep.season_number + ' · E' + ep.episode_number;
var epTitle = ep.title || ''; // no redundant "Episode N" when untitled
var tl = fmtMins(airMins(ep.airs_time));
var time = tl
? '<span class="vcal-time"><span class="vcal-time-dot"></span>' + tl + '</span>'
: '<span class="vcal-time vcal-time--none">Anytime</span>';
var flag = ep.has_file ? '<span class="vcal-flag" title="In your library">✓</span>' : '';
var meta = '<span class="vcal-se">' + se + '</span>' + (epTitle ? '<span class="vcal-ep">' + esc(epTitle) + '</span>' : '');
return '<a class="vcal-cell" style="--vcal-h:' + hue + ';--i:' + (idx % 24) + '" ' +
'href="/video-detail/library/show/' + ep.show_id + '" ' +
'data-cal-ep="' + ep.id + '" ' +
'title="' + esc(ep.show_title) + (epTitle ? ' — ' + esc(epTitle) : '') + (tl ? ' · ' + tl : '') + '">' +
'<span class="vcal-glow" aria-hidden="true"></span>' +
'<span class="vcal-card">' +
'<span class="vcal-art">' + img + '<span class="vcal-art-scrim"></span>' + flag + '</span>' +
'<span class="vcal-info">' +
time +
'<span class="vcal-show">' + esc(ep.show_title) + '</span>' +
'<span class="vcal-meta">' + meta + '</span>' +
'</span>' +
'</span>' +
'</a>';
}
function renderGrid(d) {
var cols = $('[data-video-cal-cols]'); if (!cols) return;
var byDate = {};
(d.episodes || []).forEach(function (ep) { (byDate[ep.air_date] = byDate[ep.air_date] || []).push(ep); });
var start = parseISO(d.start);
var html = '';
for (var i = 0; i < COLS; i++) {
var dt = new Date(start); dt.setDate(start.getDate() + i);
var eps = (byDate[isoOf(dt)] || []).slice();
eps.sort(function (a, b) {
var ma = airMins(a.airs_time), mb = airMins(b.airs_time);
if (ma == null && mb == null) return 0;
if (ma == null) return 1; if (mb == null) return -1;
return ma - mb;
});
var today = isoOf(dt) === d.today;
var cells = eps.length
? eps.map(epCell).join('')
: '<div class="vcal-col-empty">No episodes</div>';
html += '<section class="vcal-col' + (today ? ' vcal-col--today' : '') + '">' +
'<header class="vcal-col-head">' +
'<span class="vcal-col-wd">' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '</span>' +
'<span class="vcal-col-date">' + WD[dt.getDay()] + ' ' + dt.getDate() + '</span>' +
(eps.length ? '<span class="vcal-col-n">' + eps.length + '</span>' : '') +
'</header>' +
'<div class="vcal-col-stack">' + cells + '</div>' +
'</section>';
}
cols.innerHTML = html;
}
function load() {
state.loaded = true;
showEmpty(false); showLoading(true);
fetch(URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (!d || d.error) { showEmpty(true); return; }
if (!(d.episodes && d.episodes.length)) { showEmpty(true); return; }
state.eps = {};
d.episodes.forEach(function (e) { state.eps[e.id] = e; });
renderGrid(d); setSub(d);
})
.catch(function () { showLoading(false); showEmpty(true); });
}
function wire() {
var cols = $('[data-video-cal-cols]');
if (cols) cols.addEventListener('click', function (e) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var card = e.target.closest('[data-cal-ep]');
if (!card || !cols.contains(card)) return;
e.preventDefault();
var ep = state.eps[card.getAttribute('data-cal-ep')];
if (ep) openModal(ep);
});
}
// ── episode modal ─────────────────────────────────────────────────────────
var modalEl = null, modalKeyHandler = null;
function fmtFullDate(iso) { var d = parseISO(iso); return WD_FULL[d.getDay()] + ', ' + MO_FULL[d.getMonth()] + ' ' + d.getDate(); }
function closeModal() {
if (!modalEl) return;
modalEl.classList.remove('vcm-open');
document.body.style.removeProperty('overflow');
if (modalKeyHandler) { document.removeEventListener('keydown', modalKeyHandler); modalKeyHandler = null; }
var el = modalEl; modalEl = null;
setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 220);
}
function openModal(ep) {
closeModal();
var hue = showHue(ep.show_title || '');
var backdrop = ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id) : '';
var still = ep.has_still ? ('/api/video/poster/episode/' + ep.id)
: (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id) : '');
var se = 'S' + ep.season_number + ' · E' + ep.episode_number;
var epTitle = ep.title || ('Episode ' + ep.episode_number);
var tl = fmtMins(airMins(ep.airs_time));
var when = fmtFullDate(ep.air_date) + ' · ' + (tl || 'Anytime');
var owned = ep.has_file
? '<span class="vcm-badge vcm-badge--have">✓ In your library</span>'
: '<span class="vcm-badge vcm-badge--miss">Not in library</span>';
var tags = [owned];
if (ep.runtime_minutes) tags.push('<span class="vcm-tag">' + ep.runtime_minutes + ' min</span>');
if (ep.rating) tags.push('<span class="vcm-tag vcm-tag--star">★ ' + (Math.round(ep.rating * 10) / 10) + '</span>');
var eyebrow = [ep.network, ep.show_year, ep.show_status].filter(Boolean).map(esc).join(' · ');
var ov = document.createElement('div');
ov.className = 'vcm-overlay'; ov.setAttribute('data-vcm', '');
ov.style.setProperty('--vcm-h', hue);
ov.innerHTML =
'<div class="vcm-modal" role="dialog" aria-modal="true">' +
'<button class="vcm-close" type="button" data-vcm-close aria-label="Close">×</button>' +
'<div class="vcm-hero">' +
(backdrop ? '<div class="vcm-hero-bg" style="background-image:url(\'' + backdrop + '\')"></div>' : '') +
'<div class="vcm-hero-scrim"></div>' +
'<div class="vcm-hero-content">' +
'<div class="vcm-eyebrow" data-vcm-eyebrow>' + eyebrow + '</div>' +
'<h2 class="vcm-show">' + esc(ep.show_title) + '</h2>' +
'<div class="vcm-genres" data-vcm-genres></div>' +
'</div>' +
'</div>' +
'<div class="vcm-body">' +
'<div class="vcm-ep">' +
'<div class="vcm-ep-still">' +
(still ? '<img src="' + still + '" alt="" onerror="this.style.display=\'none\'">' : '') +
'<span class="vcm-ep-fb">▶</span></div>' +
'<div class="vcm-ep-main">' +
'<div class="vcm-ep-se">' + se + '</div>' +
'<h3 class="vcm-ep-title">' + esc(epTitle) + '</h3>' +
'<div class="vcm-when">' + esc(when) + '</div>' +
'<div class="vcm-tags">' + tags.join('') + '</div>' +
(ep.overview ? '<p class="vcm-ep-ov">' + esc(ep.overview) + '</p>'
: '<p class="vcm-ep-ov vcm-ep-ov--none">No episode synopsis yet.</p>') +
'</div>' +
'</div>' +
'<div class="vcm-about" data-vcm-about hidden>' +
'<h4 class="vcm-about-h">About the show</h4>' +
'<p class="vcm-about-ov" data-vcm-show-ov></p>' +
'</div>' +
'</div>' +
'<div class="vcm-actions">' +
'<button class="vcm-btn vcm-btn--ghost" type="button" data-vcm-close>Close</button>' +
'<button class="vcm-btn vcm-btn--primary" type="button" data-vcm-open>Open full show page →</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
document.body.style.overflow = 'hidden';
modalEl = ov;
requestAnimationFrame(function () { ov.classList.add('vcm-open'); });
ov.addEventListener('click', function (e) {
if (e.target === ov || e.target.closest('[data-vcm-close]')) { closeModal(); return; }
if (e.target.closest('[data-vcm-open]')) {
closeModal();
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: 'show', id: ep.show_id, source: 'library' },
}));
}
});
modalKeyHandler = function (e) { if (e.key === 'Escape') closeModal(); };
document.addEventListener('keydown', modalKeyHandler);
enrichModal(ep);
}
// Lazy-fill show overview + genres + a fuller eyebrow from the show detail.
function enrichModal(ep) {
var sid = ep.show_id;
fetch('/api/video/detail/show/' + sid, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !modalEl) return;
var g = modalEl.querySelector('[data-vcm-genres]');
if (g && d.genres && d.genres.length) {
g.innerHTML = d.genres.slice(0, 4).map(function (x) {
return '<span class="vcm-genre">' + esc(x) + '</span>';
}).join('');
}
var eb = modalEl.querySelector('[data-vcm-eyebrow]');
if (eb) {
var parts = [d.network || ep.network, d.year || ep.show_year, d.status || ep.show_status,
d.content_rating].filter(Boolean).map(esc);
if (parts.length) eb.textContent = parts.join(' · ');
}
var about = modalEl.querySelector('[data-vcm-about]');
var ovEl = modalEl.querySelector('[data-vcm-show-ov]');
if (about && ovEl && d.overview && d.overview !== ep.overview) {
ovEl.textContent = d.overview;
about.hidden = false;
}
})
.catch(function () { /* modal already shows payload data */ });
}
function onPageShown(e) { if (e && e.detail === PAGE_ID && !state.loaded) load(); }
function init() {
wire();
document.addEventListener('soulsync:video-page-shown', onPageShown);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -1514,3 +1514,151 @@ a.vd-guest:hover .vd-guest-name { color: rgb(var(--vd-accent-rgb)); }
is untouched). The "Video Source" panel governs which video server is used. */
body[data-side="video"] #navidrome-toggle,
body[data-side="video"] #soulsync-toggle { display: none; }
/* ── Calendar — the Week Grid (every episode, air times, breathing cells) ──── */
.vcal-page { --vcal-accent: var(--accent-rgb, 88, 101, 242); color: var(--text-primary, #fff); padding: 0 0 70px; }
.vcal-head { position: relative; padding: 30px 40px 18px; overflow: hidden; }
.vcal-head-glow {
position: absolute; top: -190px; left: 50%; transform: translateX(-50%);
width: 860px; height: 390px; pointer-events: none; z-index: 0;
background: radial-gradient(56% 70% at 50% 0%, rgba(var(--vcal-accent), 0.26), transparent 70%); filter: blur(10px);
}
.vcal-title { position: relative; z-index: 1; font-size: 36px; font-weight: 800; letter-spacing: -0.025em; margin: 0; }
.vcal-sub { position: relative; z-index: 1; margin: 7px 0 0; color: rgba(255, 255, 255, 0.55); font-size: 14px; font-weight: 600; }
.vcal-body { padding: 4px 40px 0; }
.vcal-loading { padding: 90px 0; text-align: center; }
.vcal-grid { border-radius: 20px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, #0d0d12, #0a0a0e); box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45); }
.vcal-grid.hidden { display: none; }
.vcal-cols { display: grid; grid-template-columns: repeat(7, minmax(150px, 1fr)); align-items: start; }
.vcal-col { border-left: 1px solid rgba(255, 255, 255, 0.06); min-width: 0; }
.vcal-col:first-child { border-left: 0; }
.vcal-col--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.10), rgba(var(--vcal-accent), 0.02) 30%, transparent 60%); }
/* Day header — sticky */
.vcal-col-head { position: sticky; top: 0; z-index: 4; padding: 15px 15px 13px;
background: linear-gradient(180deg, rgba(15, 15, 20, 0.96), rgba(13, 13, 18, 0.85));
backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.vcal-col-wd { display: block; font-size: 14px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
.vcal-col-date { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.4); margin-top: 2px; }
.vcal-col-n { position: absolute; top: 14px; right: 14px; font-size: 11px; font-weight: 800; color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.08); padding: 3px 9px; border-radius: 999px; }
.vcal-col--today .vcal-col-wd { color: hsl(232, 100%, 82%); }
.vcal-col--today .vcal-col-head { border-bottom-color: rgba(var(--vcal-accent), 0.5);
box-shadow: 0 1px 0 rgba(var(--vcal-accent), 0.5), 0 10px 26px -12px rgba(var(--vcal-accent), 0.5); }
.vcal-col--today .vcal-col-n { background: rgba(var(--vcal-accent), 0.88); color: #fff; }
.vcal-col-stack { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 20px; }
.vcal-col-empty { padding: 26px 8px; text-align: center; font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.22); }
/* Episode cell — one clean card (art + info), breathing colour halo behind it */
.vcal-cell { --vcal-show: var(--vcal-h, 230); position: relative; display: block; text-decoration: none; color: inherit;
transition: transform 0.2s ease; }
.vcal-glow { position: absolute; inset: -11px; z-index: 0; border-radius: 22px; pointer-events: none;
background: radial-gradient(closest-side, hsla(var(--vcal-show), 90%, 55%, 0.55), transparent 76%);
opacity: 0.18; animation: vcalBreathe 6.5s ease-in-out infinite; animation-delay: calc(var(--i) * -0.43s); }
@keyframes vcalBreathe { 0%, 100% { opacity: 0.12; } 50% { opacity: 0.42; } }
.vcal-card { position: relative; z-index: 1; display: block; border-radius: 14px; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08); background: #14141a;
transition: border-color 0.2s ease, box-shadow 0.2s ease; }
.vcal-art { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden;
background: linear-gradient(135deg, hsl(var(--vcal-show), 48%, 26%), #0b0b0f 88%); }
.vcal-cell-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vcal-art-scrim { position: absolute; inset: 0; background: linear-gradient(0deg, rgba(0, 0, 0, 0.45), transparent 55%); }
.vcal-flag { position: absolute; top: 8px; right: 8px; z-index: 2; width: 22px; height: 22px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 900;
background: rgba(108, 211, 145, 0.94); color: #07130c; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); }
.vcal-info { display: block; padding: 9px 11px 11px; }
.vcal-time { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 800; letter-spacing: 0.01em;
color: hsl(var(--vcal-show), 85%, 80%); }
.vcal-time-dot { width: 6px; height: 6px; border-radius: 50%; background: hsl(var(--vcal-show), 85%, 62%);
box-shadow: 0 0 8px hsl(var(--vcal-show), 85%, 60%); }
.vcal-time--none { color: rgba(255, 255, 255, 0.32); font-weight: 700; }
.vcal-show { display: block; font-size: 13.5px; font-weight: 800; line-height: 1.25; margin-top: 5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vcal-meta { display: block; font-size: 11.5px; color: rgba(255, 255, 255, 0.5); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vcal-se { font-weight: 800; color: rgba(255, 255, 255, 0.72); }
.vcal-ep { margin-left: 6px; }
.vcal-cell:hover { transform: translateY(-4px); }
.vcal-cell:hover .vcal-glow { opacity: 0.6 !important; animation-play-state: paused; }
.vcal-cell:hover .vcal-card { border-color: hsla(var(--vcal-show), 78%, 60%, 0.6);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45), 0 0 22px hsla(var(--vcal-show), 75%, 55%, 0.25); }
@media (prefers-reduced-motion: reduce) { .vcal-glow { animation: none; opacity: 0.2; } }
/* Empty state */
.vcal-state { text-align: center; padding: 90px 20px; color: rgba(255, 255, 255, 0.6); }
.vcal-state-ic { font-size: 48px; opacity: 0.55; }
.vcal-state-title { font-size: 20px; font-weight: 800; color: #fff; margin-top: 12px; }
.vcal-state-sub { font-size: 14px; margin-top: 6px; }
@media (max-width: 900px) { .vcal-head, .vcal-body { padding-left: 18px; padding-right: 18px; } .vcal-cols { overflow-x: auto; } }
/* ── Calendar — episode modal ─────────────────────────────────────────────── */
.vcm-overlay { position: fixed; inset: 0; z-index: 9000; display: flex; align-items: center; justify-content: center;
padding: 24px; background: rgba(5, 5, 8, 0.72); backdrop-filter: blur(9px); opacity: 0; transition: opacity 0.2s ease; }
.vcm-overlay.vcm-open { opacity: 1; }
.vcm-modal { --vcm-h: 230; position: relative; width: min(700px, 100%); max-height: 90vh; overflow-y: auto;
border-radius: 22px; background: #101015; border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 50px 130px rgba(0, 0, 0, 0.72); transform: translateY(16px) scale(0.985);
transition: transform 0.26s cubic-bezier(0.2, 0.7, 0.2, 1); }
.vcm-overlay.vcm-open .vcm-modal { transform: none; }
.vcm-modal::-webkit-scrollbar { width: 9px; }
.vcm-modal::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 5px; }
.vcm-close { position: absolute; top: 14px; right: 14px; z-index: 3; width: 36px; height: 36px; border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.18); background: rgba(0, 0, 0, 0.45); color: #fff; font-size: 22px; cursor: pointer;
backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; line-height: 1; transition: all 0.15s ease; }
.vcm-close:hover { background: rgba(0, 0, 0, 0.72); border-color: rgba(255, 255, 255, 0.4); }
.vcm-hero { position: relative; min-height: 210px; border-radius: 22px 22px 0 0; overflow: hidden; display: flex; align-items: flex-end; }
.vcm-hero-bg { position: absolute; inset: 0; background-size: cover; background-position: center 28%; transform: scale(1.05); }
.vcm-hero-scrim { position: absolute; inset: 0;
background: linear-gradient(0deg, #101015 3%, rgba(16, 16, 21, 0.45) 52%, rgba(16, 16, 21, 0.15)),
radial-gradient(130% 110% at 0% 100%, hsla(var(--vcm-h), 72%, 45%, 0.38), transparent 60%); }
.vcm-hero-content { position: relative; z-index: 1; padding: 24px 26px 18px; width: 100%; }
.vcm-eyebrow { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(var(--vcm-h), 82%, 80%); }
.vcm-eyebrow:empty { display: none; }
.vcm-show { font-size: 27px; font-weight: 800; letter-spacing: -0.02em; margin: 6px 0 0; line-height: 1.1; }
.vcm-genres { display: flex; gap: 7px; margin-top: 11px; flex-wrap: wrap; }
.vcm-genre { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.82); }
.vcm-body { padding: 20px 26px; }
.vcm-ep { display: grid; grid-template-columns: 176px 1fr; gap: 20px; }
.vcm-ep-still { position: relative; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden;
background: linear-gradient(135deg, hsl(var(--vcm-h), 48%, 28%), #0b0b0f); }
.vcm-ep-still img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vcm-ep-fb { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 24px; color: rgba(255, 255, 255, 0.3); }
.vcm-ep-main { min-width: 0; }
.vcm-ep-se { font-size: 12px; font-weight: 800; color: hsl(var(--vcm-h), 82%, 78%); letter-spacing: 0.03em; }
.vcm-ep-title { font-size: 20px; font-weight: 800; margin: 3px 0 0; line-height: 1.2; }
.vcm-when { font-size: 13px; color: rgba(255, 255, 255, 0.62); margin-top: 8px; font-weight: 600; }
.vcm-tags { display: flex; gap: 8px; margin-top: 13px; flex-wrap: wrap; align-items: center; }
.vcm-badge { font-size: 11px; font-weight: 800; padding: 4px 11px; border-radius: 999px; }
.vcm-badge--have { background: rgba(108, 211, 145, 0.16); color: #6cd391; }
.vcm-badge--miss { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.55); }
.vcm-tag { font-size: 11.5px; font-weight: 700; color: rgba(255, 255, 255, 0.62); }
.vcm-tag--star { color: rgba(245, 197, 24, 0.95); }
.vcm-ep-ov { font-size: 13.5px; line-height: 1.6; color: rgba(255, 255, 255, 0.8); margin: 15px 0 0; }
.vcm-ep-ov--none { color: rgba(255, 255, 255, 0.35); font-style: italic; }
.vcm-about { margin-top: 22px; padding-top: 18px; border-top: 1px solid rgba(255, 255, 255, 0.07); }
.vcm-about-h { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.5); margin: 0 0 8px; }
.vcm-about-ov { font-size: 13.5px; line-height: 1.6; color: rgba(255, 255, 255, 0.72); margin: 0; }
.vcm-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 4px 26px 24px; }
.vcm-btn { font-size: 13px; font-weight: 800; padding: 11px 18px; border-radius: 12px; cursor: pointer; border: 1px solid transparent; transition: all 0.15s ease; }
.vcm-btn--ghost { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.82); }
.vcm-btn--ghost:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.vcm-btn--primary { background: hsl(var(--vcm-h), 68%, 52%); color: #fff; box-shadow: 0 8px 24px hsla(var(--vcm-h), 70%, 45%, 0.4); }
.vcm-btn--primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
@media (max-width: 560px) {
.vcm-ep { grid-template-columns: 1fr; }
.vcm-ep-still { max-width: 240px; }
.vcm-actions { flex-direction: column-reverse; }
.vcm-btn { width: 100%; }
}