diff --git a/webui/static/video/video-calendar.js b/webui/static/video/video-calendar.js
index 6b327932..57567634 100644
--- a/webui/static/video/video-calendar.js
+++ b/webui/static/video/video-calendar.js
@@ -62,8 +62,16 @@
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');
+ var eps = d.episodes || [], n = eps.length;
+ var owned = 0; for (var i = 0; i < n; i++) if (eps[i].has_file) owned++;
+ var miss = n - owned;
+ var parts = [range];
+ if (n) {
+ parts.push(n + (n === 1 ? ' episode' : ' episodes'));
+ if (owned) parts.push(owned + ' in library');
+ if (miss) parts.push(miss + ' missing');
+ } else { parts.push('nothing scheduled'); }
+ el.textContent = parts.join(' · ');
}
function epCell(ep, idx) {
@@ -96,35 +104,93 @@
'';
}
+ // Time-of-day bands — the rows of the guide. A real shared time axis so
+ // you can scan ACROSS the week ("what's on in Prime Time") instead of
+ // reading seven disconnected stacks. Untimed streaming drops land in
+ // "Anytime". Each card still carries its exact time.
+ var BANDS = [
+ { key: 'morning', label: 'Morning', range: '5a–12p', lo: 300, hi: 720 },
+ { key: 'afternoon', label: 'Afternoon', range: '12–5p', lo: 720, hi: 1020 },
+ { key: 'prime', label: 'Prime Time', range: '5–9p', lo: 1020, hi: 1260 },
+ { key: 'late', label: 'Late Night', range: '9p–5a', lo: 1260, hi: 1740 }, // wraps past midnight
+ { key: 'anytime', label: 'Anytime', range: 'Streaming', lo: null, hi: null }
+ ];
+ function bandKeyFor(mins) {
+ if (mins == null) return 'anytime';
+ // Late night wraps: 21:00–04:59 (treat early-AM as +24h for the range test).
+ var m = mins < 300 ? mins + 1440 : mins;
+ for (var i = 0; i < BANDS.length; i++) {
+ var b = BANDS[i];
+ if (b.lo == null) continue;
+ if (m >= b.lo && m < b.hi) return b.key;
+ }
+ return 'anytime';
+ }
+
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 = '';
+ var days = [];
+ for (var i = 0; i < COLS; i++) { var dt = new Date(start); dt.setDate(start.getDate() + i); days.push(dt); }
+
+ // grid[bandKey][dayIndex] = [episodes]
+ var grid = {};
+ BANDS.forEach(function (b) { grid[b.key] = []; for (var i = 0; i < COLS; i++) grid[b.key].push([]); });
+ var dayCount = [];
+ for (var i = 0; i < COLS; i++) dayCount.push(0);
+ (d.episodes || []).forEach(function (ep) {
+ for (var i = 0; i < COLS; i++) {
+ if (ep.air_date === isoOf(days[i])) {
+ grid[bandKeyFor(airMins(ep.airs_time))][i].push(ep);
+ dayCount[i]++;
+ break;
+ }
+ }
+ });
+ var byTime = 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;
+ };
+
+ // header row: corner + 7 day heads
+ 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('')
- : 'No episodes
';
- html += '' +
- '' +
- '' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '' +
- '' + WD[dt.getDay()] + ' ' + dt.getDate() + '' +
- (eps.length ? '' + eps.length + '' : '') +
- '' +
- '' + cells + '
' +
- '';
+ var dt = days[i], today = isoOf(dt) === d.today;
+ html += '' +
+ '' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '' +
+ '' + WD[dt.getDay()] + ' ' + dt.getDate() + '' +
+ (dayCount[i] ? '' + dayCount[i] + '' : '') +
+ '
';
}
+
+ // one row per active band (skip bands empty across the whole week)
+ var stagger = 0;
+ // "Now" cue — the band the wall-clock is currently in. Only the today
+ // column carries it (other weeks have no today column), so it lights up
+ // the live time-of-day intersection like a TV guide.
+ var nowD = new Date();
+ var nowBand = bandKeyFor(nowD.getHours() * 60 + nowD.getMinutes());
+ BANDS.forEach(function (b) {
+ var anyEps = false;
+ for (var i = 0; i < COLS; i++) if (grid[b.key][i].length) { anyEps = true; break; }
+ if (!anyEps) return;
+ html += '' +
+ '' + b.label + '' +
+ '' + b.range + '
';
+ for (var i = 0; i < COLS; i++) {
+ var cell = grid[b.key][i].slice().sort(byTime);
+ var today = isoOf(days[i]) === d.today;
+ var isNow = today && b.key === nowBand;
+ var inner = cell.length
+ ? cell.map(function (ep) { return epCell(ep, stagger++); }).join('')
+ : '';
+ html += '' + inner + '
';
+ }
+ });
cols.innerHTML = html;
}
@@ -196,12 +262,24 @@
var view = { episodes: eps, total: eps.length, today: d.today, start: d.start, end: d.end, days: d.days };
var has = eps.length > 0;
showEmpty(!has);
- if (has) { renderHero(view); renderGrid(view); }
- else { if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; }
+ if (has) {
+ renderHero(view); renderGrid(view);
+ // Card entrance only on a fresh load/week-change — NOT on filter
+ // re-renders (those would re-stagger every click and feel busy).
+ if (state.fresh && cols) {
+ cols.classList.add('vcal-animate-in');
+ setTimeout(function () { cols.classList.remove('vcal-animate-in'); }, 900);
+ }
+ } else { if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; }
+ state.fresh = false;
+ var grid = $('[data-video-cal-grid]'); if (grid) grid.classList.remove('vcal-fading');
setSub(view);
}
function load() {
- state.loaded = true;
+ state.loaded = true; state.fresh = true;
+ // Crossfade the grid out while the next week loads (only when we already
+ // have something on screen — first load uses the entrance instead).
+ var grid = $('[data-video-cal-grid]'); if (grid && state.data) grid.classList.add('vcal-fading');
showEmpty(false); showLoading(true);
var base = new Date(); base.setHours(0, 0, 0, 0); base.setDate(base.getDate() + state.offset * 7);
fetch(URL + '?days=7&start=' + isoOf(base), { headers: { 'Accept': 'application/json' } })
@@ -266,6 +344,20 @@
for (var i = 0; i < fbs.length; i++) (function (b) {
b.addEventListener('click', function () { state.filter = b.getAttribute('data-video-cal-filter'); render(); });
})(fbs[i]);
+
+ // Keyboard nav for a page users live in: ← / → step weeks, T jumps to
+ // today. Only when the calendar is the visible page, no modal is open,
+ // and the user isn't typing in a field.
+ document.addEventListener('keydown', function (e) {
+ if (modalEl || e.metaKey || e.ctrlKey || e.altKey) return;
+ var tag = (e.target && e.target.tagName) || '';
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (e.target && e.target.isContentEditable)) return;
+ var g = $('[data-video-cal-cols]');
+ if (!g || g.offsetParent === null) return; // calendar page not visible
+ if (e.key === 'ArrowLeft') { e.preventDefault(); state.offset--; load(); }
+ else if (e.key === 'ArrowRight') { e.preventDefault(); state.offset++; load(); }
+ else if (e.key === 't' || e.key === 'T') { if (state.offset !== 0) { e.preventDefault(); state.offset = 0; load(); } }
+ });
}
// ── episode modal ─────────────────────────────────────────────────────────
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index 278ec164..a43f5a8f 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -1532,27 +1532,65 @@ 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(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%); }
+/* EPG guide — a frozen time-rail (rows = time-of-day) × 7 day columns with a
+ frozen header row, so the whole week reads on ONE shared time axis. */
+.vcal-cols { display: grid; grid-template-columns: 84px repeat(7, minmax(150px, 1fr)); align-items: stretch; }
-/* 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));
+/* sticky top-left corner */
+.vcal-guide-corner { position: sticky; top: 0; left: 0; z-index: 6;
+ background: linear-gradient(180deg, rgba(15, 15, 20, 0.97), rgba(13, 13, 18, 0.88));
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); }
+/* day header — sticky top */
+.vcal-dayhead { position: sticky; top: 0; z-index: 4; padding: 15px 14px 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);
+ border-left: 1px solid rgba(255, 255, 255, 0.05); }
+.vcal-dayhead-wd { display: block; font-size: 14px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
+.vcal-dayhead-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-dayhead-n { position: absolute; top: 14px; right: 13px; 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-dayhead--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.24), rgba(13, 13, 18, 0.86));
+ border-bottom-color: rgba(var(--vcal-accent), 0.55);
+ box-shadow: 0 1px 0 rgba(var(--vcal-accent), 0.55), 0 12px 28px -14px rgba(var(--vcal-accent), 0.6); }
+.vcal-dayhead--today .vcal-dayhead-wd { color: hsl(232, 100%, 84%); }
+.vcal-dayhead--today .vcal-dayhead-n { background: rgba(var(--vcal-accent), 0.9); color: #fff; }
+
+/* time rail — sticky left, labels the band */
+.vcal-rail { position: sticky; left: 0; z-index: 2; display: flex; flex-direction: column; gap: 2px;
+ padding: 17px 12px; border-top: 1px solid rgba(255, 255, 255, 0.05);
+ background: linear-gradient(90deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.004)); }
+.vcal-rail-label { font-size: 12.5px; font-weight: 800; letter-spacing: -0.01em; color: rgba(255, 255, 255, 0.8); }
+.vcal-rail small { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.3); }
+.vcal-rail--prime .vcal-rail-label { color: hsl(44, 100%, 72%); }
+.vcal-rail--prime small { color: hsla(44, 90%, 60%, 0.6); }
+
+/* one day×band cell */
+.vcal-slot { display: flex; flex-direction: column; gap: 15px; padding: 17px 13px; min-width: 0;
+ border-top: 1px solid rgba(255, 255, 255, 0.05); border-left: 1px solid rgba(255, 255, 255, 0.05); }
+.vcal-slot--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.07), rgba(var(--vcal-accent), 0.015)); }
+.vcal-slot--empty { align-items: center; justify-content: center; padding: 0; }
+.vcal-slot-dot { width: 4px; height: 4px; border-radius: 50%; background: rgba(255, 255, 255, 0.07); }
+/* live "now" intersection — today column × current time-of-day band */
+.vcal-slot--now { box-shadow: inset 3px 0 0 rgba(var(--vcal-accent), 0.95), inset 0 0 34px -12px rgba(var(--vcal-accent), 0.5);
+ background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.12), rgba(var(--vcal-accent), 0.03)); }
+
+/* staggered entrance — only on a fresh load/week-change (class added by JS),
+ keyed off the per-card --i (cycles every 24). Filter re-renders don't get it. */
+.vcal-cols.vcal-animate-in .vcal-card { animation: vcalCardIn 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
+ animation-delay: calc(var(--i, 0) * 0.022s); }
+@keyframes vcalCardIn { from { opacity: 0; transform: translateY(10px) scale(0.985); } to { opacity: 1; transform: none; } }
+@media (prefers-reduced-motion: reduce) { .vcal-cols.vcal-animate-in .vcal-card { animation: none; } }
+
+/* week-change crossfade */
+.vcal-grid { transition: opacity 0.22s ease; }
+.vcal-grid.vcal-fading { opacity: 0.32; }
+
+/* keyboard focus — mirrors hover + a clear ring (cards are real s) */
+.vcal-cell:focus-visible { outline: none; --ty: -6px; --sc: 1.04; z-index: 3; }
+.vcal-cell:focus-visible .vcal-card { border-color: hsla(var(--vcal-show), 80%, 64%, 0.85);
+ box-shadow: 0 0 0 2px hsla(var(--vcal-show), 82%, 62%, 0.9), 0 18px 40px rgba(0, 0, 0, 0.5); }
/* 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;