downloads page: Cinema per-type theming + sidebar live count + status polish

- 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.
This commit is contained in:
BoulderBadgeDad 2026-06-26 11:22:56 -07:00
parent cc8cc78b97
commit 458bf01599
4 changed files with 126 additions and 5 deletions

View file

@ -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

View file

@ -354,6 +354,7 @@
<a class="nav-button" data-video-page="video-downloads" href="/video-downloads">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Downloads</span>
<span class="dl-nav-badge hidden" data-video-downloads-badge>0</span>
</a>
<a class="nav-button" data-video-page="video-calendar" href="/video-calendar">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>

View file

@ -21,6 +21,12 @@
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 =
'<div class="adl-row-art adl-row-art-empty vdpg-art" data-f="ic"></div>' +
'<div class="vdpg-artwrap"><div class="adl-row-art adl-row-art-empty vdpg-art" data-f="ic"></div>' +
'<span class="vdpg-tbadge" data-f="tbadge"></span></div>' +
'<div class="adl-row-info">' +
'<div class="adl-row-title" data-f="name"></div>' +
'<div class="adl-row-meta" data-f="meta"></div>' +
@ -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 ? '<span class="vdpg-qchip">' + esc(d.quality_label) + '</span>' : '';
@ -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;
})();

View file

@ -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;