Calendar: time-aware 'Next up' (up to 3, diagonal panels) + auto-scroll to now

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.
This commit is contained in:
BoulderBadgeDad 2026-06-16 23:57:14 -07:00
parent f383c82beb
commit 7c1644384d
2 changed files with 101 additions and 18 deletions

View file

@ -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 ? '<span class="vcal-bb-badge">✓ In your library</span>' : '';
host.innerHTML =
'<div class="vcal-bb" style="--vcal-h:' + hue + '" data-cal-ep="' + ep.id + '" role="button" tabindex="0">' +
var cls = multi ? ('vcal-bb-panel' + (lead ? ' vcal-bb-panel--lead' : '')) : 'vcal-bb';
return '<div class="' + cls + '" style="--vcal-h:' + hue + '" data-cal-ep="' + ep.id + '" role="button" tabindex="0">' +
(bg ? '<div class="vcal-bb-bg" style="background-image:url(\'' + bg + '\')"></div>' : '') +
'<div class="vcal-bb-scrim"></div>' +
'<div class="vcal-bb-content">' +
@ -235,6 +246,16 @@
'</div>' +
'</div>';
}
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 = '<div class="vcal-bb-multi" data-count="' + list.length + '">' + panels + '</div>';
}
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();

View file

@ -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) ───────────────────────── */