From 81d6f0e332480dcdbfb737d031cea44ad34060a4 Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:55:28 +0200 Subject: [PATCH] UI: global download banner above the status strip A sticky-feeling banner appears whenever any job is queued or active. It shows the eyebrow (Downloading / Queued), the current title's name, a "3 of 9" chip when more than one job is in this batch, plus percent, speed, and ETA. A thin gradient bar runs edge-to-edge along the bottom so the at-a-glance state matches the per-card progress. Batch counting: jobs with state in {queued, active, done} make up the batch; done count + 1 is the current position. When all queued jobs finish and roll out of the pool list the banner hides on the next poll. While the active job's manifest is still being fetched (no total_bytes yet) the bar runs an indeterminate sweep so the user isn't staring at a frozen 0%. Speed comes from the existing per-job smoothed estimate in reconcileFromJobs so the banner shares one source of truth with the per-game tiles. Render hooks: renderGames() and applyDownloadProgress() both call renderGlobalStatus(), so every refresh path keeps it in sync without adding a separate timer. --- web/app.js | 63 ++++++++++++++++++++++++++++++++ web/index.html | 23 ++++++++++++ web/styles.css | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/web/app.js b/web/app.js index 40bc39e..76ccf62 100644 --- a/web/app.js +++ b/web/app.js @@ -128,6 +128,14 @@ function bindElements() { refreshBtn: document.getElementById("refreshBtn"), updateAllBtn: document.getElementById("updateAllBtn"), brandVersion: document.getElementById("brandVersion"), + globalDl: document.getElementById("globalDl"), + globalDlState: document.getElementById("globalDlState"), + globalDlName: document.getElementById("globalDlName"), + globalDlPosition: document.getElementById("globalDlPosition"), + globalDlPct: document.getElementById("globalDlPct"), + globalDlSpeed: document.getElementById("globalDlSpeed"), + globalDlEta: document.getElementById("globalDlEta"), + globalDlBar: document.getElementById("globalDlBar"), saveBtn: document.getElementById("saveBtn"), clearLogBtn: document.getElementById("clearLogBtn"), toast: document.getElementById("toast"), @@ -299,6 +307,7 @@ function renderSettings() { /* ---------------- games ---------------- */ function renderGames() { + renderGlobalStatus(); const visible = state.titles.filter(matchesFilter).filter(matchesQuery); els.gameGrid.replaceChildren(); @@ -692,8 +701,62 @@ function downloadingIds() { return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(","); } +// Top-of-page status banner. Visible while any job in this batch is queued, +// active, or has just finished (done jobs linger one or two polls before the +// pool reaps them, which is what gives us the "5 of 9" position). +function renderGlobalStatus() { + const el = els.globalDl; + if (!el) return; + const jobs = state.downloads || []; + const batch = jobs.filter((j) => ["queued", "active", "done"].includes(j.state)); + const flying = batch.filter((j) => j.state === "queued" || j.state === "active"); + + if (flying.length === 0) { el.hidden = true; return; } + el.hidden = false; + + const active = batch.find((j) => j.state === "active") || flying[0]; + const total = batch.length; + const done = batch.filter((j) => j.state === "done").length; + const pos = Math.min(total, done + 1); + const isActive = active && active.state === "active"; + + // Eyebrow + name + els.globalDlState.textContent = isActive ? "Downloading" : "Queued"; + els.globalDlName.textContent = active.name || active.title_id || "—"; + + // Position chip ("3 of 9") only when there's more than one game in this run + if (total > 1) { + els.globalDlPosition.hidden = false; + els.globalDlPosition.textContent = `${pos} of ${total}`; + } else { + els.globalDlPosition.hidden = true; + } + + // Progress line + const pct = pctOf(active); + const speed = Number(active._speed) || 0; + const done_ = Number(active.bytes) || 0; + const total_ = Number(active.total_bytes) || 0; + els.globalDlPct.innerHTML = isActive ? `${pct}%` : `— waiting`; + els.globalDlSpeed.innerHTML = isActive && speed > 0 + ? `${formatBytes(speed)}/s` + : (isActive && !total_ ? "fetching manifest…" : "—"); + els.globalDlEta.innerHTML = isActive && speed > 0 && total_ > done_ + ? `ETA ${formatEta((total_ - done_) / speed)}` + : ""; + + // Bar: animated indeterminate while fetching manifest, otherwise width=pct + if (isActive && total_ > 0) { + els.globalDlBar.classList.remove("is-indeterminate"); + els.globalDlBar.style.width = pct + "%"; + } else { + els.globalDlBar.classList.add("is-indeterminate"); + } +} + // Update the progress bar/meta in place to avoid rebuilding every card each tick. function applyDownloadProgress() { + renderGlobalStatus(); let needRender = false; state.downloads.forEach((d) => { // Only titles currently downloading render a progress tile; paused/done/error diff --git a/web/index.html b/web/index.html index b9c0926..2b69ca7 100644 --- a/web/index.html +++ b/web/index.html @@ -80,6 +80,29 @@ + +
Firmware diff --git a/web/styles.css b/web/styles.css index f292d83..f14371b 100644 --- a/web/styles.css +++ b/web/styles.css @@ -173,6 +173,105 @@ h2 { font-size: 18px; line-height: 1.2; } margin-bottom: 18px; } +/* ---------------- global download banner ---------------- */ +/* Sticky banner above the status strip, visible only while at least one + download is queued/active. The pulse dot and gradient bar at the bottom + echo the per-game card progress styling so the two read as one system. */ +.global-dl { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 18px; + padding: 16px 18px 0; + border: 1px solid var(--border); + background: + radial-gradient(120% 80% at 0% 0%, var(--green-soft), transparent 60%), + linear-gradient(180deg, rgba(56, 193, 114, 0.06), transparent 60%), + var(--surface); + border-radius: var(--radius); + overflow: hidden; +} + +.global-dl[hidden] { display: none; } + +.global-dl-row { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.global-dl-pulse { + flex: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 0 0 var(--green); + animation: globalDlPulse 1.6s ease-out infinite; +} + +@keyframes globalDlPulse { + 0% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0.55); } + 70% { box-shadow: 0 0 0 14px rgba(56, 193, 114, 0); } + 100% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0); } +} + +.global-dl-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } + +.global-dl-line1 { + display: flex; align-items: baseline; gap: 10px; + min-width: 0; flex-wrap: wrap; +} +.global-dl-eyebrow { + text-transform: uppercase; letter-spacing: 0.08em; + font-size: 11px; font-weight: 600; color: var(--green-dark); +} +.global-dl-name { + font-size: 16px; font-weight: 600; color: #fff; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; flex: 1; +} +.global-dl-position { + font-size: 12px; color: var(--muted); + padding: 2px 8px; border-radius: 999px; + background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border); +} +.global-dl-position[hidden] { display: none; } + +.global-dl-line2 { + display: flex; align-items: center; gap: 8px; + font-size: 12px; color: var(--muted); + font-variant-numeric: tabular-nums; +} +.global-dl-line2 b { color: #d6dde4; font-weight: 600; } +.global-dl-sep { opacity: 0.5; } + +.global-dl-track { + position: relative; + height: 3px; + margin: 0 -18px; /* extend bar edge-to-edge */ + background: rgba(56, 193, 114, 0.08); + overflow: hidden; +} +.global-dl-bar { + display: block; + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--green) 0%, var(--green-dark) 100%); + box-shadow: 0 0 12px rgba(56, 193, 114, 0.4); + transition: width 400ms ease-out; +} +.global-dl-bar.is-indeterminate { + width: 32% !important; + animation: globalDlIndet 1.6s ease-in-out infinite; +} +@keyframes globalDlIndet { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(320%); } +} + .metric { min-width: 0; padding: 14px 16px;