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:
parent
35957b426d
commit
e24e9bfe1e
2 changed files with 183 additions and 5 deletions
|
|
@ -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 →</button>' +
|
||||
'<span class="vgm-sel-count" data-vgm-count></span>' +
|
||||
'<button class="vgm-btn vgm-btn--text" type="button" data-vgm-open>Full page →</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>.
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue