diff --git a/webui/index.html b/webui/index.html index 832fc0e5..3ba8da32 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1284,7 +1284,16 @@

Downloads

Everything you've grabbed from the video side

- +
+ + +
+ +
+ + + +
diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js index 1b0d1411..301328b6 100644 --- a/webui/static/video/video-downloads-page.js +++ b/webui/static/video/video-downloads-page.js @@ -1,17 +1,19 @@ /* * SoulSync โ€” Video Downloads page. * - * Every grab from the video side lands here. Polls /downloads/active while the page - * is open and reflects live status. Cards are created ONCE and patched in place - * (no innerHTML churn) so progress bars glide and nothing re-animates each tick โ€” - * the smooth, music-downloads feel. + * Every grab from the video side lands here. Filter tabs (All / Active / Completed / + * Failed), per-row cancel + retry, cancel-all and clear-finished โ€” the depth of the + * music downloads page. Cards are created ONCE and patched in place (no innerHTML + * churn) so progress bars glide and nothing re-animates each tick. */ (function () { 'use strict'; var URL_ACTIVE = '/api/video/downloads/active'; var URL_CLEAR = '/api/video/downloads/clear'; - var _timer = null, _wired = false; + var URL_CANCEL = '/api/video/downloads/cancel'; + var URL_RETRY = '/api/video/downloads/retry'; + var _timer = null, _wired = false, _filter = 'all'; var _cards = {}; // id -> card element (kept across polls for in-place updates) function esc(s) { @@ -23,14 +25,25 @@ return fetch(u, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); } + function postJSON(u, b) { + return fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(b || {}) }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); + } var KIND_ICON = { movie: '๐ŸŽฌ', show: '๐Ÿ“บ', episode: '๐Ÿ“บ', season: '๐Ÿ“บ', series: '๐Ÿ“บ', youtube: 'โ–ถ๏ธ' }; var STATUS = { downloading: { label: 'Downloading', st: 'dl' }, queued: { label: 'Queued', st: 'q' }, completed: { label: 'Completed', st: 'ok' }, - failed: { label: 'Failed', st: 'fail' } + failed: { label: 'Failed', st: 'fail' }, + cancelled: { label: 'Cancelled', st: 'cancel' } }; + function isActive(s) { return s === 'downloading' || s === 'queued'; } + function isFail(s) { return s === 'failed' || s === 'cancelled'; } + function matches(s) { + return _filter === 'all' || (_filter === 'active' && isActive(s)) || + (_filter === 'completed' && s === 'completed') || (_filter === 'failed' && isFail(s)); + } function fmtSize(bytes) { var gb = (bytes || 0) / (1024 * 1024 * 1024); @@ -59,13 +72,14 @@ '
' + '
' + '
' + - ''; + '' + + '
'; return el; } function patchCard(el, d) { var info = STATUS[d.status] || STATUS.downloading; - var active = d.status === 'downloading' || d.status === 'queued'; + var active = isActive(d.status); var pct = Math.max(0, Math.min(100, d.progress || 0)); var q = function (f) { return el.querySelector('[data-f="' + f + '"]'); }; @@ -80,41 +94,52 @@ var rel = q('rel'); var relTxt = (d.release_title && d.release_title !== name) ? d.release_title : ''; - if (rel.textContent !== relTxt) { rel.textContent = relTxt; rel.style.display = relTxt ? '' : 'none'; } + if (rel.textContent !== relTxt) rel.textContent = relTxt; - var bar = q('bar'); - bar.style.display = active ? '' : 'none'; - if (active) q('fill').style.width = pct + '%'; // CSS transition glides it + q('bar').style.display = active ? '' : 'none'; + if (active) q('fill').style.width = pct + '%'; - // meta line โ€” patched only when its text changes (no churn) var meta = [fmtSize(d.size_bytes)]; if (d.username) meta.push('๐Ÿ‘ค ' + d.username); if (active) meta.push(Math.round(pct) + '%'); if (d.status === 'completed' && d.dest_path) meta.push('โ†’ ' + d.dest_path); - else if (d.status === 'failed' && d.error) meta.push(d.error); + else if (isFail(d.status) && d.error) meta.push(d.error); else if (d.created_at) meta.push(ago(d.created_at)); var html = meta.map(function (m, i) { var cls = (i === 0) ? 'vdpg-m' : (d.status === 'completed' && /^โ†’/.test(m)) ? 'vdpg-m vdpg-dest' - : (d.status === 'failed' && m === d.error) ? 'vdpg-m vdpg-err' : 'vdpg-m'; + : (isFail(d.status) && m === d.error) ? 'vdpg-m vdpg-err' : 'vdpg-m'; return '' + esc(m) + ''; }).join(''); var mt = q('meta'); if (mt.innerHTML !== html) mt.innerHTML = html; + + var act = q('actions'); + var actHTML = active + ? '' + : isFail(d.status) + ? '' + : ''; + if (act.innerHTML !== actHTML) act.innerHTML = actHTML; } function render(list) { var host = document.querySelector('[data-vdpg-list]'); if (!host) return; list = list || []; - // header counts + clear button - var finished = list.filter(function (d) { return d.status === 'completed' || d.status === 'failed'; }).length; - var active = list.length - finished; - var clearBtn = document.querySelector('[data-vdpg-clear]'); if (clearBtn) clearBtn.hidden = finished === 0; + var counts = { all: list.length, active: 0, completed: 0, failed: 0 }; + list.forEach(function (d) { + if (isActive(d.status)) counts.active++; + else if (d.status === 'completed') counts.completed++; + else counts.failed++; + }); + ['all', 'active', 'completed', 'failed'].forEach(function (k) { + var n = document.querySelector('[data-vdpg-n="' + k + '"]'); if (n) n.textContent = counts[k]; + }); + var cancelAll = document.querySelector('[data-vdpg-cancel-all]'); if (cancelAll) cancelAll.hidden = counts.active === 0; + var clearBtn = document.querySelector('[data-vdpg-clear]'); if (clearBtn) clearBtn.hidden = (counts.completed + counts.failed) === 0; var sub = document.querySelector('[data-vdpg-sub]'); - if (sub) sub.textContent = list.length ? (active + ' active ยท ' + finished + ' finished') + if (sub) sub.textContent = list.length ? (counts.active + ' active ยท ' + counts.completed + ' done ยท ' + counts.failed + ' failed') : "Everything you've grabbed from the video side"; - // empty state - var empty = host.querySelector('.vdpg-empty'); if (!list.length) { _cards = {}; host.innerHTML = '
โค“
' + @@ -122,34 +147,45 @@ '
Hit Grab on a search result and it\'ll show up here.
'; return; } - if (empty) empty.remove(); + var empty = host.querySelector('.vdpg-empty'); if (empty) empty.remove(); - // reconcile: create/patch in order, then drop stale โ€” no full re-render - var seen = {}; + var seen = {}, shown = 0; list.forEach(function (d, i) { seen[d.id] = true; - var el = _cards[d.id]; - if (!el) { el = _cards[d.id] = makeCard(d); } + var el = _cards[d.id] || (_cards[d.id] = makeCard(d)); patchCard(el, d); + var vis = matches(d.status); + el.style.display = vis ? '' : 'none'; + if (vis) shown++; var atPos = host.children[i]; - if (atPos !== el) host.insertBefore(el, atPos || null); // place without re-animating + if (atPos !== el) host.insertBefore(el, atPos || null); }); Object.keys(_cards).forEach(function (id) { if (!seen[id]) { var el = _cards[id]; if (el && el.parentNode) el.parentNode.removeChild(el); delete _cards[id]; } }); + + var fe = host.querySelector('.vdpg-filter-empty'); + if (shown === 0) { + if (!fe) { fe = document.createElement('div'); fe.className = 'vdpg-filter-empty'; host.appendChild(fe); } + fe.textContent = 'Nothing ' + (_filter === 'all' ? 'here' : _filter) + ' right now.'; + } else if (fe) { fe.remove(); } + } + + function setFilter(f) { + _filter = f; + Array.prototype.forEach.call(document.querySelectorAll('[data-vdpg-filter]'), function (b) { + b.classList.toggle('vdpg-pill-f--on', b.getAttribute('data-vdpg-filter') === f); + }); + getJSON(URL_ACTIVE).then(function (d) { if (d) render(d.downloads || []); }); } function anyActive() { return !!document.querySelector('.vdpg-item[data-st="dl"], .vdpg-item[data-st="q"]'); } function poll() { - getJSON(URL_ACTIVE).then(function (d) { - if (d) render(d.downloads || []); - schedule(); - }); + getJSON(URL_ACTIVE).then(function (d) { if (d) render(d.downloads || []); schedule(); }); } function schedule() { if (_timer) clearTimeout(_timer); - // brisk while something's moving, relaxed when idle โ€” and never a hard blink. _timer = setTimeout(poll, anyActive() ? 1500 : 6000); } function start() { wire(); if (_timer) clearTimeout(_timer); poll(); } @@ -159,9 +195,27 @@ if (_wired) return; _wired = true; var clearBtn = document.querySelector('[data-vdpg-clear]'); if (clearBtn) clearBtn.addEventListener('click', function () { - fetch(URL_CLEAR, { method: 'POST', headers: { Accept: 'application/json' } }) - .then(function () { toast('Cleared finished downloads', 'success'); poll(); }) - .catch(function () { /* ignore */ }); + postJSON(URL_CLEAR, {}).then(function () { toast('Cleared finished downloads', 'success'); poll(); }); + }); + var cancelAll = document.querySelector('[data-vdpg-cancel-all]'); + if (cancelAll) cancelAll.addEventListener('click', function () { + getJSON(URL_ACTIVE).then(function (d) { + var ids = ((d && d.downloads) || []).filter(function (x) { return isActive(x.status); }).map(function (x) { return x.id; }); + Promise.all(ids.map(function (id) { return postJSON(URL_CANCEL, { id: id }); })) + .then(function () { toast('Cancelled ' + ids.length + ' download' + (ids.length === 1 ? '' : 's'), 'info'); poll(); }); + }); + }); + var pills = document.querySelector('[data-vdpg-pills]'); + if (pills) pills.addEventListener('click', function (e) { + var b = e.target.closest('[data-vdpg-filter]'); if (b) setFilter(b.getAttribute('data-vdpg-filter')); + }); + var list = document.querySelector('[data-vdpg-list]'); + if (list) list.addEventListener('click', function (e) { + var c = e.target.closest('[data-vdpg-cancel]'); + if (c) { c.disabled = true; postJSON(URL_CANCEL, { id: +c.getAttribute('data-vdpg-cancel') }).then(function () { poll(); }); return; } + var r = e.target.closest('[data-vdpg-retry]'); + if (r) { r.disabled = true; postJSON(URL_RETRY, { id: +r.getAttribute('data-vdpg-retry') }).then(function (res) { + if (res && res.ok) toast('Retrying', 'info'); else toast((res && res.error) || 'Retry failed', 'error'); poll(); }); } }); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 4a8efd0f..43a297d2 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3324,12 +3324,33 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdpg-head-row { position: relative; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; } .vdpg-title { font-size: 34px; font-weight: 900; letter-spacing: -0.03em; margin: 0; color: #fff; } .vdpg-sub { margin: 6px 0 0; font-size: 13.5px; font-weight: 600; color: rgba(255, 255, 255, 0.5); } -.vdpg-clear { flex-shrink: 0; padding: 9px 16px; border-radius: 10px; cursor: pointer; font-size: 12.5px; font-weight: 800; +.vdpg-head-actions { flex-shrink: 0; display: flex; gap: 9px; } +.vdpg-btn { padding: 9px 15px; border-radius: 10px; cursor: pointer; font-size: 12.5px; font-weight: 800; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.85); transition: all 0.15s ease; } -.vdpg-clear:hover { background: rgba(239, 68, 68, 0.16); border-color: rgba(239, 68, 68, 0.45); color: #fecaca; } -.vdpg-clear[hidden] { display: none; } +.vdpg-btn:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.26); color: #fff; } +.vdpg-btn--danger:hover { background: rgba(239, 68, 68, 0.16); border-color: rgba(239, 68, 68, 0.45); color: #fecaca; } +.vdpg-btn[hidden] { display: none; } +/* filter tabs (music-style pills) */ +.vdpg-pills { position: relative; display: flex; gap: 8px; margin-top: 18px; flex-wrap: wrap; } +.vdpg-pill-f { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 999px; cursor: pointer; + font-size: 12.5px; font-weight: 800; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); transition: all 0.15s ease; } +.vdpg-pill-f:hover { color: #fff; border-color: rgba(255, 255, 255, 0.22); } +.vdpg-pill-f--on { background: rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.5); color: #fff; } +.vdpg-pill-n { font: 800 11px/1 'JetBrains Mono', ui-monospace, monospace; padding: 2px 6px; border-radius: 999px; + background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.75); } +.vdpg-pill-f--on .vdpg-pill-n { background: rgba(var(--accent-rgb), 0.3); color: #fff; } .vdpg-list { display: flex; flex-direction: column; gap: 11px; } +.vdpg-filter-empty { text-align: center; padding: 40px 20px; font-size: 13.5px; color: rgba(255, 255, 255, 0.4); } +/* per-row actions */ +.vdpg-actions { flex-shrink: 0; display: flex; align-items: center; gap: 7px; } +.vdpg-act { width: 34px; height: 34px; border-radius: 10px; cursor: pointer; font-size: 15px; font-weight: 900; + display: grid; place-items: center; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); + color: rgba(255, 255, 255, 0.7); transition: all 0.15s ease; } +.vdpg-act:disabled { opacity: 0.5; cursor: default; } +.vdpg-act--cancel:hover { background: rgba(239, 68, 68, 0.18); border-color: rgba(239, 68, 68, 0.5); color: #fca5a5; } +.vdpg-act--retry:hover { background: rgba(var(--accent-rgb), 0.22); border-color: rgba(var(--accent-rgb), 0.5); color: #fff; transform: rotate(-90deg); } /* card is created ONCE then patched in place โ€” the entrance animation plays a single time on mount, never on a poll (that was the old 'blink'). */ @@ -3342,6 +3363,8 @@ body[data-side="video"] #soulsync-toggle { display: none; } background: linear-gradient(100deg, rgba(var(--accent-rgb), 0.06), rgba(255, 255, 255, 0.03) 55%); } .vdpg-item[data-st="ok"] { border-left-color: #6cd391; } .vdpg-item[data-st="fail"] { border-left-color: #ef4444; } +.vdpg-item[data-st="cancel"] { border-left-color: rgba(255, 255, 255, 0.35); } +.vdpg-item[data-st="cancel"], .vdpg-item[data-st="fail"] { opacity: 0.82; } .vdpg-item[data-st="q"] { border-left-color: rgba(255, 255, 255, 0.3); } .vdpg-ic { flex-shrink: 0; width: 50px; height: 50px; border-radius: 13px; display: grid; place-items: center; font-size: 24px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08); } @@ -3356,6 +3379,7 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdpg-pill[data-st="dl"] { background: rgba(var(--accent-rgb), 0.18); color: rgb(var(--accent-rgb)); animation: vdpgPulse 1.6s ease-in-out infinite; } .vdpg-pill[data-st="ok"] { background: rgba(108, 211, 145, 0.18); color: #8fe7af; } .vdpg-pill[data-st="fail"] { background: rgba(239, 68, 68, 0.18); color: #fca5a5; } +.vdpg-pill[data-st="cancel"] { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.65); } .vdpg-rel { font: 500 11px/1.3 'JetBrains Mono', ui-monospace, monospace; color: rgba(255, 255, 255, 0.4); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .vdpg-rel:empty { display: none; }