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;