diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js index bdcce8b3..1b0d1411 100644 --- a/webui/static/video/video-downloads-page.js +++ b/webui/static/video/video-downloads-page.js @@ -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') - ? '
' + - '
' + (Math.round(pct)) + '%
' - : ''; - var meta = []; - meta.push('' + fmtGB(d.size_bytes) + ''); - if (d.username) meta.push('๐Ÿ‘ค ' + esc(d.username) + ''); - if (d.status === 'completed' && d.dest_path) meta.push('โ†’ ' + esc(d.dest_path) + ''); - if (d.status === 'failed' && d.error) meta.push('' + esc(d.error) + ''); - return '
' + - '
' + icon + '
' + + function makeCard(d) { + var el = document.createElement('div'); + el.className = 'vdpg-item'; + el.setAttribute('data-dl-id', d.id); + el.innerHTML = + '
' + '
' + - '
' + esc(name) + '' + - '' + st.label + '
' + - (d.release_title && d.release_title !== name ? '
' + esc(d.release_title) + '
' : '') + - bar + - '
' + meta.join('') + '
' + - '
' + - '
'; + '
' + + '
' + + '
' + + '
' + + '
' + + ''; + 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 '' + esc(m) + ''; + }).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 = '
โค“
' + '
No downloads yet
' + '
Hit Grab on a search result and it\'ll show up here.
'; 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; })(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index d02273da..7e98b3b6 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; } }