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:
parent
5b6ef295f8
commit
8e00670491
8 changed files with 574 additions and 1 deletions
|
|
@ -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
62
api/video/calendar.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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…</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">📺</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
|
||||
|
|
|
|||
290
webui/static/video/video-calendar.js
Normal file
290
webui/static/video/video-calendar.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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%; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue