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;