From 7c1644384dfe7cce18962900a9e0c075b76550d8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 23:57:14 -0700 Subject: [PATCH] Calendar: time-aware 'Next up' (up to 3, diagonal panels) + auto-scroll to now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero used to lock onto the first episode of the week and never move. Now: - 'Next up' is time-of-day aware on the current week: it shows the soonest episodes that haven't finished airing yet (90-min grace so a show that's on right now stays up; streaming/undated shows count as 'anytime today' so they don't expire at midnight), advancing as the day goes. Once the week has fully aired it falls back to the most recent. - When 2-3 are upcoming, the hero splits into diagonal clip-path panels — the soonest leads, hovering any panel expands it full-width and collapses the others (secondary panels tease the title; sub + actions fade in on expand). Single upcoming = the original billboard. Stacks vertically on mobile. - Opening the calendar (or hitting Today) now auto-scrolls to the band the wall-clock is in, so you land on 'now' instead of pre-dawn. Calendar-only; music side untouched. --- webui/static/video/video-calendar.js | 83 ++++++++++++++++++++++------ webui/static/video/video-side.css | 36 ++++++++++++ 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/webui/static/video/video-calendar.js b/webui/static/video/video-calendar.js index c36b3ea9..4248a61c 100644 --- a/webui/static/video/video-calendar.js +++ b/webui/static/video/video-calendar.js @@ -195,16 +195,28 @@ } // ── 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; + // Air datetime in ms. No airs_time → treat as "anytime today" (end of day) + // so streaming/undated shows stay featured all day instead of expiring at 00:00. + function epDT(ep) { + var base = parseISO(ep.air_date).getTime(); + var mins = airMins(ep.airs_time); + return base + (mins != null ? mins * 60000 : (23 * 60 + 59) * 60000); + } + // Up to 3 "next up" episodes, soonest first. On the CURRENT week this is + // time-of-day aware: the next episodes that haven't finished airing yet + // (90-min grace so a show that's on right now stays up), advancing as the + // day goes. Once the whole week has aired it falls back to the most recent. + // Other weeks just feature the first few of the week. + function featuredList(d) { + var eps = (d.episodes || []).slice().sort(function (a, b) { return epDT(a) - epDT(b); }); + if (!eps.length) return []; + if (state.offset === 0) { + var now = new Date().getTime(), GRACE = 90 * 60000; + var up = eps.filter(function (ep) { return epDT(ep) + GRACE >= now; }); + var pool = up.length ? up : eps.slice().reverse(); + return pool.slice(0, 3); + } + return eps.slice(0, 3); } function whenLabel(ep, today) { var mins = airMins(ep.airs_time); @@ -213,17 +225,16 @@ : 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; } + // One billboard panel (used solo, or as one slice of the diagonal multi-hero) + function heroPanel(ep, d, opts) { + var multi = opts && opts.multi, lead = opts && opts.lead; 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 = - '
' + + var cls = multi ? ('vcal-bb-panel' + (lead ? ' vcal-bb-panel--lead' : '')) : 'vcal-bb'; + return '
' + (bg ? '
' : '') + '
' + '
' + @@ -235,6 +246,16 @@ '
' + '
'; } + function renderHero(d) { + var host = $('[data-video-cal-hero]'); if (!host) return; + var list = featuredList(d); + if (!list.length) { host.innerHTML = ''; return; } + if (list.length === 1) { host.innerHTML = heroPanel(list[0], d, { multi: false }); return; } + // 2-3 episodes → diagonal split panels; the soonest leads, hovering any + // expands it full-width and collapses the rest. + var panels = list.map(function (ep, i) { return heroPanel(ep, d, { multi: true, lead: i === 0 }); }).join(''); + host.innerHTML = '
' + panels + '
'; + } function filterEps(eps) { if (state.filter === 'owned') return eps.filter(function (e) { return e.has_file; }); @@ -271,10 +292,29 @@ setTimeout(function () { cols.classList.remove('vcal-animate-in'); }, 900); } } else { if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; } + if (has && state.scrollToNow) scrollToNow(); + state.scrollToNow = false; state.fresh = false; var grid = $('[data-video-cal-grid]'); if (grid) grid.classList.remove('vcal-fading'); setSub(view); } + // Scroll the band the wall-clock is currently in into view, so opening the + // calendar lands on "now" instead of pre-dawn. Current week only. + function scrollToNow() { + if (state.offset !== 0) return; + var cols = $('[data-video-cal-cols]'); if (!cols) return; + var now = new Date(); + var nowKey = bandKeyFor(now.getHours() * 60 + now.getMinutes()); + var idx = 0; for (var k = 0; k < BANDS.length; k++) if (BANDS[k].key === nowKey) { idx = k; break; } + var smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + requestAnimationFrame(function () { + var el = cols.querySelector('.vcal-slot--now') || cols.querySelector('.vcal-rail--' + nowKey); + // now band has nothing all week → nearest rendered band, scanning down then up + for (var i = idx; i < BANDS.length && !el; i++) el = cols.querySelector('.vcal-rail--' + BANDS[i].key); + for (var j = idx - 1; j >= 0 && !el; j--) el = cols.querySelector('.vcal-rail--' + BANDS[j].key); + if (el && el.scrollIntoView) el.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' }); + }); + } function load() { state.loaded = true; state.fresh = true; // Crossfade the grid out while the next week loads (only when we already @@ -400,7 +440,10 @@ var prev = $('[data-video-cal-prev]'); if (prev) prev.addEventListener('click', function () { state.offset--; load(); }); var next = $('[data-video-cal-next]'); if (next) next.addEventListener('click', function () { state.offset++; load(); }); var today = $('[data-video-cal-today]'); - if (today) today.addEventListener('click', function () { if (state.offset !== 0) { state.offset = 0; load(); } }); + if (today) today.addEventListener('click', function () { + state.scrollToNow = true; + if (state.offset !== 0) { state.offset = 0; load(); } else scrollToNow(); + }); var fbs = document.querySelectorAll('[data-video-cal-filter]'); for (var i = 0; i < fbs.length; i++) (function (b) { b.addEventListener('click', function () { state.filter = b.getAttribute('data-video-cal-filter'); render(); }); @@ -544,7 +587,11 @@ .catch(function () { /* modal already shows payload data */ }); } - function onPageShown(e) { if (e && e.detail === PAGE_ID && !state.loaded) load(); } + function onPageShown(e) { + if (!e || e.detail !== PAGE_ID) return; + if (!state.loaded) { state.scrollToNow = true; load(); } + else scrollToNow(); // already loaded → just re-land on "now" + } function init() { wire(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 0f11cee5..05ff07f5 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1750,9 +1750,45 @@ body[data-side="video"] #soulsync-toggle { display: none; } .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; } } + +/* "Next up" with 2-3 episodes → diagonal split panels; hover one to expand it */ +.vcal-bb-multi { position: relative; display: flex; min-height: 306px; border-radius: 22px; overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); box-shadow: 0 34px 90px rgba(0, 0, 0, 0.55); background: #08080c; } +.vcal-bb-panel { --vcal-h: 230; position: relative; flex: 1 1 0; flex-grow: 1; min-width: 0; overflow: hidden; + display: flex; align-items: flex-end; cursor: pointer; + clip-path: polygon(34px 0, 100% 0, calc(100% - 34px) 100%, 0 100%); margin-left: -34px; + transition: flex-grow 0.55s cubic-bezier(0.22, 1, 0.36, 1); } +/* outer edges of the strip stay straight; only the interior seams are diagonal */ +.vcal-bb-panel:first-child { clip-path: polygon(0 0, 100% 0, calc(100% - 34px) 100%, 0 100%); margin-left: 0; } +.vcal-bb-panel:last-child { clip-path: polygon(34px 0, 100% 0, 100% 100%, 0 100%); } +/* soonest leads at rest; hovering any panel expands it and collapses the rest */ +.vcal-bb-panel--lead { flex-grow: 2.3; } +.vcal-bb-multi:hover .vcal-bb-panel { flex-grow: 0.16; } +.vcal-bb-multi .vcal-bb-panel:hover { flex-grow: 9; } +.vcal-bb-panel::after { content: ''; position: absolute; inset: 0; background: rgba(0, 0, 0, 0.32); + opacity: 0; transition: opacity 0.4s ease; pointer-events: none; } +.vcal-bb-multi:hover .vcal-bb-panel:not(:hover)::after { opacity: 1; } +.vcal-bb-panel .vcal-bb-content { padding: 28px 30px 30px 50px; max-width: 540px; } +.vcal-bb-panel .vcal-bb-title { font-size: 28px; } +/* secondary panels tease just the title; sub + actions fade in on expand */ +.vcal-bb-panel .vcal-bb-sub, .vcal-bb-panel .vcal-bb-actions { opacity: 0; transition: opacity 0.3s ease; } +.vcal-bb-panel--lead .vcal-bb-sub, .vcal-bb-panel--lead .vcal-bb-actions { opacity: 1; } +.vcal-bb-multi:hover .vcal-bb-panel--lead .vcal-bb-sub, +.vcal-bb-multi:hover .vcal-bb-panel--lead .vcal-bb-actions { opacity: 0; } +.vcal-bb-multi .vcal-bb-panel:hover .vcal-bb-sub, +.vcal-bb-multi .vcal-bb-panel:hover .vcal-bb-actions { opacity: 1; } +.vcal-bb-panel:hover .vcal-bb-btn { transform: translateY(-2px); } +@media (prefers-reduced-motion: reduce) { .vcal-bb-panel { transition: 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; } + /* stack the diagonal panels vertically on narrow screens */ + .vcal-bb-multi { flex-direction: column; min-height: 0; } + .vcal-bb-panel, .vcal-bb-panel:first-child, .vcal-bb-panel:last-child { clip-path: none; margin-left: 0; min-height: 150px; } + .vcal-bb-panel--lead, .vcal-bb-multi:hover .vcal-bb-panel, .vcal-bb-multi .vcal-bb-panel:hover { flex-grow: 1; } + .vcal-bb-panel .vcal-bb-sub, .vcal-bb-panel .vcal-bb-actions { opacity: 1; } + .vcal-bb-multi:hover .vcal-bb-panel:not(:hover)::after { opacity: 0; } } /* ── Calendar — header controls (week nav + filter) ───────────────────────── */