soulsync/webui/static/video/video-calendar.js
BoulderBadgeDad 49222dd0b8 video calendar: watchlist-driven by default + an 'All library' toggle
The calendar pulled every airing show in the library regardless of whether you
follow it. Now it's scoped to the EFFECTIVE watchlist by default — explicit show
follows ∪ airing library shows (not muted), same logic as the Shows watchlist tab —
so it tracks what you actually care about, and you can mute a show off it. A
'Watchlist / All library' toggle on the calendar lets you flip to everything you
own (remembered in localStorage).

- calendar_upcoming(watchlist_only=) adds the watchlist filter; /calendar takes
  ?scope=watchlist|all (default watchlist).
- Calendar page gets a scope toggle (defaults watchlist, persists, refetches on
  change).

Tests: DB scope (followed-only / airing-default / mute drops out / all-library sees
all) + frontend wiring. node --check clean.
2026-06-20 14:07:40 -07:00

650 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 ? '<img class="vcal-cell-img" src="' + art + '" alt="" loading="lazy" decoding="async" ' +
'onload="this.classList.add(\'vcal-loaded\')" onerror="this.style.display=\'none\'">' : '';
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
? '<span class="vcal-time"><span class="vcal-time-dot"></span>' + tl + '</span>'
: '<span class="vcal-time vcal-time--none">Anytime</span>';
var flag = ep.has_file ? '<span class="vcal-flag" title="In your library">✓</span>' : '';
var meta = '<span class="vcal-se">' + se + '</span>' + (epTitle ? '<span class="vcal-ep">' + esc(epTitle) + '</span>' : '');
return '<a class="vcal-cell' + (ep.has_file ? ' vcal-cell--owned' : '') + '" style="--vcal-h:' + hue + ';--i:' + (idx % 24) + '" ' +
'href="/video-detail/library/show/' + ep.show_id + '" ' +
'data-cal-ep="' + ep.id + '" ' +
'title="' + esc(ep.show_title) + (epTitle ? ' — ' + esc(epTitle) : '') + (tl ? ' · ' + tl : '') + '">' +
'<span class="vcal-glow" aria-hidden="true"></span>' +
'<span class="vcal-card">' +
'<span class="vcal-art">' + img + '<span class="vcal-art-scrim"></span>' + flag + '</span>' +
'<span class="vcal-info">' +
time +
'<span class="vcal-show">' + esc(ep.show_title) + '</span>' +
'<span class="vcal-meta">' + meta + '</span>' +
'</span>' +
'</span>' +
'</a>';
}
// 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: '5a12p', lo: 300, hi: 720 },
{ key: 'afternoon', label: 'Afternoon', range: '125p', lo: 720, hi: 1020 },
{ key: 'prime', label: 'Prime Time', range: '59p', lo: 1020, hi: 1260 },
{ key: 'late', label: 'Late Night', range: '9p5a', 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:0004: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 = '<div class="vcal-guide-corner"></div>';
for (var i = 0; i < COLS; i++) {
var dt = days[i], today = isoOf(dt) === d.today;
html += '<div class="vcal-dayhead' + (today ? ' vcal-dayhead--today' : '') + '">' +
'<span class="vcal-dayhead-wd">' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '</span>' +
'<span class="vcal-dayhead-date">' + WD[dt.getDay()] + ' ' + dt.getDate() + '</span>' +
(dayCount[i] ? '<span class="vcal-dayhead-n">' + dayCount[i] + '</span>' : '') +
'</div>';
}
// 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 += '<div class="vcal-rail vcal-rail--' + b.key + '">' +
'<span class="vcal-rail-label">' + b.label + '</span>' +
'<small>' + b.range + '</small></div>';
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('')
: '<span class="vcal-slot-dot" aria-hidden="true"></span>';
html += '<div class="vcal-slot' + (today ? ' vcal-slot--today' : '') +
(isNow ? ' vcal-slot--now' : '') +
(cell.length ? '' : ' vcal-slot--empty') + '">' + inner + '</div>';
}
});
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 ? '<span class="vcal-bb-badge">✓ In your library</span>' : '';
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">' +
'<div class="vcal-bb-eyebrow"><span class="vcal-bb-dot"></span>' +
(state.offset === 0 ? 'NEXT UP' : 'FEATURED') + ' · ' + esc(whenLabel(ep, d.today)) + '</div>' +
'<h2 class="vcal-bb-title">' + esc(ep.show_title) + '</h2>' +
'<div class="vcal-bb-sub"><span class="vcal-bb-se">' + se + '</span>' + (epTitle ? ' · ' + esc(epTitle) : '') + '</div>' +
'<div class="vcal-bb-actions"><span class="vcal-bb-btn">View details</span>' + owned + '</div>' +
'</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; });
if (state.filter === 'missing') return eps.filter(function (e) { return !e.has_file; });
return eps;
}
function updateChrome() {
var t = $('[data-video-cal-title]');
if (t) t.textContent = state.offset === 0 ? 'This Week'
: state.offset === 1 ? 'Next Week' : state.offset === -1 ? 'Last Week'
: state.offset > 0 ? 'In ' + state.offset + ' weeks' : Math.abs(state.offset) + ' weeks ago';
var today = $('[data-video-cal-today]');
if (today) today.disabled = state.offset === 0;
var fbs = document.querySelectorAll('[data-video-cal-filter]');
for (var i = 0; i < fbs.length; i++)
fbs[i].classList.toggle('vcal-filter-btn--on', fbs[i].getAttribute('data-video-cal-filter') === state.filter);
}
// Render from the cached payload + the active filter (no refetch).
function render() {
var d = state.data;
updateChrome();
var hero = $('[data-video-cal-hero]'), cols = $('[data-video-cal-cols]');
if (!d) { showEmpty(true); if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; return; }
var eps = filterEps(d.episodes || []);
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);
// 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 = ''; }
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
// 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) + '&scope=' + (state.scope || 'watchlist'),
{ headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (!d || d.error) { state.data = null; render(); return; }
state.data = d;
state.eps = {};
(d.episodes || []).forEach(function (e) { state.eps[e.id] = e; });
render();
refreshAddMissing(); // surface the catch-up button for aired-missing eps
})
.catch(function () { showLoading(false); state.data = null; render(); });
}
// ── "Add missing to wishlist" (catch-up for the auto-promoter) ────────────
// Targets already-AIRED, MISSING episodes in this week that aren't yet on the
// wishlist. Upcoming episodes are left alone (the calendar promotes them once
// they air). Mostly a no-op on the current/future weeks; useful on past ones.
function airedMissing() {
var d = state.data; if (!d) return [];
var today = d.today;
return (d.episodes || []).filter(function (e) {
return !e.has_file && e.show_tmdb_id && e.air_date && e.air_date < today;
});
}
function refreshAddMissing() {
var btn = $('[data-video-cal-addmissing]'); if (!btn) return;
state.addMissing = [];
btn.classList.add('hidden');
var cand = airedMissing();
if (!cand.length) return;
var ids = []; cand.forEach(function (e) { if (ids.indexOf(e.show_tmdb_id) < 0) ids.push(e.show_tmdb_id); });
fetch('/api/video/wishlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shows: ids }) })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
var by = (res && res.by_show) || {};
var fresh = cand.filter(function (e) {
var have = by[String(e.show_tmdb_id)] || [];
return have.indexOf(e.season_number + '_' + e.episode_number) < 0;
});
state.addMissing = fresh;
var lbl = $('[data-video-cal-addmissing-label]');
if (lbl) lbl.textContent = 'Add ' + fresh.length + ' missing to wishlist';
btn.classList.toggle('hidden', !fresh.length);
})
.catch(function () { /* leave hidden */ });
}
function addMissing() {
var eps = state.addMissing || []; if (!eps.length) return;
var btn = $('[data-video-cal-addmissing]'); if (btn) btn.disabled = true;
var byShow = {};
eps.forEach(function (e) {
var g = byShow[e.show_tmdb_id] || (byShow[e.show_tmdb_id] = {
tmdb_id: e.show_tmdb_id, title: e.show_title,
poster_url: e.show_has_poster ? ('/api/video/poster/show/' + e.show_id) : null,
library_id: e.show_id, episodes: [] });
g.episodes.push({ season_number: e.season_number, episode_number: e.episode_number,
title: e.title, air_date: e.air_date });
});
var posts = Object.keys(byShow).map(function (k) {
var g = byShow[k];
return fetch('/api/video/wishlist/add', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ show: { tmdb_id: g.tmdb_id, title: g.title, poster_url: g.poster_url,
library_id: g.library_id }, episodes: g.episodes }) }).then(function (r) { return r.ok ? r.json() : null; });
});
Promise.all(posts).then(function () {
if (typeof showToast === 'function') showToast('Added ' + eps.length + ' missing episode' + (eps.length === 1 ? '' : 's') + ' to wishlist', 'success');
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
if (btn) btn.disabled = false;
refreshAddMissing(); // recompute → button hides what's now queued
}).catch(function () { if (btn) btn.disabled = false; });
}
function openFrom(target) {
var el = target.closest('[data-cal-ep]');
if (!el) return false;
var ep = state.eps[el.getAttribute('data-cal-ep')];
if (ep) openModal(ep);
return true;
}
// Cursor-following 3D tilt (delegated, one element at a time).
function wireTilt(container, sel, deg) {
if (!container) return;
var last = null;
container.addEventListener('mousemove', function (e) {
var c = e.target.closest(sel);
if (c !== last && last) { last.style.removeProperty('--rx'); last.style.removeProperty('--ry'); }
last = c;
if (!c) return;
var r = c.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
c.style.setProperty('--ry', (px * deg).toFixed(2) + 'deg');
c.style.setProperty('--rx', (-py * deg).toFixed(2) + 'deg');
});
container.addEventListener('mouseleave', function () {
if (last) { last.style.removeProperty('--rx'); last.style.removeProperty('--ry'); last = null; }
});
}
function wire() {
var cols = $('[data-video-cal-cols]');
if (cols) cols.addEventListener('click', function (e) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (e.target.closest('[data-cal-ep]') && cols.contains(e.target)) { e.preventDefault(); openFrom(e.target); }
});
var hero = $('[data-video-cal-hero]');
if (hero) {
hero.addEventListener('click', function (e) { if (openFrom(e.target)) e.preventDefault(); });
hero.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('[data-cal-ep]')) { e.preventDefault(); openFrom(e.target); }
});
wireTilt(hero, '.vcal-bb', 3);
}
wireTilt(cols, '.vcal-cell', 7);
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 () {
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(); });
})(fbs[i]);
var vbs = document.querySelectorAll('[data-video-cal-view]');
for (var k = 0; k < vbs.length; k++) (function (b) {
b.addEventListener('click', function () { setView(b.getAttribute('data-video-cal-view')); });
})(vbs[k]);
var sbs = document.querySelectorAll('[data-video-cal-scope]');
for (var j = 0; j < sbs.length; j++) (function (b) {
b.addEventListener('click', function () { setScope(b.getAttribute('data-video-cal-scope')); });
})(sbs[j]);
var addBtn = $('[data-video-cal-addmissing]');
if (addBtn) addBtn.addEventListener('click', addMissing);
// 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 ─────────────────────────────────────────────────────────
var modalEl = null, modalKeyHandler = null;
function fmtFullDate(iso) { var d = parseISO(iso); return WD_FULL[d.getDay()] + ', ' + MO_FULL[d.getMonth()] + ' ' + d.getDate(); }
function closeModal() {
if (!modalEl) return;
modalEl.classList.remove('vcm-open');
document.body.style.removeProperty('overflow');
if (modalKeyHandler) { document.removeEventListener('keydown', modalKeyHandler); modalKeyHandler = null; }
var el = modalEl; modalEl = null;
setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 220);
}
function openModal(ep) {
closeModal();
var hue = showHue(ep.show_title || '');
var backdrop = ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=1000') : '';
var still = ep.has_still ? ('/api/video/poster/episode/' + ep.id + '?w=600')
: (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=600') : '');
var se = 'S' + ep.season_number + ' · E' + ep.episode_number;
var epTitle = ep.title || ('Episode ' + ep.episode_number);
var tl = fmtMins(airMins(ep.airs_time));
var when = fmtFullDate(ep.air_date) + ' · ' + (tl || 'Anytime');
var owned = ep.has_file
? '<span class="vcm-badge vcm-badge--have">✓ In your library</span>'
: '<span class="vcm-badge vcm-badge--miss">Not in library</span>';
var tags = [owned];
if (ep.runtime_minutes) tags.push('<span class="vcm-tag">' + ep.runtime_minutes + ' min</span>');
if (ep.rating) tags.push('<span class="vcm-tag vcm-tag--star">★ ' + (Math.round(ep.rating * 10) / 10) + '</span>');
var eyebrow = [ep.network, ep.show_year, ep.show_status].filter(Boolean).map(esc).join(' · ');
var ov = document.createElement('div');
ov.className = 'vcm-overlay'; ov.setAttribute('data-vcm', '');
ov.style.setProperty('--vcm-h', hue);
ov.innerHTML =
'<div class="vcm-modal" role="dialog" aria-modal="true">' +
'<button class="vcm-close" type="button" data-vcm-close aria-label="Close">×</button>' +
'<div class="vcm-hero">' +
(backdrop ? '<div class="vcm-hero-bg" style="background-image:url(\'' + backdrop + '\')"></div>' : '') +
'<div class="vcm-hero-scrim"></div>' +
'<div class="vcm-hero-content">' +
'<div class="vcm-eyebrow" data-vcm-eyebrow>' + eyebrow + '</div>' +
'<h2 class="vcm-show">' + esc(ep.show_title) + '</h2>' +
'<div class="vcm-genres" data-vcm-genres></div>' +
'</div>' +
'</div>' +
'<div class="vcm-body">' +
'<div class="vcm-ep">' +
'<div class="vcm-ep-still">' +
(still ? '<img src="' + still + '" alt="" decoding="async" ' +
'onload="this.classList.add(\'vcm-loaded\')" onerror="this.style.display=\'none\'">' : '') +
'<span class="vcm-ep-fb">▶</span></div>' +
'<div class="vcm-ep-main">' +
'<div class="vcm-ep-se">' + se + '</div>' +
'<h3 class="vcm-ep-title">' + esc(epTitle) + '</h3>' +
'<div class="vcm-when">' + esc(when) + '</div>' +
'<div class="vcm-tags">' + tags.join('') + '</div>' +
(ep.overview ? '<p class="vcm-ep-ov">' + esc(ep.overview) + '</p>'
: '<p class="vcm-ep-ov vcm-ep-ov--none">No episode synopsis yet.</p>') +
'</div>' +
'</div>' +
'<div class="vcm-about" data-vcm-about hidden>' +
'<h4 class="vcm-about-h">About the show</h4>' +
'<p class="vcm-about-ov" data-vcm-show-ov></p>' +
'</div>' +
'</div>' +
'<div class="vcm-actions">' +
'<button class="vcm-btn vcm-btn--ghost" type="button" data-vcm-close>Close</button>' +
'<button class="vcm-btn vcm-btn--primary" type="button" data-vcm-open>Open full show page →</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
document.body.style.overflow = 'hidden';
modalEl = ov;
requestAnimationFrame(function () { ov.classList.add('vcm-open'); });
ov.addEventListener('click', function (e) {
if (e.target === ov || e.target.closest('[data-vcm-close]')) { closeModal(); return; }
if (e.target.closest('[data-vcm-open]')) {
closeModal();
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: 'show', id: ep.show_id, source: 'library' },
}));
}
});
modalKeyHandler = function (e) { if (e.key === 'Escape') closeModal(); };
document.addEventListener('keydown', modalKeyHandler);
enrichModal(ep);
}
// Lazy-fill show overview + genres + a fuller eyebrow from the show detail.
function enrichModal(ep) {
var sid = ep.show_id;
fetch('/api/video/detail/show/' + sid, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !modalEl) return;
var g = modalEl.querySelector('[data-vcm-genres]');
if (g && d.genres && d.genres.length) {
g.innerHTML = d.genres.slice(0, 4).map(function (x) {
return '<span class="vcm-genre">' + esc(x) + '</span>';
}).join('');
}
var eb = modalEl.querySelector('[data-vcm-eyebrow]');
if (eb) {
var parts = [d.network || ep.network, d.year || ep.show_year, d.status || ep.show_status,
d.content_rating].filter(Boolean).map(esc);
if (parts.length) eb.textContent = parts.join(' · ');
}
var about = modalEl.querySelector('[data-vcm-about]');
var ovEl = modalEl.querySelector('[data-vcm-show-ov]');
if (about && ovEl && d.overview && d.overview !== ep.overview) {
ovEl.textContent = d.overview;
about.hidden = false;
}
})
.catch(function () { /* modal already shows payload data */ });
}
// View switcher (cards ↔ compact). Just toggles a class on the stable grid
// wrapper — no refetch/re-render needed — and remembers the choice.
function setView(v) {
state.view = v;
try { localStorage.setItem('vcalView', v); } catch (e) { /* private mode */ }
applyView();
}
function applyView() {
var grid = $('[data-video-cal-grid]');
if (grid) grid.classList.toggle('vcal-view--compact', state.view === 'compact');
var vbs = document.querySelectorAll('[data-video-cal-view]');
for (var i = 0; i < vbs.length; i++)
vbs[i].classList.toggle('vcal-view-btn--on', vbs[i].getAttribute('data-video-cal-view') === state.view);
}
// Source switcher (watchlist ↔ all library). Unlike the filter, this changes
// WHICH shows the server returns, so it refetches. Remembers the choice.
function setScope(sc) {
if (sc !== 'watchlist' && sc !== 'all') return;
if (sc === state.scope) return;
state.scope = sc;
try { localStorage.setItem('vcalScope', sc); } catch (e) { /* private mode */ }
applyScope();
load();
}
function applyScope() {
var sbs = document.querySelectorAll('[data-video-cal-scope]');
for (var i = 0; i < sbs.length; i++)
sbs[i].classList.toggle('vcal-filter-btn--on', sbs[i].getAttribute('data-video-cal-scope') === state.scope);
}
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();
try { var sv = localStorage.getItem('vcalView'); if (sv) state.view = sv; } catch (e) { /* private mode */ }
try { var sc = localStorage.getItem('vcalScope'); if (sc === 'watchlist' || sc === 'all') state.scope = sc; } catch (e) { /* private mode */ }
applyView();
applyScope();
document.addEventListener('soulsync:video-page-shown', onPageShown);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();