video downloads Phase B: page redesign — filter tabs, cancel/retry, depth
Brings the Downloads page up toward the music page's depth: - Filter tabs (music-style pills): All / Active / Completed / Failed, each with a live count; clicking filters the list. Cancelled rolls under Failed. - Header actions: Cancel all (active) + Clear finished, shown only when relevant; a live 'N active · N done · N failed' subline. - Per-row actions: ✕ Cancel on active rows (→ /downloads/cancel), ↻ Retry on failed/cancelled rows (→ /downloads/retry, re-grabs the release). - Cancelled status styling (pill + dimmed row). Still the smooth in-place patching (no blink), adaptive polling, empty + filtered-empty states. Balance clean. Phase C (auto-retry + alternate-query retry) next.
This commit is contained in:
parent
0bb77bf782
commit
d25be2c3c6
3 changed files with 127 additions and 40 deletions
|
|
@ -1284,7 +1284,16 @@
|
|||
<h1 class="vdpg-title">Downloads</h1>
|
||||
<p class="vdpg-sub" data-vdpg-sub>Everything you've grabbed from the video side</p>
|
||||
</div>
|
||||
<button class="vdpg-clear" type="button" data-vdpg-clear hidden>Clear finished</button>
|
||||
<div class="vdpg-head-actions">
|
||||
<button class="vdpg-btn vdpg-btn--danger" type="button" data-vdpg-cancel-all hidden>Cancel all</button>
|
||||
<button class="vdpg-btn" type="button" data-vdpg-clear hidden>Clear finished</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vdpg-pills" data-vdpg-pills>
|
||||
<button class="vdpg-pill-f vdpg-pill-f--on" type="button" data-vdpg-filter="all">All <span class="vdpg-pill-n" data-vdpg-n="all">0</span></button>
|
||||
<button class="vdpg-pill-f" type="button" data-vdpg-filter="active">Active <span class="vdpg-pill-n" data-vdpg-n="active">0</span></button>
|
||||
<button class="vdpg-pill-f" type="button" data-vdpg-filter="completed">Completed <span class="vdpg-pill-n" data-vdpg-n="completed">0</span></button>
|
||||
<button class="vdpg-pill-f" type="button" data-vdpg-filter="failed">Failed <span class="vdpg-pill-n" data-vdpg-n="failed">0</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vdpg-list" data-vdpg-list></div>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
'<div class="vdpg-rel" data-f="rel"></div>' +
|
||||
'<div class="vdpg-bar" data-f="bar"><div class="vdpg-bar-fill" data-f="fill"></div></div>' +
|
||||
'<div class="vdpg-meta" data-f="meta"></div>' +
|
||||
'</div>';
|
||||
'</div>' +
|
||||
'<div class="vdpg-actions" data-f="actions"></div>';
|
||||
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 '<span class="' + cls + '">' + esc(m) + '</span>';
|
||||
}).join('');
|
||||
var mt = q('meta'); if (mt.innerHTML !== html) mt.innerHTML = html;
|
||||
|
||||
var act = q('actions');
|
||||
var actHTML = active
|
||||
? '<button class="vdpg-act vdpg-act--cancel" type="button" data-vdpg-cancel="' + d.id + '" title="Cancel">✕</button>'
|
||||
: isFail(d.status)
|
||||
? '<button class="vdpg-act vdpg-act--retry" type="button" data-vdpg-retry="' + d.id + '" title="Retry">↻</button>'
|
||||
: '';
|
||||
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 = '<div class="vdpg-empty"><div class="vdpg-empty-ic">⤓</div>' +
|
||||
|
|
@ -122,34 +147,45 @@
|
|||
'<div class="vdpg-empty-s">Hit Grab on a search result and it\'ll show up here.</div></div>';
|
||||
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(); }); }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue