From 5856eefd7c3afaa7b7a2ed7d656412c959e6d086 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 19:25:45 -0700 Subject: [PATCH] Video Calendar: featured billboard, tactile cards, faster art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take the calendar visuals up a level + fix slow image loads. - Featured "Next up" billboard: the soonest upcoming episode as a cinematic hero (backdrop, pulsing accent, show title, S·E, air time, "View details" → modal). - Cards: cursor-following 3D tilt + hover lift/scale/glow bloom; ambient glow dialed way down so it's calm at rest and only blooms on interaction. - Faster art: poster proxy takes ?w= and asks the source for a thumbnail (Plex transcoder w/ original fallback, Jellyfin maxWidth, TMDB size bucket) — calendar requests ~500px instead of full backdrops. Skeleton shimmer → fade-in on load. --- api/video/poster.py | 48 ++++++++++++-- webui/index.html | 1 + webui/static/video/video-calendar.js | 99 ++++++++++++++++++++++++---- webui/static/video/video-side.css | 82 +++++++++++++++++++---- 4 files changed, 199 insertions(+), 31 deletions(-) diff --git a/api/video/poster.py b/api/video/poster.py index cc2ef0da..02e8a3cc 100644 --- a/api/video/poster.py +++ b/api/video/poster.py @@ -7,13 +7,34 @@ browser). Falls back to 404 so the frontend shows its placeholder. from __future__ import annotations -from flask import Response, abort +from flask import Response, abort, request from utils.logging_config import get_logger logger = get_logger("video_api.poster") +def _req_width(): + """Optional ?w= thumbnail width (clamped) so the calendar/library don't pull + full-size art into tiny cells. None = original.""" + try: + w = request.args.get("w", type=int) + except Exception: + w = None + if not w: + return None + return max(48, min(1600, w)) + + +def _tmdb_resize(url, w, backdrop): + """Rewrite a TMDB image URL's size segment (/t/p//...) to a bucket near + ``w`` so episode stills load small. Best-effort — unchanged if it doesn't match.""" + import re + buckets = [300, 780, 1280] if backdrop else [185, 342, 500, 780] + pick = next((b for b in buckets if w <= b), buckets[-1]) + return re.sub(r"/t/p/[^/]+/", "/t/p/w%d/" % pick, url, count=1) + + def register_routes(bp): def _stream_art(kind, item_id, art): from . import get_video_db @@ -22,11 +43,14 @@ def register_routes(bp): abort(404) try: import requests - from config.settings import config_manager + w = _req_width() + backdrop = art == "backdrop" path = ref["poster_url"] # Enrichment can store a full external URL (e.g. a TMDB season poster) — # stream it directly; otherwise it's a server path needing the token. if path.startswith("http://") or path.startswith("https://"): + if w and "image.tmdb.org" in path: + path = _tmdb_resize(path, w, backdrop) upstream = requests.get(path, timeout=15, stream=True) if upstream.status_code != 200: abort(404) @@ -38,25 +62,41 @@ def register_routes(bp): # creds, or inherited from music) — that's where the item was scanned. from core.video.sources import video_plex_config, video_jellyfin_config source = ref.get("server_source") + fallback = None if source == "plex": cfg = video_plex_config() base, token = cfg.get("base_url"), cfg.get("token") if not base or not token: abort(404) - url = base.rstrip("/") + ref["poster_url"] + base = base.rstrip("/") params = {"X-Plex-Token": token} + if w: + # Plex photo transcoder → a right-sized JPEG instead of full art; + # fall back to the original if a server has transcoding disabled. + from urllib.parse import quote + h = int(w * (9 / 16 if backdrop else 3 / 2)) + url = (base + "/photo/:/transcode?width=%d&height=%d&minSize=1&upscale=1&url=%s" + % (w, h, quote(ref["poster_url"], safe=""))) + fallback = base + ref["poster_url"] + else: + url = base + ref["poster_url"] elif source == "jellyfin": cfg = video_jellyfin_config() base, key = cfg.get("base_url"), cfg.get("api_key") if not base: abort(404) - image = "Backdrop" if art == "backdrop" else "Primary" + image = "Backdrop" if backdrop else "Primary" url = base.rstrip("/") + f"/Items/{ref['server_id']}/Images/{image}" params = {"api_key": key} if key else {} + if w: + params["maxWidth"] = w + params["quality"] = 90 else: abort(404) upstream = requests.get(url, params=params, timeout=15, stream=True) + if upstream.status_code != 200 and fallback: + upstream = requests.get(fallback, params=params, timeout=15, stream=True) if upstream.status_code != 200: abort(404) ctype = upstream.headers.get("Content-Type", "image/jpeg") diff --git a/webui/index.html b/webui/index.html index 3ce0d9e5..2d2688b4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -877,6 +877,7 @@
Loading calendar…
+
diff --git a/webui/static/video/video-calendar.js b/webui/static/video/video-calendar.js index 35425d63..5a976a74 100644 --- a/webui/static/video/video-calendar.js +++ b/webui/static/video/video-calendar.js @@ -68,9 +68,10 @@ 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 ? '' : ''; + var art = ep.has_still ? ('/api/video/poster/episode/' + ep.id + '?w=500') + : (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=500') : ''); + var img = art ? '' : ''; 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)); @@ -127,6 +128,47 @@ cols.innerHTML = html; } + // ── featured "next up" billboard ────────────────────────────────────────── + function featured(d) { + var eps = (d.episodes || []).slice(); + eps.sort(function (a, b) { + if (a.air_date !== b.air_date) return a.air_date < b.air_date ? -1 : 1; + 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; + }); + return eps[0] || null; + } + function whenLabel(ep, today) { + var mins = airMins(ep.airs_time); + var diff = Math.round((parseISO(ep.air_date) - parseISO(today)) / 86400000); + var day = diff === 0 ? ((mins != null && mins >= 17 * 60) ? 'Tonight' : 'Today') + : diff === 1 ? 'Tomorrow' : WD_FULL[parseISO(ep.air_date).getDay()]; + return day + (mins != null ? ', ' + fmtMins(mins) : ''); + } + function renderHero(d) { + var host = $('[data-video-cal-hero]'); if (!host) return; + var ep = featured(d); + if (!ep) { host.innerHTML = ''; return; } + var hue = showHue(ep.show_title || ''); + var bg = ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=1280') : ''; + var se = 'S' + ep.season_number + ' · E' + ep.episode_number; + var epTitle = ep.title || ''; + var owned = ep.has_file ? '✓ In your library' : ''; + host.innerHTML = + '
' + + (bg ? '
' : '') + + '
' + + '
' + + '
NEXT UP · ' + esc(whenLabel(ep, d.today)) + '
' + + '

' + esc(ep.show_title) + '

' + + '
' + se + '' + (epTitle ? ' · ' + esc(epTitle) : '') + '
' + + '
View details' + owned + '
' + + '
' + + '
'; + } + function load() { state.loaded = true; showEmpty(false); showLoading(true); @@ -138,21 +180,51 @@ 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); + renderHero(d); renderGrid(d); setSub(d); }) .catch(function () { showLoading(false); showEmpty(true); }); } + function openFrom(target) { + var el = target.closest('[data-cal-ep]'); + if (!el) return false; + var ep = state.eps[el.getAttribute('data-cal-ep')]; + if (ep) openModal(ep); + return true; + } + // Cursor-following 3D tilt (delegated, one element at a time). + function wireTilt(container, sel, deg) { + if (!container) return; + var last = null; + container.addEventListener('mousemove', function (e) { + var c = e.target.closest(sel); + if (c !== last && last) { last.style.removeProperty('--rx'); last.style.removeProperty('--ry'); } + last = c; + if (!c) return; + var r = c.getBoundingClientRect(); + var px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5; + c.style.setProperty('--ry', (px * deg).toFixed(2) + 'deg'); + c.style.setProperty('--rx', (-py * deg).toFixed(2) + 'deg'); + }); + container.addEventListener('mouseleave', function () { + if (last) { last.style.removeProperty('--rx'); last.style.removeProperty('--ry'); last = null; } + }); + } 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); + if (e.target.closest('[data-cal-ep]') && cols.contains(e.target)) { e.preventDefault(); openFrom(e.target); } }); + var hero = $('[data-video-cal-hero]'); + if (hero) { + hero.addEventListener('click', function (e) { if (openFrom(e.target)) e.preventDefault(); }); + hero.addEventListener('keydown', function (e) { + if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('[data-cal-ep]')) { e.preventDefault(); openFrom(e.target); } + }); + wireTilt(hero, '.vcal-bb', 3); + } + wireTilt(cols, '.vcal-cell', 7); } // ── episode modal ───────────────────────────────────────────────────────── @@ -171,9 +243,9 @@ 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 backdrop = ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=1000') : ''; + var still = ep.has_still ? ('/api/video/poster/episode/' + ep.id + '?w=600') + : (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=600') : ''); 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)); @@ -204,7 +276,8 @@ '
' + '
' + '
' + - (still ? '' : '') + + (still ? '' : '') + '
' + '
' + '
' + se + '
' + diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 97f64184..8ec7243e 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1532,7 +1532,7 @@ body[data-side="video"] #soulsync-toggle { display: none; } .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-cols { display: grid; grid-template-columns: repeat(7, minmax(168px, 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%); } @@ -1556,19 +1556,31 @@ body[data-side="video"] #soulsync-toggle { display: none; } /* 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; } + transform: perspective(760px) translateY(var(--ty, 0px)) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--sc, 1)); + transition: transform 0.16s ease; } +/* Calm by default; the colour only comes alive on hover. */ .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; } } + background: radial-gradient(closest-side, hsla(var(--vcal-show), 90%, 55%, 0.6), transparent 76%); + opacity: 0.06; animation: vcalBreathe 7s ease-in-out infinite; animation-delay: calc(var(--i) * -0.5s); + transition: opacity 0.25s ease; } +@keyframes vcalBreathe { 0%, 100% { opacity: 0.04; } 50% { opacity: 0.13; } } .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%; + background: linear-gradient(135deg, hsl(var(--vcal-show), 42%, 22%), #0a0a0e 90%); } +/* skeleton shimmer shown until the image fades in */ +.vcal-art::before { content: ''; position: absolute; inset: 0; z-index: 0; + background: linear-gradient(100deg, transparent 16%, rgba(255, 255, 255, 0.05) 38%, + rgba(255, 255, 255, 0.10) 50%, rgba(255, 255, 255, 0.05) 62%, transparent 84%); + background-size: 220% 100%; animation: vcalShimmer 1.5s ease-in-out infinite; } +.vcal-cell-img { position: relative; z-index: 1; width: 100%; height: 100%; object-fit: cover; display: block; + opacity: 0; transition: opacity 0.5s ease; } +.vcal-cell-img.vcal-loaded { opacity: 1; } +@keyframes vcalShimmer { 0% { background-position: 200% 0; } 100% { background-position: -130% 0; } } +@media (prefers-reduced-motion: reduce) { .vcal-art::before { animation: none; } .vcal-cell-img { transition: none; } } +.vcal-art-scrim { position: absolute; inset: 0; z-index: 2; background: linear-gradient(0deg, rgba(0, 0, 0, 0.45), transparent 55%); } +.vcal-flag { position: absolute; top: 8px; right: 8px; z-index: 3; 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; } @@ -1583,12 +1595,15 @@ body[data-side="video"] #soulsync-toggle { display: none; } 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 { --ty: -6px; --sc: 1.04; z-index: 3; } .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); } +.vcal-cell:hover .vcal-card { border-color: hsla(var(--vcal-show), 78%, 62%, 0.7); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.55), 0 0 30px hsla(var(--vcal-show), 78%, 55%, 0.34); } -@media (prefers-reduced-motion: reduce) { .vcal-glow { animation: none; opacity: 0.2; } } +@media (prefers-reduced-motion: reduce) { + .vcal-glow { animation: none; opacity: 0.08; } + .vcal-cell { transform: none; } .vcal-cell:hover { --ty: 0px; --sc: 1; } +} /* Empty state */ .vcal-state { text-align: center; padding: 90px 20px; color: rgba(255, 255, 255, 0.6); } @@ -1630,7 +1645,9 @@ body[data-side="video"] #soulsync-toggle { display: none; } .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-still img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; + opacity: 0; transition: opacity 0.45s ease; } +.vcm-ep-still img.vcm-loaded { opacity: 1; } .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; } @@ -1662,3 +1679,40 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vcm-actions { flex-direction: column-reverse; } .vcm-btn { width: 100%; } } + +/* ── Calendar — featured "next up" billboard ──────────────────────────────── */ +.vcal-hero-wrap { padding: 4px 40px 8px; perspective: 1400px; } +.vcal-hero-wrap:empty { display: none; } +.vcal-bb { --vcal-h: 230; position: relative; display: flex; align-items: flex-end; min-height: 306px; + border-radius: 22px; overflow: hidden; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 34px 90px rgba(0, 0, 0, 0.55); + transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--sc, 1)); + transition: transform 0.18s ease, box-shadow 0.3s ease; will-change: transform; } +.vcal-bb:hover { --sc: 1.012; box-shadow: 0 44px 110px rgba(0, 0, 0, 0.62), 0 0 60px hsla(var(--vcal-h), 72%, 50%, 0.22); } +.vcal-bb-bg { position: absolute; inset: 0; background-size: cover; background-position: center 22%; transform: scale(1.08); } +.vcal-bb-scrim { position: absolute; inset: 0; + background: linear-gradient(90deg, rgba(8, 8, 12, 0.94) 0%, rgba(8, 8, 12, 0.62) 42%, transparent 78%), + linear-gradient(0deg, rgba(8, 8, 12, 0.82), transparent 58%), + radial-gradient(120% 130% at 0% 100%, hsla(var(--vcal-h), 72%, 45%, 0.42), transparent 58%); } +.vcal-bb-content { position: relative; z-index: 1; padding: 34px 42px 34px; max-width: 640px; } +.vcal-bb-eyebrow { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 800; + letter-spacing: 0.09em; text-transform: uppercase; color: hsl(var(--vcal-h), 88%, 80%); } +.vcal-bb-dot { width: 7px; height: 7px; border-radius: 50%; background: hsl(var(--vcal-h), 88%, 62%); + box-shadow: 0 0 12px hsl(var(--vcal-h), 88%, 60%); animation: vcalPulse 2s ease-in-out infinite; } +@keyframes vcalPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.45; transform: scale(0.6); } } +.vcal-bb-title { font-size: 42px; font-weight: 900; letter-spacing: -0.03em; line-height: 1.02; margin: 11px 0 0; + text-shadow: 0 2px 20px rgba(0, 0, 0, 0.6); } +.vcal-bb-sub { font-size: 15px; color: rgba(255, 255, 255, 0.82); margin-top: 9px; font-weight: 600; } +.vcal-bb-se { font-weight: 800; color: #fff; } +.vcal-bb-actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; } +.vcal-bb-btn { display: inline-flex; align-items: center; font-size: 13.5px; font-weight: 800; padding: 12px 22px; + border-radius: 12px; background: #fff; color: #0a0a0e; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + transition: transform 0.15s ease; } +.vcal-bb:hover .vcal-bb-btn { transform: translateY(-2px); } +.vcal-bb-badge { font-size: 12px; font-weight: 800; padding: 6px 13px; border-radius: 999px; + background: rgba(108, 211, 145, 0.18); color: #6cd391; } +@media (prefers-reduced-motion: reduce) { .vcal-bb { transform: none; } .vcal-bb-dot { animation: none; } } +@media (max-width: 900px) { + .vcal-hero-wrap { padding-left: 18px; padding-right: 18px; } + .vcal-bb-title { font-size: 30px; } .vcal-bb { min-height: 240px; } +}