Video get-modal: TV episode selector (evolved Download Discography) + Download btn

Shows aren't wishlist items — episodes are. So the show modal now grows an
inline season/episode selector below the overview:
- Collapsible season cards (season-level select-all + 'N missing · M eps').
- Per-episode rows with three states: owned (locked ✓, 'In library'),
  upcoming (locked ◷, air date — these belong to the watchlist), and
  missing-aired (accent checkbox, PRE-SELECTED).
- 'Missing only' toggle (default on) hides owned to cut noise; live selected
  count; season checkboxes go indeterminate as you pick episodes.

Footer is now [count] · [Full page] · [Download N] · [Add N to Wishlist].
Movies keep a simple [Download] · [Add to Wishlist]. Add/Download are visual
stubs (toast the count) — the curated video_wishlist + download path come later.

Data is the existing show_detail seasons/episodes (owned + air_date).
This commit is contained in:
BoulderBadgeDad 2026-06-16 11:30:58 -07:00
parent 35957b426d
commit e24e9bfe1e
2 changed files with 183 additions and 5 deletions

View file

@ -53,6 +53,25 @@
setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 220);
}
var modalState = null; // { kind, sel:Set<key> } for the open show modal
function isoToday() {
var n = new Date();
return n.getFullYear() + '-' + ('0' + (n.getMonth() + 1)).slice(-2) + '-' + ('0' + n.getDate()).slice(-2);
}
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function fmtDate(iso) {
var p = String(iso || '').split('-');
if (p.length < 3) return '';
return MO[(+p[1] || 1) - 1] + ' ' + (+p[2] || 1) + ', ' + p[0];
}
// owned (have it) | upcoming (airs in the future) | missing (aired, not owned)
function epState(e, today) {
if (e.owned) return 'owned';
if (e.air_date && e.air_date > today) return 'upcoming';
return 'missing';
}
function openModal(o) {
closeModal();
var ov = document.createElement('div');
@ -71,15 +90,19 @@
'</div>' +
'<div class="vgm-body">' +
'<p class="vgm-overview" data-vgm-overview>Loading details…</p>' +
'<div class="vgm-eps" data-vgm-eps hidden></div>' +
'</div>' +
'<div class="vgm-actions">' +
'<button class="vgm-btn vgm-btn--ghost" type="button" data-vgm-open>Open full page &rarr;</button>' +
'<span class="vgm-sel-count" data-vgm-count></span>' +
'<button class="vgm-btn vgm-btn--text" type="button" data-vgm-open>Full page &rarr;</button>' +
'<button class="vgm-btn vgm-btn--ghost" type="button" data-vgm-download>Download</button>' +
'<button class="vgm-btn vgm-btn--primary" type="button" data-vgm-wishlist>+ Add to Wishlist</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
document.body.style.overflow = 'hidden';
modalEl = ov;
modalState = null;
requestAnimationFrame(function () { ov.classList.add('vgm-open'); });
ov.addEventListener('click', function (e) {
@ -91,9 +114,41 @@
}));
return;
}
if (e.target.closest('[data-vgm-wishlist]')) {
// v1: visual only — real wishlist population is a later phase.
toast('Wishlist coming soon', 'info');
// Collapse/expand a season (ignore clicks on its checkbox).
var sh = e.target.closest('[data-vgm-season-toggle]');
if (sh && !e.target.closest('.vgm-season-check')) {
sh.parentNode.classList.toggle('vgm-season--open');
return;
}
if (e.target.closest('[data-vgm-wishlist]') || e.target.closest('[data-vgm-download]')) {
// v1: visual only — real wishlist/download is a later phase.
var n = modalState ? modalState.sel.size : 0;
var what = e.target.closest('[data-vgm-download]') ? 'Download' : 'Wishlist';
toast((modalState ? (n + ' episode' + (n === 1 ? '' : 's') + ' — ') : '') + what + ' coming soon', 'info');
}
});
ov.addEventListener('change', function (e) {
var sa = e.target.closest('[data-vgm-season-all]');
if (sa) { // season select-all → toggle all its selectable episodes
var sn = sa.getAttribute('data-vgm-season-all');
var boxes = ov.querySelectorAll('.vgm-ep-cb[data-vgm-ep^="' + sn + '_"]');
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = sa.checked;
toggleSel(boxes[i].getAttribute('data-vgm-ep'), sa.checked);
}
updateFooter();
return;
}
var ep = e.target.closest('.vgm-ep-cb');
if (ep) {
toggleSel(ep.getAttribute('data-vgm-ep'), ep.checked);
syncSeasonCheck(ov, ep.getAttribute('data-vgm-ep').split('_')[0]);
updateFooter();
return;
}
if (e.target.closest('[data-vgm-missing-only]')) {
var wrap = ov.querySelector('[data-vgm-eps]');
if (wrap) wrap.classList.toggle('vgm-eps--missing-only', e.target.checked);
}
});
keyHandler = function (e) { if (e.key === 'Escape') closeModal(); };
@ -108,10 +163,84 @@
.catch(function () { /* keep the title-only shell */ });
}
function toggleSel(key, on) { if (!modalState) return; if (on) modalState.sel.add(key); else modalState.sel.delete(key); }
function syncSeasonCheck(ov, sn) {
var all = ov.querySelector('[data-vgm-season-all="' + sn + '"]'); if (!all) return;
var boxes = ov.querySelectorAll('.vgm-ep-cb[data-vgm-ep^="' + sn + '_"]');
var checked = 0;
for (var i = 0; i < boxes.length; i++) if (boxes[i].checked) checked++;
all.checked = checked === boxes.length && boxes.length > 0;
all.indeterminate = checked > 0 && checked < boxes.length;
}
function updateFooter() {
if (!modalEl) return;
var n = modalState ? modalState.sel.size : 0;
var cnt = modalEl.querySelector('[data-vgm-count]');
var add = modalEl.querySelector('[data-vgm-wishlist]');
var dl = modalEl.querySelector('[data-vgm-download]');
if (modalState && modalState.kind === 'show') {
if (cnt) cnt.textContent = n + ' episode' + (n === 1 ? '' : 's') + ' selected';
if (add) { add.textContent = '+ Add ' + n + ' to Wishlist'; add.disabled = n === 0; }
if (dl) { dl.textContent = 'Download ' + n; dl.disabled = n === 0; }
} else {
if (cnt) cnt.textContent = '';
if (add) { add.textContent = '+ Add to Wishlist'; add.disabled = false; }
if (dl) { dl.textContent = 'Download'; dl.disabled = false; }
}
}
function epRow(snum, e, today) {
var st = epState(e, today);
var key = snum + '_' + e.episode_number;
var date = e.air_date ? fmtDate(e.air_date) : '';
var ctrl = (st === 'missing')
? '<input type="checkbox" class="vgm-ep-cb" data-vgm-ep="' + esc(key) + '" checked>'
: '<span class="vgm-ep-lock">' + (st === 'owned' ? '✓' : '◷') + '</span>';
var badge = st === 'owned' ? '<span class="vgm-ep-badge vgm-ep-badge--owned">In library</span>'
: st === 'upcoming' ? '<span class="vgm-ep-badge vgm-ep-badge--soon">' + (date || 'Upcoming') + '</span>'
: '<span class="vgm-ep-badge vgm-ep-badge--missing">Missing</span>';
return '<label class="vgm-ep vgm-ep--' + st + '">' +
'<span class="vgm-ep-ctrl">' + ctrl + '</span>' +
'<span class="vgm-ep-num">E' + (e.episode_number != null ? e.episode_number : '') + '</span>' +
'<span class="vgm-ep-title">' + esc(e.title || ('Episode ' + e.episode_number)) + '</span>' +
(date && st !== 'upcoming' ? '<span class="vgm-ep-date">' + esc(date) + '</span>' : '') +
badge + '</label>';
}
function renderEpisodes(d) {
var wrap = modalEl.querySelector('[data-vgm-eps]'); if (!wrap) return;
if (!d.seasons || !d.seasons.length) { wrap.hidden = true; return; }
var today = isoToday();
modalState = { kind: 'show', sel: new Set() };
var html = '<div class="vgm-eps-head"><span class="vgm-eps-h">Episodes</span>' +
'<label class="vgm-eps-toggle"><input type="checkbox" data-vgm-missing-only checked>' +
'<span>Missing only</span></label></div><div class="vgm-eps-list">';
d.seasons.forEach(function (s) {
var eps = s.episodes || [];
var missing = 0;
eps.forEach(function (e) { if (epState(e, today) === 'missing') { missing++; modalState.sel.add(s.season_number + '_' + e.episode_number); } });
html += '<div class="vgm-season">' +
'<div class="vgm-season-head" data-vgm-season-toggle>' +
(missing ? '<span class="vgm-season-check"><input type="checkbox" data-vgm-season-all="' + s.season_number + '" checked></span>'
: '<span class="vgm-season-check vgm-season-check--lock">✓</span>') +
'<span class="vgm-season-name">' + esc(s.title || ('Season ' + s.season_number)) + '</span>' +
'<span class="vgm-season-meta">' + (missing ? missing + ' missing · ' : '') + eps.length + ' eps</span>' +
'<span class="vgm-season-chev" aria-hidden="true">⌄</span>' +
'</div>' +
'<div class="vgm-season-eps">' + eps.map(function (e) { return epRow(s.season_number, e, today); }).join('') + '</div>' +
'</div>';
});
html += '</div>';
wrap.innerHTML = html;
wrap.classList.add('vgm-eps--missing-only');
wrap.hidden = false;
}
function fill(d, o) {
var q = function (s) { return modalEl.querySelector(s); };
var id = o.id;
// backdrop (library art routes; tmdb payloads carry urls)
var hero = q('[data-vgm-hero]');
var bg = (o.source !== 'tmdb' && d.has_backdrop)
? '/api/video/backdrop/' + o.kind + '/' + id + '?w=1280'
@ -140,6 +269,9 @@
if (d.overview) { ov.textContent = d.overview; ov.classList.remove('vgm-overview--none'); }
else { ov.textContent = 'No synopsis available yet.'; ov.classList.add('vgm-overview--none'); }
}
if (o.kind === 'show') renderEpisodes(d); // the season/episode selector
updateFooter();
}
// One capture-phase handler — the get button sits inside a card <a>.

