From 419b939999801de5e4df7aa96366d19a3c9a6cb6 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 11:42:57 -0700 Subject: [PATCH] Video get-modal: owned episodes re-downloadable + 'Add to watchlist' offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Owned episodes are now SELECTABLE (checkbox, not pre-checked) so you can re-download a season/episode you already have; flip 'Missing only' off to see them. Every season (incl. fully-owned) gets a select-all. Select-all + the season checkbox state respect the 'Missing only' filter (no silently grabbing hidden owned episodes). - Airing show you don't follow yet → an accent 'Add to watchlist' tickbox (default on) so grabbing episodes also starts you watching for new ones. Hidden for ended shows + shows you already follow. The Add-to-Wishlist stub toast notes '+ Watchlist' when it's ticked. --- webui/static/video/video-get-modal.js | 73 +++++++++++++++++++++------ webui/static/video/video-side.css | 10 ++++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index 566d2163..d8302ea1 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -91,6 +91,7 @@ '
' + '

Loading details…

' + '' + + '' + '
' + '
' + '' + @@ -123,19 +124,22 @@ 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'); + var isDl = !!e.target.closest('[data-vgm-download]'); + var aw = ov.querySelector('[data-vgm-add-watch]'); + var follow = (!isDl && aw && aw.checked) ? ' + Watchlist' : ''; + toast((modalState ? (n + ' episode' + (n === 1 ? '' : 's') + ' — ') : '') + + (isDl ? 'Download' : 'Wishlist') + follow + ' 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 + if (sa) { // season select-all → toggle its currently-actionable 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); - } + sa.indeterminate = false; + seasonBoxes(ov, sn).forEach(function (b) { + b.checked = sa.checked; + toggleSel(b.getAttribute('data-vgm-ep'), sa.checked); + }); updateFooter(); return; } @@ -149,6 +153,9 @@ 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); + // the actionable set changed → re-derive every season checkbox + var sas = ov.querySelectorAll('[data-vgm-season-all]'); + for (var k = 0; k < sas.length; k++) syncSeasonCheck(ov, sas[k].getAttribute('data-vgm-season-all')); } }); keyHandler = function (e) { if (e.key === 'Escape') closeModal(); }; @@ -165,13 +172,26 @@ function toggleSel(key, on) { if (!modalState) return; if (on) modalState.sel.add(key); else modalState.sel.delete(key); } + function missingOnlyOn(ov) { var m = ov.querySelector('[data-vgm-missing-only]'); return !m || m.checked; } + // Episode checkboxes for a season that the season-select-all should act on: + // when "Missing only" is on, owned episodes are hidden, so exclude them. + function seasonBoxes(ov, sn) { + var skipOwned = missingOnlyOn(ov); + var all = ov.querySelectorAll('.vgm-ep-cb[data-vgm-ep^="' + sn + '_"]'), out = []; + for (var i = 0; i < all.length; i++) { + if (skipOwned && all[i].closest('.vgm-ep--owned')) continue; + out.push(all[i]); + } + return out; + } 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 boxes = seasonBoxes(ov, 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.checked = boxes.length > 0 && checked === boxes.length; all.indeterminate = checked > 0 && checked < boxes.length; + all.disabled = boxes.length === 0; } function updateFooter() { @@ -195,9 +215,11 @@ 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' ? '✓' : '◷') + ''; + // missing-aired -> pre-checked; owned -> selectable for re-download (not + // pre-checked); upcoming -> locked (can't grab what hasn't aired). + var ctrl = (st === 'upcoming') + ? '' + : ''; var badge = st === 'owned' ? 'In library' : st === 'upcoming' ? '' + (date || 'Upcoming') + '' : 'Missing'; @@ -223,8 +245,7 @@ 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' + '' + @@ -236,6 +257,8 @@ wrap.innerHTML = html; wrap.classList.add('vgm-eps--missing-only'); wrap.hidden = false; + // initial season-checkbox states reflect the (missing-only) selection + d.seasons.forEach(function (s) { syncSeasonCheck(modalEl, s.season_number); }); } function fill(d, o) { @@ -270,10 +293,28 @@ else { ov.textContent = 'No synopsis available yet.'; ov.classList.add('vgm-overview--none'); } } - if (o.kind === 'show') renderEpisodes(d); // the season/episode selector + if (o.kind === 'show') { renderEpisodes(d); maybeFollow(d); } // selector + follow offer updateFooter(); } + // Airing show you don't follow yet → offer to start watching it (default on), + // so grabbing episodes also keeps you current. Hidden for ended shows (can't + // "watch" for new) and shows you already follow. + function maybeFollow(d) { + var fw = modalEl.querySelector('[data-vgm-follow]'); if (!fw) return; + if (!d.tmdb_id || !isAiring(d.status)) { fw.hidden = true; return; } + fetch('/api/video/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'show', tmdb_ids: [d.tmdb_id] }) }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (res) { + if (!modalEl) return; + if (res && res.results && res.results[String(d.tmdb_id)]) { fw.hidden = true; return; } + fw.innerHTML = ''; + fw.hidden = false; + }).catch(function () { /* skip the row */ }); + } + // One capture-phase handler — the get button sits inside a card . document.addEventListener('click', function (e) { var b = e.target.closest && e.target.closest('.vget-btn'); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 052bf39f..4b7c1b73 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2024,3 +2024,13 @@ body[data-side="video"] #soulsync-toggle { display: none; } .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; } + +/* "Add to watchlist" offer in the show get-modal (airing + not-yet-followed) */ +.vgm-follow { margin-top: 14px; } +.vgm-follow[hidden] { display: none; } +.vgm-follow-row { display: flex; align-items: center; gap: 11px; padding: 13px 14px; border-radius: 12px; cursor: pointer; + background: rgba(var(--accent-rgb, 88 101 242), 0.1); border: 1px solid rgba(var(--accent-rgb, 88 101 242), 0.28); } +.vgm-follow-row:hover { background: rgba(var(--accent-rgb, 88 101 242), 0.14); } +.vgm-follow-row input { accent-color: rgb(var(--accent-rgb, 88 101 242)); width: 17px; height: 17px; cursor: pointer; flex-shrink: 0; } +.vgm-follow-txt { font-size: 13px; color: rgba(255, 255, 255, 0.82); } +.vgm-follow-txt strong { color: #fff; font-weight: 800; }