diff --git a/project-context.md b/project-context.md index 6f69a61..480ee6b 100644 --- a/project-context.md +++ b/project-context.md @@ -65,7 +65,7 @@ The UI definition of "peers at 100%" is an absolute count of `peers[]` entries w ## Frontend Architecture -The frontend is a single page with module-level state in `web/app.js`. It polls every 3 seconds and fetches torrents and stats in parallel. +The frontend is a single page with module-level state in `web/app.js`. It polls on a user-selectable interval, defaults to 3 seconds, and fetches torrents and stats in parallel. Important patterns: @@ -83,6 +83,7 @@ Current sort keys: - `progress` - `ratio` - `size` +- `speed`: orders by combined download and upload speed descending, then download speed, upload speed, and name. - `peer100`: orders by peers at 100% descending, then connected peers descending, progress descending, and name ascending. ## UI And Design Rules diff --git a/web/app.js b/web/app.js index b4dfdf0..1aebaff 100644 --- a/web/app.js +++ b/web/app.js @@ -2,10 +2,11 @@ /* ============================================================ Transmission Manager - frontend - Vanilla SPA. Auto-refreshes every 3s with in-place updates. + Vanilla SPA. Auto-refreshes on a selectable interval with in-place updates. ============================================================ */ -const REFRESH_MS = 3000; +const DEFAULT_REFRESH_MS = 3000; +const REFRESH_INTERVALS = new Set([1000, 3000, 5000, 10000]); const TRANSMISSION_STATUSES = { 0: "stopped", 1: "check-wait", @@ -21,6 +22,7 @@ const state = { stats: {}, filter: "", sortKey: "status", + refreshMs: DEFAULT_REFRESH_MS, lastError: null, }; @@ -28,6 +30,7 @@ const torrentCards = new Map(); const actionStates = new Map(); let syncInFlight = false; let syncQueued = false; +let refreshTimer = null; const $ = (sel) => document.querySelector(sel); @@ -91,8 +94,34 @@ function setSyncing(on) { const el = $("#refresh-indicator"); if (!el) return; el.classList.toggle("syncing", on); - const t = el.querySelector(".refresh-text"); - if (t) t.textContent = on ? "syncing" : "live"; + updateRefreshMetadata(on); +} + +function refreshLabel() { + return `${state.refreshMs / 1000}s`; +} + +function updateRefreshMetadata(syncing = false) { + const label = refreshLabel(); + const indicator = $("#refresh-indicator"); + if (indicator) { + indicator.title = `Auto-refreshes every ${label}`; + indicator.setAttribute( + "aria-label", + syncing ? `Syncing; auto-refresh every ${label}` : `Auto-refresh active every ${label}`, + ); + } + + const hint = $("#error-hint"); + if (hint) hint.textContent = `Retrying in ${label}...`; +} + +function setRefreshInterval(ms) { + state.refreshMs = REFRESH_INTERVALS.has(ms) ? ms : DEFAULT_REFRESH_MS; + updateRefreshMetadata(syncInFlight); + + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = setInterval(sync, state.refreshMs); } async function runTorrentAction(id, action) { @@ -217,6 +246,10 @@ function compareName(a, b) { return (a.name || "").localeCompare(b.name || ""); } +function activitySpeed(t) { + return (t.rateDownload || 0) + (t.rateUpload || 0); +} + function sortTorrents(list) { const key = state.sortKey; const sorted = list.slice(); @@ -230,6 +263,11 @@ function sortTorrents(list) { return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b); case "size": return (b.totalSize || 0) - (a.totalSize || 0) || compareName(a, b); + case "speed": + return (activitySpeed(b) - activitySpeed(a)) + || ((b.rateDownload || 0) - (a.rateDownload || 0)) + || ((b.rateUpload || 0) - (a.rateUpload || 0)) + || compareName(a, b); case "peer100": { const pa = peerStatus(a); const pb = peerStatus(b); @@ -513,6 +551,19 @@ function wireEvents() { }); }); + const refreshSelect = $("#refresh-interval"); + if (refreshSelect) { + refreshSelect.value = String(state.refreshMs); + refreshSelect.addEventListener("change", (e) => { + const next = Number(e.target.value); + if (!REFRESH_INTERVALS.has(next)) { + e.target.value = String(state.refreshMs); + return; + } + setRefreshInterval(next); + }); + } + $("#torrents").addEventListener("click", (e) => { const button = e.target.closest("[data-action-button]"); if (!button || button.disabled) return; @@ -535,8 +586,8 @@ function wireEvents() { /* ---------- Init ---------- */ function boot() { wireEvents(); + setRefreshInterval(state.refreshMs); sync(); - setInterval(sync, REFRESH_MS); } document.addEventListener("DOMContentLoaded", boot); diff --git a/web/index.html b/web/index.html index 636adfe..fef178c 100644 --- a/web/index.html +++ b/web/index.html @@ -53,11 +53,22 @@ + -