video search UI: real result cards across movie/episode/season/show scopes

Wires the four scopes to the /downloads/search pipeline and renders beautiful,
profile-aware result cards (mock indexer for now):
  - Movie: per-source ⌕ / Search all → movie scope.
  - Episode: expand an episode → per-source ⌕ → episode scope (its own results panel).
  - Season: the season ⌕ → SEASON-PACK scope, results under the season header.
  - Whole show: top-bar 'Search whole show' → complete-SERIES-pack scope.
Each card shows the full release name (mono), colour-coded quality badges
(resolution/source/codec/HDR/audio/repack), size, seeders, and a verdict — green
'✓ Meets profile' or a dimmed '✕ <reason>' (e.g. not in your enabled tiers, wrong
season, over size cap). Accepted hits sort first and carry a ⤓ Grab button (stub).
Spinner while searching; staggered card entrance; reduced-motion honoured.
Replaced the faux-scan stubs. JS/CSS balance clean.
This commit is contained in:
BoulderBadgeDad 2026-06-19 13:44:50 -07:00
parent 44b074772b
commit c5bffe51a5
3 changed files with 157 additions and 62 deletions

View file

@ -58,14 +58,21 @@
'<button class="vdl-search-all" type="button" data-vdl-search-all>⌕ Search all</button>' +
'</div>' +
'<div class="vdl-sources" data-vdl-sources><div class="vdl-src-empty">Loading sources…</div></div>' +
'<div class="vdl-results" data-vdl-results hidden></div>' +
'</div>';
}
function onClick(e) {
var container = e.currentTarget;
if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isnt wired up yet — coming soon', 'info'); return; }
var o = container._opts || {};
var params = { scope: 'movie', title: o.title || '', year: o.year || null };
var results = container.querySelector('[data-vdl-results]');
var sb = e.target.closest('[data-vdl-search]');
if (sb) { stubSearch(container, sb.getAttribute('data-vdl-search')); return; }
if (e.target.closest('[data-vdl-search-all]')) { stubSearch(container, '*'); }
if (sb) { searchInto(container, results, params, [sb.closest('.vdl-src')]); return; }
if (e.target.closest('[data-vdl-search-all]')) {
searchInto(container, results, params, Array.prototype.slice.call(container.querySelectorAll('.vdl-src')));
}
}
// Render the download view into `container`. Re-callable (resets each time).
@ -73,6 +80,7 @@
if (!container) return;
opts = opts || {};
if (opts.kind === 'show') { renderShow(container, opts); return; }
container._opts = opts;
container.innerHTML = contentHTML();
if (!container._vdlWired) { container._vdlWired = true; container.addEventListener('click', onClick); }
@ -173,29 +181,72 @@
// Scaffold: a satisfying faux-scan (animated) that resolves to "coming soon".
// No backend yet — this is the motion the real engine will drive.
function scanRow(row, i) {
if (row._scanning) return;
row._scanning = true;
var st = row.querySelector('[data-vdl-status]');
var btn = row.querySelector('[data-vdl-search]');
row.classList.add('vdl-src--scanning');
if (btn) btn.disabled = true;
if (st) { st.textContent = 'Searching'; st.className = 'vdl-src-status vdl-src-status--scanning'; }
setTimeout(function () {
if (!row.isConnected) { row._scanning = false; return; }
row.classList.remove('vdl-src--scanning');
row._scanning = false;
if (btn) btn.disabled = false;
// ── search + results ──────────────────────────────────────────────────────
var SRC_LABEL = { remux: 'Remux', bluray: 'BluRay', 'web-dl': 'WEB-DL', webrip: 'WEBRip',
hdtv: 'HDTV', dvd: 'DVD', cam: 'CAM', screener: 'Screener', workprint: 'Workprint' };
var RES_LABEL = { '2160p': '4K', '1080p': '1080p', '720p': '720p', '480p': 'SD' };
function _setScanning(rows, on) {
rows.forEach(function (row) {
if (!row) return;
if (row.matches && row.matches('button')) { row.disabled = on; row.classList.toggle('vdl-btn--busy', on); return; }
row.classList.toggle('vdl-src--scanning', on);
var b = row.querySelector('[data-vdl-search]'); if (b) b.disabled = on;
var s = row.querySelector('[data-vdl-status]');
if (s) { s.textContent = 'Search engine coming soon'; s.className = 'vdl-src-status vdl-src-status--soon'; }
}, 1300 + i * 280); // staggered finish so a "search all" ripples
if (s) { s.textContent = on ? 'Searching' : 'Ready'; s.className = 'vdl-src-status' + (on ? ' vdl-src-status--scanning' : ''); }
});
}
function stubSearch(container, which) {
var sel = which === '*' ? '[data-vdl-src]' : '[data-vdl-src="' + which + '"]';
var rows = container.querySelectorAll(sel);
for (var i = 0; i < rows.length; i++) scanRow(rows[i], i);
toast('Automatic search isnt wired up yet — coming soon', 'info');
// Run a search (mock indexer → real parse/evaluate/rank) and render the cards.
function searchInto(container, resultsEl, params, triggerRows) {
if (!resultsEl) return;
triggerRows = (triggerRows || []).filter(Boolean);
_setScanning(triggerRows, true);
resultsEl.hidden = false;
resultsEl.innerHTML = '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
postJSON('/api/video/downloads/search', params).then(function (d) {
_setScanning(triggerRows, false);
if (!resultsEl.isConnected) return;
var rows = (d && d.results) || [];
if (!rows.length) { resultsEl.innerHTML = '<div class="vdl-res-empty">No matching releases found.</div>'; return; }
var okN = rows.filter(function (r) { return r.accepted; }).length;
resultsEl.innerHTML =
'<div class="vdl-res-head"><strong>' + rows.length + '</strong> result' + (rows.length === 1 ? '' : 's') +
' · <span class="vdl-res-okn">' + okN + ' meet your profile</span></div>' +
rows.map(resultCardHTML).join('');
});
}
function scopeWord(s) {
return s === 'season' ? 'for the season pack' : s === 'series' ? 'for the full series'
: s === 'episode' ? 'this episode' : 'for the movie';
}
function rb(text, mod) { return '<span class="vdl-rb' + (mod ? ' vdl-rb--' + mod : '') + '">' + esc(text) + '</span>'; }
function resultCardHTML(r) {
var badges = [];
if (r.resolution) badges.push(rb(RES_LABEL[r.resolution] || r.resolution, 'res'));
if (r.source) badges.push(rb(SRC_LABEL[r.source] || r.source, 'src'));
if (r.codec) badges.push(rb(String(r.codec).toUpperCase()));
if (r.hdr) badges.push(rb(String(r.hdr).toUpperCase(), 'hdr'));
if (r.audio) badges.push(rb(String(r.audio).toUpperCase().replace('-', ' ')));
if (r.repack) badges.push(rb('REPACK'));
var verdict = r.accepted
? '<span class="vdl-res-verdict vdl-res-verdict--ok">✓ Meets profile</span>'
: '<span class="vdl-res-verdict vdl-res-verdict--no">✕ ' + esc(r.rejected || 'Filtered') + '</span>';
return '<div class="vdl-res' + (r.accepted ? '' : ' vdl-res--rejected') + '">' +
'<div class="vdl-res-top">' +
'<div class="vdl-res-title" title="' + esc(r.title) + '">' + esc(r.title) + '</div>' +
(r.accepted ? '<button class="vdl-res-grab" type="button" data-vdl-grab title="Grab this release">⤓</button>' : '') +
'</div>' +
'<div class="vdl-res-badges">' + badges.join('') + '</div>' +
'<div class="vdl-res-foot">' +
'<span class="vdl-res-stat">' + r.size_gb + ' GB</span>' +
'<span class="vdl-res-stat vdl-res-seed">▲ ' + (r.seeders || 0) + '</span>' +
verdict +
'</div>' +
'</div>';
}
// ── TV show download view ─────────────────────────────────────────────────
@ -214,10 +265,13 @@
function renderShow(container, opts) {
var d = opts.detail || {};
var maxSeason = 0;
(d.seasons || []).forEach(function (s) { if ((s.season_number || 0) > maxSeason) maxSeason = s.season_number; });
var st = container._dl = {
sel: new Set(), today: isoToday(),
tvId: opts.tvId || d.tmdb_id || null, source: opts.source || 'library',
sources: ['soulseek'], epMeta: {}
sources: ['soulseek'], epMeta: {},
title: d.title || opts.title || '', maxSeason: maxSeason
};
container.innerHTML =
'<div class="vdl-section"><div class="vdl-sec-label">Quality target</div>' +
@ -225,9 +279,9 @@
'<div class="vdl-show-bar">' +
'<label class="vdl-allchk"><input type="checkbox" data-vdl-all><span class="vdl-allchk-txt">All</span></label>' +
'<span class="vdl-show-summary" data-vdl-summary>Loading episodes…</span>' +
'<button class="vdl-search-all vdl-search-sel" type="button" data-vdl-search-sel disabled>' +
'⌕ Search <span data-vdl-selcount>0</span> selected</button>' +
'<button class="vdl-search-all" type="button" data-vdl-search-show>⌕ Search whole show</button>' +
'</div>' +
'<div class="vdl-results" data-vdl-show-results hidden></div>' +
'<div class="vdl-seasons" data-vdl-seasons></div>';
if (!container._dlShowWired) {
@ -266,7 +320,10 @@
'<button class="vdl-season-search" type="button" data-vdl-season-search="' + sn + '" title="Search this season">⌕</button>' +
'<span class="vdl-season-chev" aria-hidden="true">⌄</span>' +
'</div>' +
'<div class="vdl-season-body"><div class="vdl-season-eps"><div class="vdl-season-empty">Loading…</div></div></div>' +
'<div class="vdl-season-body">' +
'<div class="vdl-results vdl-results--season" data-vdl-season-results hidden></div>' +
'<div class="vdl-season-eps"><div class="vdl-season-empty">Loading…</div></div>' +
'</div>' +
'</div>';
}
@ -325,16 +382,35 @@
}
function onShowClick(e) {
var container = e.currentTarget; if (!container._dl) return;
var container = e.currentTarget; var st = container._dl; if (!st) return;
if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isnt wired up yet — coming soon', 'info'); return; }
// Episode-scope search (a source row inside an expanded episode).
var srch = e.target.closest('[data-vdl-search]');
if (srch) { var row = srch.closest('.vdl-src'); if (row) scanRow(row, 0); toast('Automatic search isnt wired up yet — coming soon', 'info'); return; }
if (srch) {
var epEl = srch.closest('.vdl-ep'); if (!epEl) return;
var parts = (epEl.getAttribute('data-vdl-ep') || '').split('_');
searchInto(container, epEl.querySelector('[data-vdl-ep-results]'),
{ scope: 'episode', title: st.title, season: +parts[0], episode: +parts[1] },
[srch.closest('.vdl-src')]);
return;
}
// Season-scope search → season PACK.
var ss = e.target.closest('[data-vdl-season-search]');
if (ss) {
var sc = container.querySelector('.vdl-season[data-vdl-season="' + ss.getAttribute('data-vdl-season-search') + '"]');
if (sc) sc.classList.add('vdl-season--open'); // reveal the rows being scanned
searchScope(container, sc); return;
var sn = ss.getAttribute('data-vdl-season-search');
var sc = container.querySelector('.vdl-season[data-vdl-season="' + sn + '"]');
if (sc) sc.classList.add('vdl-season--open');
searchInto(container, sc && sc.querySelector('[data-vdl-season-results]'),
{ scope: 'season', title: st.title, season: +sn }, [ss]);
return;
}
// Whole-show search → complete-series pack.
if (e.target.closest('[data-vdl-search-show]')) {
searchInto(container, container.querySelector('[data-vdl-show-results]'),
{ scope: 'series', title: st.title, season_end: st.maxSeason || 5 },
[e.target.closest('[data-vdl-search-show]')]);
return;
}
if (e.target.closest('[data-vdl-search-sel]')) { searchScope(container, container.querySelector('[data-vdl-seasons]')); return; }
var sh = e.target.closest('[data-vdl-season-toggle]');
if (sh && !e.target.closest('.vdl-season-cb') && !e.target.closest('[data-vdl-season-search]')) {
sh.closest('.vdl-season').classList.toggle('vdl-season--open'); return;
@ -376,7 +452,8 @@
'<span class="vdl-src-meta"><span class="vdl-src-dot"></span><span class="vdl-src-status" data-vdl-status>Ready</span></span></span>' +
'<button class="vdl-src-search" type="button" data-vdl-search="' + s + '">⌕ Search</button>' +
'</div>';
}).join('') + '</div>';
}).join('') + '</div>' +
'<div class="vdl-results" data-vdl-ep-results hidden></div>';
}
function setSeasonSel(container, sn, on) {
@ -407,9 +484,6 @@
function updateShowBar(container) {
var st = container._dl; if (!st) return;
var n = st.sel.size;
var cnt = container.querySelector('[data-vdl-selcount]'); if (cnt) cnt.textContent = n;
var btn = container.querySelector('[data-vdl-search-sel]'); if (btn) btn.disabled = n === 0;
var sum = container.querySelector('[data-vdl-summary]');
if (sum) {
var seasons = container.querySelectorAll('.vdl-season').length;
@ -427,30 +501,5 @@
}
}
// Scan every SELECTED episode within a scope (a season card or the whole list).
function searchScope(container, scopeEl) {
if (!scopeEl || !container._dl) return;
var st = container._dl, eps = scopeEl.querySelectorAll('.vdl-ep'), picked = [];
for (var i = 0; i < eps.length; i++) { var k = eps[i].getAttribute('data-vdl-ep'); if (k && st.sel.has(k)) picked.push(eps[i]); }
if (!picked.length) { toast('Select at least one episode', 'info'); return; }
picked.forEach(function (epEl, i) { scanEp(epEl, i); });
toast('Automatic search isnt wired up yet — coming soon', 'info');
}
function scanEp(epEl, i) {
if (epEl._scanning) return;
epEl._scanning = true;
epEl.classList.add('vdl-ep--scanning');
var s = epEl.querySelector('[data-vdl-ep-status]');
if (s) { s.textContent = 'Searching'; s.className = 'vdl-ep-status vdl-ep-status--scanning'; }
setTimeout(function () {
if (!epEl.isConnected) { epEl._scanning = false; return; }
epEl.classList.remove('vdl-ep--scanning');
epEl._scanning = false;
var x = epEl.querySelector('[data-vdl-ep-status]');
if (x) { x.textContent = 'Coming soon'; x.className = 'vdl-ep-status vdl-ep-status--soon'; }
}, 1100 + i * 180);
}
window.VideoDownload = { render: render };
})();

