From 458bf0159910efb41509879321a7166fdd8d60f3 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 11:22:56 -0700 Subject: [PATCH] downloads page: Cinema per-type theming + sidebar live count + status polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - per-type colour (movie=azure / TV=violet / youtube=red) on a left accent bar, the status pill, the progress fill + quality chip β€” tell the three apart at a glance. - bigger poster art + a type corner badge (reads even with a poster). - status is now a coloured pill; queued/searching/importing get an indeterminate shimmer instead of a frozen bar; 'Importing' + 'Import failed' surfaced with their own context lines ('Moving into your library…'). - sidebar Downloads nav shows a live active-count badge, kept fresh off-page by a light poll (skips the fetch while the page's own poll is running). all scoped to .vdpg-card so the music downloads page is untouched. contract-tested. --- tests/test_video_downloads_page.py | 33 ++++++++++++++ webui/index.html | 1 + webui/static/video/video-downloads-page.js | 53 ++++++++++++++++++++-- webui/static/video/video-side.css | 44 ++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 tests/test_video_downloads_page.py diff --git a/tests/test_video_downloads_page.py b/tests/test_video_downloads_page.py new file mode 100644 index 00000000..9bc0405f --- /dev/null +++ b/tests/test_video_downloads_page.py @@ -0,0 +1,33 @@ +"""Downloads page UX overhaul β€” string-contract level (like test_video_side_shell.py), so a +refactor that silently drops the per-type theming, the sidebar count, or the new statuses +fails here.""" + +from __future__ import annotations + +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_JS = (_ROOT / "webui" / "static" / "video" / "video-downloads-page.js").read_text(encoding="utf-8") +_CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encoding="utf-8") +_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace") + + +def test_cards_carry_a_type_for_cinema_theming(): + assert "function dlType(" in _JS + assert "data-vtype" in _JS # set on each card + # the three Cinema palette colours are defined per type + for vt in ('[data-vtype="movie"]', '[data-vtype="tv"]', '[data-vtype="youtube"]'): + assert vt in _CSS, vt + assert "--vt: 79, 143, 247" in _CSS and "--vt: 240, 69, 75" in _CSS # azure + red + + +def test_importing_is_a_first_class_active_status(): + assert "importing:" in _JS and "import_failed:" in _JS + assert "s === 'importing'" in _JS # counts as active + assert "vdpg-prog-indet" in _JS # indeterminate sweep for queued/searching/importing + + +def test_sidebar_has_a_live_downloads_count(): + assert "data-video-downloads-badge" in _INDEX # the nav badge element + assert "function setDownloadsBadge(" in _JS + assert "function badgePoll(" in _JS # stays live off-page too diff --git a/webui/index.html b/webui/index.html index 73c613d0..e971aab6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -354,6 +354,7 @@ Downloads + diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js index 854144f9..86fc502a 100644 --- a/webui/static/video/video-downloads-page.js +++ b/webui/static/video/video-downloads-page.js @@ -21,6 +21,12 @@ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function toast(m, t) { if (typeof showToast === 'function') showToast(m, t); } + function setDownloadsBadge(n) { + var b = document.querySelector('[data-video-downloads-badge]'); + if (!b) return; + if (n > 0) { b.textContent = n > 99 ? '99+' : n; b.classList.remove('hidden'); } + else { b.classList.add('hidden'); } + } function getJSON(u) { return fetch(u, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); @@ -31,6 +37,11 @@ } var KIND_ICON = { movie: '🎬', show: 'πŸ“Ί', episode: 'πŸ“Ί', season: 'πŸ“Ί', series: 'πŸ“Ί', youtube: '▢️' }; + // collapse the kinds into the three colour groups (Cinema palette in CSS): movie / tv / youtube + function dlType(kind) { + var k = (kind || '').toLowerCase(); + return k === 'youtube' ? 'youtube' : (k === 'movie' ? 'movie' : 'tv'); + } // status -> { label, cls } where cls is the music .adl-row-/.adl-status-dot class var STATUS = { downloading: { label: 'Downloading', cls: 'active' }, @@ -59,10 +70,11 @@ function makeCard(d) { var el = document.createElement('div'); - el.className = 'adl-row'; + el.className = 'vdpg-card adl-row'; el.setAttribute('data-dl-id', d.id); el.innerHTML = - '
' + + '
' + + '
' + '
' + '
' + '
' + @@ -77,13 +89,21 @@ function patchCard(el, d) { var info = STATUS[d.status] || STATUS.downloading; var cls = info.cls, active = isActive(d.status); - var showBar = d.status === 'downloading' || d.status === 'queued'; + var showBar = active; // downloading/queued/searching/importing all get a bar + // queued/searching/importing have no real % β†’ an indeterminate shimmer (not "frozen at 0/100") + var indet = d.status === 'queued' || d.status === 'searching' || d.status === 'importing'; var pct = Math.max(0, Math.min(100, d.progress || 0)); var q = function (f) { return el.querySelector('[data-f="' + f + '"]'); }; - var want = 'adl-row adl-row-' + cls; + var vt = dlType(d.kind); + if (el.getAttribute('data-vtype') !== vt) el.setAttribute('data-vtype', vt); + var want = 'vdpg-card adl-row adl-row-' + cls; if (el.className !== want) el.className = want; + // type badge on the art corner (so you can tell movie / TV / youtube even with a poster) + var tb = q('tbadge'); + if (tb) { var tbi = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬'; if (tb.textContent !== tbi) tb.textContent = tbi; } + // poster art tile (falls back to the kind emoji) var ic = q('ic'); if (d.poster_url) { @@ -102,6 +122,8 @@ var ctx; if (d.status === 'completed' && d.dest_path) ctx = 'β†’ ' + d.dest_path; else if (d.status === 'searching') ctx = 'Trying another release…'; + else if (d.status === 'importing') ctx = 'Moving into your library…'; + else if (d.status === 'queued') ctx = 'Waiting for a free slot…'; else if (showBar) ctx = [fmtSize(d.size_bytes), d.username ? ('πŸ‘€ ' + d.username) : '', Math.round(pct) + '%'].filter(Boolean).join(' Β· '); else ctx = (d.release_title && d.release_title !== (d.title || '')) ? d.release_title : fmtSize(d.size_bytes); var chip = d.quality_label ? '' + esc(d.quality_label) + '' : ''; @@ -115,7 +137,10 @@ var bar = q('bar'); bar.style.display = showBar ? '' : 'none'; - if (showBar) q('fill').style.width = pct + '%'; + if (showBar) { + bar.classList.toggle('vdpg-prog-indet', indet); + q('fill').style.width = indet ? '100%' : pct + '%'; + } var st = q('status'); var stWant = 'adl-row-status ' + cls; if (st.className !== stWant) st.className = stWant; @@ -148,6 +173,7 @@ else if (d.status === 'completed') counts.completed++; else counts.failed++; }); + setDownloadsBadge(counts.active); // sidebar live count (this page's poll keeps it fresh) var cancelAll = document.querySelector('[data-vdpg-cancel-all]'); if (cancelAll) cancelAll.style.display = counts.active ? '' : 'none'; var clearBtn = document.querySelector('[data-vdpg-clear]'); if (clearBtn) clearBtn.style.display = (counts.completed + counts.failed) ? '' : 'none'; var sub = document.querySelector('[data-vdpg-sub]'); @@ -248,5 +274,22 @@ document.addEventListener('soulsync:video-download-started', function () { if (document.querySelector('[data-video-subpage="video-downloads"]:not([hidden])')) setTimeout(poll, 350); }); + + // Keep the sidebar Downloads badge live even when you're NOT on the page (the on-page + // poll already refreshes it, so skip the fetch then). Only runs on the video side. + var _badgeTimer = null; + function badgePoll() { + var onVideo = document.body.getAttribute('data-side') === 'video'; + if (onVideo && !_onPage() && !document.hidden) { + getJSON(URL_ACTIVE).then(function (d) { + if (d) setDownloadsBadge((d.downloads || []).filter(function (x) { return isActive(x.status); }).length); + scheduleBadgePoll(); + }); + } else { scheduleBadgePoll(); } + } + function scheduleBadgePoll() { if (_badgeTimer) clearTimeout(_badgeTimer); _badgeTimer = setTimeout(badgePoll, 8000); } + document.addEventListener('soulsync:video-wishlist-changed', function () { setTimeout(badgePoll, 200); }); + scheduleBadgePoll(); + window._vdpgAnyActive = anyActive; })(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 5877e8d8..6cc63561 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3753,6 +3753,50 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdpg-row-retry:disabled { opacity: 0.5 !important; pointer-events: none; } @media (hover: none) { .vdpg-row-retry { opacity: 1; pointer-events: auto; transform: none; } } +/* ───────────────────────── Downloads β€” Cinema per-type theming ─────────────────── + movie = azure Β· TV = violet Β· youtube = red. --vt is the type's RGB triplet, used for + the left accent bar, status pill, progress fill + quality chip. Scoped to .vdpg-card so + the music downloads page (.adl-row) is byte-untouched. */ +.vdpg-card[data-vtype="movie"] { --vt: 79, 143, 247; } +.vdpg-card[data-vtype="tv"] { --vt: 164, 114, 245; } +.vdpg-card[data-vtype="youtube"] { --vt: 240, 69, 75; } +.vdpg-card { position: relative; overflow: hidden; padding-left: 16px; + transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease; } +.vdpg-card::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: rgb(var(--vt, 255, 255, 255)); opacity: 0.9; } +.vdpg-card:hover { transform: translateY(-1px); background: rgba(var(--vt, 255, 255, 255), 0.05); + box-shadow: 0 7px 24px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(var(--vt, 255, 255, 255), 0.14); } + +/* bigger poster art + a type corner badge (so the type reads even with a poster) */ +.vdpg-artwrap { position: relative; flex-shrink: 0; } +.vdpg-card .adl-row-art { width: 50px; height: 74px; border-radius: 8px; } +.vdpg-tbadge { position: absolute; right: -5px; bottom: -5px; width: 20px; height: 20px; border-radius: 6px; + display: grid; place-items: center; font-size: 11px; line-height: 1; color: #fff; + background: rgb(var(--vt, 90, 90, 90)); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(0, 0, 0, 0.4); } + +/* status as a colored pill */ +.vdpg-card .adl-row-status { padding: 4px 11px; border-radius: 999px; min-width: 0; gap: 6px; + border: 1px solid transparent; font-weight: 700; font-size: 0.72rem; } +.vdpg-card .adl-row-status.active { color: rgb(var(--vt)); background: rgba(var(--vt), 0.14); border-color: rgba(var(--vt), 0.4); } +.vdpg-card .adl-row-status.queued { color: rgba(255, 255, 255, 0.55); background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); } +.vdpg-card .adl-row-status.completed { color: #2bd17e; background: rgba(34, 197, 94, 0.14); border-color: rgba(34, 197, 94, 0.36); } +.vdpg-card .adl-row-status.failed { color: #f87171; background: rgba(239, 68, 68, 0.14); border-color: rgba(239, 68, 68, 0.36); } +.vdpg-card .adl-row-status.cancelled { background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.1); } +.vdpg-card .adl-row-status .adl-status-dot { width: 6px; height: 6px; background: currentColor; } +.vdpg-card.adl-row-active .adl-row-status .adl-status-dot { animation: vdpg-pulse 1.4s ease-in-out infinite; } + +/* progress in the type color; queued/searching/importing get an indeterminate sweep */ +.vdpg-card .vdpg-prog-fill { background: linear-gradient(90deg, rgba(var(--vt), 0.6), rgb(var(--vt))); } +.vdpg-card .vdpg-prog-indet .vdpg-prog-fill { width: 100% !important; transition: none; + background: linear-gradient(90deg, transparent, rgb(var(--vt)), transparent); + background-size: 38% 100%; background-repeat: no-repeat; animation: vdpg-indet 1.3s ease-in-out infinite; } +@keyframes vdpg-indet { 0% { background-position: -40% 0; } 100% { background-position: 140% 0; } } +@keyframes vdpg-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + +/* quality chip follows the type color */ +.vdpg-card .vdpg-qchip { background: rgba(var(--vt), 0.16); border-color: rgba(var(--vt), 0.36); } + /* Automation Hub panes that are music-specific β€” emptied on the video side for now. */ .vauto-hub-soon { padding: 36px 20px; text-align: center; font-size: 13.5px; font-weight: 600;