From e24e9bfe1ec5753b4cc159d8194781f909fb17c2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 11:30:58 -0700 Subject: [PATCH] Video get-modal: TV episode selector (evolved Download Discography) + Download btn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- webui/static/video/video-get-modal.js | 142 +++++++++++++++++++++++++- webui/static/video/video-side.css | 46 +++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index 738bc96b..566d2163 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -53,6 +53,25 @@ setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 220); } + var modalState = null; // { kind, sel:Set } 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 @@ '' + '
' + '

Loading details…

' + + '' + '
' + '
' + - '' + + '' + + '' + + '' + '' + '
' + ''; 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') + ? '' + : '' + (st === 'owned' ? '✓' : '◷') + ''; + var badge = st === 'owned' ? 'In library' + : st === 'upcoming' ? '' + (date || 'Upcoming') + '' + : 'Missing'; + return ''; + } + + 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 = '
Episodes' + + '
'; + 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 += '
' + + '
' + + (missing ? '' + : '') + + '' + esc(s.title || ('Season ' + s.season_number)) + '' + + '' + (missing ? missing + ' missing · ' : '') + eps.length + ' eps' + + '' + + '
' + + '
' + eps.map(function (e) { return epRow(s.season_number, e, today); }).join('') + '
' + + '
'; + }); + html += '
'; + 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 . diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 21e46dc1..052bf39f 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }