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 @@
'
' +
'
' +
@@ -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; }