video Downloads page: smooth in-place updates (no more blink) + polish
The blink was every poll re-running each card's entrance animation via innerHTML churn. Now cards are created ONCE and PATCHED in place (data-st attribute swaps, progress width glides over a 1.4s transition, meta only rewrites when its text changes), so nothing re-animates on a tick — the music-downloads smoothness. Also: adaptive polling (1.5s while active, 6s idle), border/pill colours TRANSITION on status change, downloading cards get an accent tint + glow, a relative 'started Xs ago', and the % moved inline. Balance clean.
This commit is contained in:
parent
fff6448a14
commit
7e0726d5eb
2 changed files with 135 additions and 73 deletions
|
|
@ -2,8 +2,9 @@
|
|||
* SoulSync — Video Downloads page.
|
||||
*
|
||||
* Every grab from the video side lands here. Polls /downloads/active while the page
|
||||
* is open and renders live status: a progress bar while downloading, then the file's
|
||||
* final library destination when it completes. Self-contained; isolated .vdpg-* styling.
|
||||
* 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.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
var URL_ACTIVE = '/api/video/downloads/active';
|
||||
var URL_CLEAR = '/api/video/downloads/clear';
|
||||
var _timer = null, _wired = false;
|
||||
var _cards = {}; // id -> card element (kept across polls for in-place updates)
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
|
|
@ -24,86 +26,141 @@
|
|||
|
||||
var KIND_ICON = { movie: '🎬', show: '📺', episode: '📺', season: '📺', series: '📺', youtube: '▶️' };
|
||||
var STATUS = {
|
||||
downloading: { label: 'Downloading', cls: 'dl' },
|
||||
queued: { label: 'Queued', cls: 'q' },
|
||||
completed: { label: 'Completed', cls: 'ok' },
|
||||
failed: { label: 'Failed', cls: 'fail' }
|
||||
downloading: { label: 'Downloading', st: 'dl' },
|
||||
queued: { label: 'Queued', st: 'q' },
|
||||
completed: { label: 'Completed', st: 'ok' },
|
||||
failed: { label: 'Failed', st: 'fail' }
|
||||
};
|
||||
|
||||
function fmtGB(bytes) {
|
||||
function fmtSize(bytes) {
|
||||
var gb = (bytes || 0) / (1024 * 1024 * 1024);
|
||||
return gb >= 0.1 ? (Math.round(gb * 10) / 10) + ' GB' : Math.round((bytes || 0) / (1024 * 1024)) + ' MB';
|
||||
}
|
||||
function ago(ts) {
|
||||
if (!ts) return '';
|
||||
var t = Date.parse(String(ts).replace(' ', 'T') + 'Z');
|
||||
if (isNaN(t)) return '';
|
||||
var s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.round(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.round(s / 3600) + 'h ago';
|
||||
return Math.round(s / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function itemHTML(d) {
|
||||
var st = STATUS[d.status] || STATUS.downloading;
|
||||
var icon = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬';
|
||||
var pct = Math.max(0, Math.min(100, d.progress || 0));
|
||||
var name = d.title || d.release_title || 'Download';
|
||||
var bar = (d.status === 'downloading' || d.status === 'queued')
|
||||
? '<div class="vdpg-bar"><div class="vdpg-bar-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<div class="vdpg-pct">' + (Math.round(pct)) + '%</div>'
|
||||
: '';
|
||||
var meta = [];
|
||||
meta.push('<span>' + fmtGB(d.size_bytes) + '</span>');
|
||||
if (d.username) meta.push('<span>👤 ' + esc(d.username) + '</span>');
|
||||
if (d.status === 'completed' && d.dest_path) meta.push('<span class="vdpg-dest">→ ' + esc(d.dest_path) + '</span>');
|
||||
if (d.status === 'failed' && d.error) meta.push('<span class="vdpg-err">' + esc(d.error) + '</span>');
|
||||
return '<div class="vdpg-item vdpg-item--' + st.cls + '">' +
|
||||
'<div class="vdpg-ic">' + icon + '</div>' +
|
||||
function makeCard(d) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'vdpg-item';
|
||||
el.setAttribute('data-dl-id', d.id);
|
||||
el.innerHTML =
|
||||
'<div class="vdpg-ic" data-f="ic"></div>' +
|
||||
'<div class="vdpg-body">' +
|
||||
'<div class="vdpg-row1"><span class="vdpg-name">' + esc(name) + '</span>' +
|
||||
'<span class="vdpg-pill vdpg-pill--' + st.cls + '">' + st.label + '</span></div>' +
|
||||
(d.release_title && d.release_title !== name ? '<div class="vdpg-rel" title="' + esc(d.release_title) + '">' + esc(d.release_title) + '</div>' : '') +
|
||||
bar +
|
||||
'<div class="vdpg-meta">' + meta.join('') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'<div class="vdpg-row1"><span class="vdpg-name" data-f="name"></span>' +
|
||||
'<span class="vdpg-pill" data-f="pill"></span></div>' +
|
||||
'<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>';
|
||||
return el;
|
||||
}
|
||||
|
||||
function patchCard(el, d) {
|
||||
var info = STATUS[d.status] || STATUS.downloading;
|
||||
var active = d.status === 'downloading' || d.status === 'queued';
|
||||
var pct = Math.max(0, Math.min(100, d.progress || 0));
|
||||
var q = function (f) { return el.querySelector('[data-f="' + f + '"]'); };
|
||||
|
||||
if (el.getAttribute('data-st') !== info.st) el.setAttribute('data-st', info.st);
|
||||
var ic = q('ic'); var icon = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬';
|
||||
if (ic.textContent !== icon) ic.textContent = icon;
|
||||
var name = d.title || d.release_title || 'Download';
|
||||
var nm = q('name'); if (nm.textContent !== name) nm.textContent = name;
|
||||
var pill = q('pill');
|
||||
if (pill.textContent !== info.label) pill.textContent = info.label;
|
||||
if (pill.getAttribute('data-st') !== info.st) pill.setAttribute('data-st', info.st);
|
||||
|
||||
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'; }
|
||||
|
||||
var bar = q('bar');
|
||||
bar.style.display = active ? '' : 'none';
|
||||
if (active) q('fill').style.width = pct + '%'; // CSS transition glides it
|
||||
|
||||
// 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 (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';
|
||||
return '<span class="' + cls + '">' + esc(m) + '</span>';
|
||||
}).join('');
|
||||
var mt = q('meta'); if (mt.innerHTML !== html) mt.innerHTML = html;
|
||||
}
|
||||
|
||||
function render(list) {
|
||||
var host = document.querySelector('[data-vdpg-list]'); if (!host) return;
|
||||
list = list || [];
|
||||
var clearBtn = document.querySelector('[data-vdpg-clear]');
|
||||
|
||||
// header counts + clear button
|
||||
var finished = list.filter(function (d) { return d.status === 'completed' || d.status === 'failed'; }).length;
|
||||
if (clearBtn) clearBtn.hidden = finished === 0;
|
||||
var active = list.filter(function (d) { return d.status === 'downloading' || d.status === 'queued'; }).length;
|
||||
var active = list.length - finished;
|
||||
var clearBtn = document.querySelector('[data-vdpg-clear]'); if (clearBtn) clearBtn.hidden = finished === 0;
|
||||
var sub = document.querySelector('[data-vdpg-sub]');
|
||||
if (sub) sub.textContent = list.length
|
||||
? (active + ' active · ' + finished + ' finished')
|
||||
if (sub) sub.textContent = list.length ? (active + ' active · ' + finished + ' finished')
|
||||
: "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>' +
|
||||
'<div class="vdpg-empty-t">No downloads yet</div>' +
|
||||
'<div class="vdpg-empty-s">Hit Grab on a search result and it\'ll show up here.</div></div>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = list.map(itemHTML).join('');
|
||||
if (empty) empty.remove();
|
||||
|
||||
// reconcile: create/patch in order, then drop stale — no full re-render
|
||||
var seen = {};
|
||||
list.forEach(function (d, i) {
|
||||
seen[d.id] = true;
|
||||
var el = _cards[d.id];
|
||||
if (!el) { el = _cards[d.id] = makeCard(d); }
|
||||
patchCard(el, d);
|
||||
var atPos = host.children[i];
|
||||
if (atPos !== el) host.insertBefore(el, atPos || null); // place without re-animating
|
||||
});
|
||||
Object.keys(_cards).forEach(function (id) {
|
||||
if (!seen[id]) { var el = _cards[id]; if (el && el.parentNode) el.parentNode.removeChild(el); delete _cards[id]; }
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
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 anyActive() {
|
||||
return !!document.querySelector('.vdpg-item--dl, .vdpg-item--q');
|
||||
function poll() {
|
||||
getJSON(URL_ACTIVE).then(function (d) {
|
||||
if (d) render(d.downloads || []);
|
||||
schedule();
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
load();
|
||||
if (_timer) clearInterval(_timer);
|
||||
// Poll while open; the monitor moves files server-side regardless.
|
||||
_timer = setInterval(function () { load(); }, 2500);
|
||||
wire();
|
||||
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 stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
|
||||
function start() { wire(); if (_timer) clearTimeout(_timer); poll(); }
|
||||
function stop() { if (_timer) { clearTimeout(_timer); _timer = null; } }
|
||||
|
||||
function wire() {
|
||||
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'); load(); })
|
||||
.then(function () { toast('Cleared finished downloads', 'success'); poll(); })
|
||||
.catch(function () { /* ignore */ });
|
||||
});
|
||||
}
|
||||
|
|
@ -111,11 +168,8 @@
|
|||
document.addEventListener('soulsync:video-page-shown', function (e) {
|
||||
if (e.detail === 'video-downloads') start(); else stop();
|
||||
});
|
||||
// A fresh grab elsewhere → if the page is open, reflect it quickly.
|
||||
document.addEventListener('soulsync:video-download-started', function () {
|
||||
if (document.querySelector('[data-video-subpage="video-downloads"]:not([hidden])')) setTimeout(load, 400);
|
||||
if (document.querySelector('[data-video-subpage="video-downloads"]:not([hidden])')) setTimeout(poll, 350);
|
||||
});
|
||||
// Keep polling lively only while something is in flight (handled by the 2.5s timer);
|
||||
// anyActive() is exposed for future adaptive intervals.
|
||||
window._vdpgAnyActive = anyActive;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3326,45 +3326,53 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdpg-clear[hidden] { display: none; }
|
||||
.vdpg-list { display: flex; flex-direction: column; gap: 11px; }
|
||||
|
||||
/* 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'). */
|
||||
.vdpg-item { display: flex; gap: 15px; padding: 15px 17px; border-radius: 16px; align-items: center;
|
||||
background: rgba(255, 255, 255, 0.035); border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
animation: vdlRise 0.45s both; transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; }
|
||||
background: rgba(255, 255, 255, 0.035); border: 1px solid rgba(255, 255, 255, 0.08); border-left: 3px solid transparent;
|
||||
animation: vdlRise 0.45s both;
|
||||
transition: border-color 0.3s ease, background 0.25s ease, transform 0.18s ease; }
|
||||
.vdpg-item:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.055); }
|
||||
.vdpg-item--dl { border-left: 3px solid rgb(var(--accent-rgb)); }
|
||||
.vdpg-item--ok { border-left: 3px solid #6cd391; }
|
||||
.vdpg-item--fail { border-left: 3px solid #ef4444; }
|
||||
.vdpg-item--q { border-left: 3px solid rgba(255, 255, 255, 0.3); }
|
||||
.vdpg-item[data-st="dl"] { border-left-color: rgb(var(--accent-rgb));
|
||||
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="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); }
|
||||
.vdpg-item[data-st="dl"] .vdpg-ic { background: rgba(var(--accent-rgb), 0.12); border-color: rgba(var(--accent-rgb), 0.3); }
|
||||
.vdpg-body { flex: 1; min-width: 0; }
|
||||
.vdpg-row1 { display: flex; align-items: center; gap: 10px; }
|
||||
.vdpg-name { font-size: 15.5px; font-weight: 800; color: #fff; letter-spacing: -0.01em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vdpg-pill { flex-shrink: 0; font-size: 10px; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 999px; }
|
||||
.vdpg-pill--dl { background: rgba(var(--accent-rgb), 0.18); color: rgb(var(--accent-rgb)); animation: vdpgPulse 1.6s ease-in-out infinite; }
|
||||
.vdpg-pill--q { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.6); }
|
||||
.vdpg-pill--ok { background: rgba(108, 211, 145, 0.18); color: #8fe7af; }
|
||||
.vdpg-pill--fail { background: rgba(239, 68, 68, 0.18); color: #fca5a5; }
|
||||
padding: 3px 9px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.6);
|
||||
transition: background 0.25s ease, color 0.25s ease; }
|
||||
.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-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; }
|
||||
.vdpg-bar { margin-top: 10px; height: 7px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
|
||||
.vdpg-bar-fill { height: 100%; border-radius: 999px; transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
.vdpg-bar-fill { height: 100%; width: 0; border-radius: 999px; transition: width 1.4s cubic-bezier(0.3, 0.8, 0.3, 1);
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.7), rgb(var(--accent-rgb))); position: relative; }
|
||||
.vdpg-bar-fill::after { content: ''; position: absolute; inset: 0; border-radius: 999px;
|
||||
background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.35) 50%, transparent 70%);
|
||||
animation: vdpgShimmer 1.4s linear infinite; }
|
||||
.vdpg-pct { margin-top: 4px; font-size: 10.5px; font-weight: 800; color: rgba(255, 255, 255, 0.5); }
|
||||
.vdpg-meta { display: flex; flex-wrap: wrap; gap: 13px; margin-top: 8px; font-size: 11.5px; font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.55); }
|
||||
.vdpg-dest { color: #8fe7af; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; }
|
||||
animation: vdpgShimmer 1.6s linear infinite; }
|
||||
.vdpg-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 13px; margin-top: 9px; font-size: 11.5px;
|
||||
font-weight: 600; color: rgba(255, 255, 255, 0.55); }
|
||||
.vdpg-m { display: inline-flex; align-items: center; }
|
||||
.vdpg-dest { color: #8fe7af; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px;
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vdpg-err { color: #fca5a5; }
|
||||
.vdpg-empty { text-align: center; padding: 70px 20px; color: rgba(255, 255, 255, 0.45); }
|
||||
.vdpg-empty { text-align: center; padding: 70px 20px; color: rgba(255, 255, 255, 0.45); animation: vdlRise 0.5s both; }
|
||||
.vdpg-empty-ic { font-size: 46px; opacity: 0.5; }
|
||||
.vdpg-empty-t { font-size: 17px; font-weight: 800; color: rgba(255, 255, 255, 0.7); margin-top: 12px; }
|
||||
.vdpg-empty-s { font-size: 13px; margin-top: 6px; }
|
||||
@keyframes vdpgPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
||||
@keyframes vdpgShimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vdpg-item, .vdpg-pill--dl, .vdpg-bar-fill::after { animation: none !important; }
|
||||
.vdpg-item, .vdpg-pill[data-st="dl"], .vdpg-bar-fill::after { animation: none !important; }
|
||||
.vdpg-bar-fill { transition: none; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue