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;