View file

@ -1978,3 +1978,49 @@ body[data-side="video"] #soulsync-toggle { display: none; }
positioned top-right; the buttons inside flow statically in a little row. */
.vcard-ctrls { position: absolute; top: 8px; right: 8px; z-index: 4; display: flex; gap: 6px; }
.vcard-ctrls .vwl-btn, .vcard-ctrls .vget-btn { position: static; top: auto; right: auto; }
/* ── Get-modal: season/episode selector + smarter footer ──────────────────── */
.vgm-eps { margin-top: 20px; padding-top: 18px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
.vgm-eps[hidden] { display: none; }
.vgm-eps-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.vgm-eps-h { font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.55); }
.vgm-eps-toggle { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 700;
color: rgba(255, 255, 255, 0.6); cursor: pointer; user-select: none; }
.vgm-eps-toggle input { accent-color: rgb(var(--accent-rgb, 88 101 242)); width: 15px; height: 15px; cursor: pointer; }
.vgm-eps-list { display: flex; flex-direction: column; gap: 8px; }
.vgm-season { border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 12px; background: rgba(255, 255, 255, 0.02); overflow: hidden; }
.vgm-season-head { display: flex; align-items: center; gap: 11px; padding: 12px 14px; cursor: pointer; transition: background 0.15s ease; }
.vgm-season-head:hover { background: rgba(255, 255, 255, 0.04); }
.vgm-season-check { display: inline-flex; align-items: center; }
.vgm-season-check input { accent-color: rgb(var(--accent-rgb, 88 101 242)); width: 17px; height: 17px; cursor: pointer; }
.vgm-season-check--lock { color: rgba(108, 211, 145, 0.85); font-size: 14px; font-weight: 900; width: 17px; justify-content: center; }
.vgm-season-name { font-size: 14px; font-weight: 800; color: #fff; }
.vgm-season-meta { font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.45); margin-left: auto; }
.vgm-season-chev { color: rgba(255, 255, 255, 0.4); transition: transform 0.2s ease; font-size: 13px; }
.vgm-season--open .vgm-season-chev { transform: rotate(180deg); }
.vgm-season-eps { display: none; padding: 2px 6px 8px; }
.vgm-season--open .vgm-season-eps { display: block; }
.vgm-ep { display: flex; align-items: center; gap: 11px; padding: 8px 10px; border-radius: 9px; cursor: pointer; transition: background 0.12s ease; }
.vgm-ep:hover { background: rgba(255, 255, 255, 0.04); }
.vgm-eps--missing-only .vgm-ep--owned { display: none; }
.vgm-ep-ctrl { width: 18px; display: inline-flex; justify-content: center; flex-shrink: 0; }
.vgm-ep-cb { accent-color: rgb(var(--accent-rgb, 88 101 242)); width: 16px; height: 16px; cursor: pointer; }
.vgm-ep-lock { font-size: 13px; }
.vgm-ep--owned .vgm-ep-lock { color: rgba(108, 211, 145, 0.8); }
.vgm-ep--upcoming .vgm-ep-lock { color: rgba(255, 255, 255, 0.3); }
.vgm-ep-num { font-size: 11.5px; font-weight: 800; color: rgba(255, 255, 255, 0.4); min-width: 30px; flex-shrink: 0; }
.vgm-ep-title { flex: 1; min-width: 0; font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.9); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vgm-ep--owned .vgm-ep-title, .vgm-ep--upcoming .vgm-ep-title { color: rgba(255, 255, 255, 0.45); }
.vgm-ep-date { font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.35); margin-left: 8px; flex-shrink: 0; }
.vgm-ep-badge { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em; padding: 2px 8px; border-radius: 6px; margin-left: 8px; flex-shrink: 0; }
.vgm-ep-badge--owned { background: rgba(108, 211, 145, 0.16); color: #6cd391; }
.vgm-ep-badge--missing { background: rgba(var(--accent-rgb, 88 101 242), 0.18); color: #c4cbff; }
.vgm-ep-badge--soon { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.5); }
/* footer */
.vgm-sel-count { margin-right: auto; font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, 0.55); }
.vgm-btn--text { background: transparent; border-color: transparent; color: rgba(255, 255, 255, 0.6); padding: 11px 8px; }
.vgm-btn--text:hover { color: #fff; }
.vgm-btn:disabled { opacity: 0.4; cursor: default; transform: none !important; filter: none !important; }