/*
* SoulSync — Video Calendar (isolated): the Week Grid.
*
* A real 7-column week (TODAY first). Every upcoming episode for your owned
* shows is shown — no "+N more" — each with its air time, sorted earliest →
* latest per day (untimed streaming drops sink to the bottom). The "vibe" lives
* ON the grid: each cell softly breathes a glow in its show's colour. Cells open
* the show detail. Self-contained IIFE, fetches only /api/video/calendar.
*/
(function () {
'use strict';
var PAGE_ID = 'video-calendar';
var URL = '/api/video/calendar';
var COLS = 7;
var WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var WD_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var MO_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
'September', 'October', 'November', 'December'];
var state = { loaded: false, eps: {}, data: null, offset: 0, filter: 'all', view: 'compact', scope: 'watchlist' };
function $(s) { return document.querySelector(s); }
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>');
}
function parseISO(s) { var p = (s || '').split('-'); return new Date(+p[0], (+p[1] || 1) - 1, +p[2] || 1); }
function isoOf(d) {
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
}
function showHue(title) {
var h = 0, s = title || '';
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
// TVDB air time → minutes-from-midnight (null if absent). Handles "21:00",
// "21:00:00", "9:00 PM", "8:00pm".
function airMins(s) {
if (!s) return null;
s = String(s).trim();
var m = s.match(/^(\d{1,2}):(\d{2})/);
if (!m) return null;
var h = +m[1], mi = +m[2];
if (/pm/i.test(s) && h < 12) h += 12;
if (/am/i.test(s) && h === 12) h = 0;
if (h > 23 || mi > 59) return null;
if (h === 0 && mi === 0) return null; // 00:00 = TVDB streaming placeholder, not a real slot
return h * 60 + mi;
}
function fmtMins(mins) {
if (mins == null) return '';
var h = (mins / 60) | 0, mi = mins % 60, ap = h >= 12 ? 'PM' : 'AM', hh = h % 12 || 12;
return hh + ':' + ('0' + mi).slice(-2) + ' ' + ap;
}
function showLoading(on) { var el = $('[data-video-cal-loading]'); if (el) el.classList.toggle('hidden', !on); }
function showEmpty(on) {
var el = $('[data-video-cal-empty]'); if (el) el.classList.toggle('hidden', !on);
var g = $('[data-video-cal-grid]'); if (g) g.classList.toggle('hidden', !!on);
}
function setSub(d) {
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 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) {
var hue = showHue(ep.show_title || '');
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));
var time = tl
? '' + tl + ''
: 'Anytime';
var flag = ep.has_file ? '✓' : '';
var meta = '' + se + '' + (epTitle ? '' + esc(epTitle) + '' : '');
return '' +
'' +
'' +
'' + img + '' + flag + '' +
'' +
time +
'' + esc(ep.show_title) + '' +
'' + meta + '' +
'' +
'' +
'';
}
// 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 start = parseISO(d.start);
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 = days[i], today = isoOf(dt) === d.today;
html += '
';
}
// 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;
}
// ── featured "next up" billboard ──────────────────────────────────────────
// 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);
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) : '');
}
// 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' : '';
var cls = multi ? ('vcal-bb-panel' + (lead ? ' vcal-bb-panel--lead' : '')) : 'vcal-bb';
return '