View file

@ -91,7 +91,8 @@
ov.classList.add('vgm-mode-dl-show');
} else {
var file = (modalState && modalState.kind === 'movie' && modalState.owned) ? (modalState.file || null) : null;
VideoDownload.render(content, { kind: o.kind, id: o.id, source: o.source || 'library', isYt: false, file: file });
VideoDownload.render(content, { kind: o.kind, id: o.id, source: o.source || 'library', isYt: false, file: file,
title: (modalState && modalState.title) || o.title || '', year: (modalState && modalState.year) || null });
}
setDownloadMode(ov, true);
dl.hidden = false;

View file

@ -3248,3 +3248,48 @@ body[data-side="video"] #soulsync-toggle { display: none; }
@media (prefers-reduced-motion: reduce) {
.vdl-show-bar, .vdl-season, .vdl-ep--scanning::before { animation: none !important; }
}
/* ── search results (movie/episode/season/series) ─────────────────────────── */
.vdl-results { margin-top: 12px; }
.vdl-results[hidden] { display: none; }
.vdl-results--season { margin: 2px 14px 12px 14px; }
.vdl-ep-search .vdl-results { padding: 0 14px 12px 42px; margin-top: 2px; }
.vdl-ep--open .vdl-ep-search { max-height: 1800px; } /* room for result cards */
.vdl-res-loading { display: flex; align-items: center; gap: 9px; font-size: 12.5px; font-weight: 600;
color: rgba(255, 255, 255, 0.6); padding: 9px 2px; }
.vdl-res-spin { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgb(var(--accent-rgb)); animation: vdlSpin 0.7s linear infinite; }
@keyframes vdlSpin { to { transform: rotate(360deg); } }
.vdl-res-empty { font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 9px 2px; }
.vdl-res-head { font-size: 11.5px; font-weight: 700; color: rgba(255, 255, 255, 0.5); margin: 4px 0 10px; }
.vdl-res-head strong { color: #fff; }
.vdl-res-okn { color: #8fe7af; }
.vdl-res { border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.03);
padding: 11px 13px; margin-bottom: 8px; animation: vdlRise 0.4s both;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; }
.vdl-res:hover { border-color: rgba(var(--accent-rgb), 0.4); background: rgba(255, 255, 255, 0.055); transform: translateY(-1px); }
.vdl-res--rejected { opacity: 0.5; }
.vdl-res--rejected:hover { opacity: 0.78; }
.vdl-res-top { display: flex; align-items: flex-start; gap: 10px; }
.vdl-res-title { flex: 1; min-width: 0; font: 600 11.5px/1.45 'JetBrains Mono', ui-monospace, monospace;
color: rgba(255, 255, 255, 0.9); word-break: break-all; }
.vdl-res-grab { flex-shrink: 0; width: 30px; height: 30px; border-radius: 9px; cursor: pointer; font-size: 15px;
font-weight: 900; background: rgb(var(--accent-rgb)); color: #06210f; border: none;
transition: filter 0.15s ease, transform 0.15s ease; }
.vdl-res-grab:hover { filter: brightness(1.12); transform: translateY(-1px); }
.vdl-res-badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 9px 0; }
.vdl-rb { font-size: 10px; font-weight: 800; letter-spacing: 0.02em; padding: 2px 8px; border-radius: 6px;
text-transform: uppercase; background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8); }
.vdl-rb--res { background: rgba(var(--accent-rgb), 0.2); border-color: rgba(var(--accent-rgb), 0.42); color: #fff; }
.vdl-rb--src { background: rgba(255, 255, 255, 0.12); color: #fff; }
.vdl-rb--hdr { background: linear-gradient(100deg, rgba(245, 158, 11, 0.28), rgba(168, 85, 247, 0.28));
border-color: rgba(245, 158, 11, 0.4); color: #fde2b3; }
.vdl-res-foot { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.vdl-res-stat { font-size: 11.5px; font-weight: 700; color: rgba(255, 255, 255, 0.55); }
.vdl-res-seed { color: #8fe7af; }
.vdl-res-verdict { margin-left: auto; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; }
.vdl-res-verdict--ok { background: rgba(108, 211, 145, 0.16); color: #8fe7af; }
.vdl-res-verdict--no { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.5); }
.vdl-btn--busy { opacity: 0.7; cursor: progress; }
@media (prefers-reduced-motion: reduce) { .vdl-res, .vdl-res-spin { animation: none !important; } }