Skip to content

feat(downloads): status bar with combined speed and ETA - #309

Open
zohaiblazuli wants to merge 3 commits into
tonhowtf:mainfrom
zohaiblazuli:feat/download-status-bar
Open

feat(downloads): status bar with combined speed and ETA#309
zohaiblazuli wants to merge 3 commits into
tonhowtf:mainfrom
zohaiblazuli:feat/download-status-bar

Conversation

@zohaiblazuli

Copy link
Copy Markdown

What

Adds a slim download status bar that appears whenever the queue has work and hides shortly after it drains. It shows the combined transfer speed, the combined ETA, overall progress and the existing sparkline — from any route in the app.

Why

Speed and ETA already existed, but only on /downloads. Leave that page and the only remaining signal is the sidebar badge count, so answering "how fast is this going, and when will it be done" means navigating away from whatever you were doing. Browsers solved this with a download shelf; this is the same idea.

The gap was never the data. QueueItemProgress already carries speed_bytes_per_sec, downloaded_bytes, total_bytes and eta_seconds, and the frontend already has formatSpeed / formatEta / formatBytes plus DownloadSpeedGraph. What was missing was an aggregate selector and somewhere app-wide to mount it.

How it works

getAggregate() in download-store.svelte.ts, alongside getCounts(). Speed is the sum over downloading items. ETA prefers the batch model — total remaining over total speed — which is much steadier than taking the slowest item, and falls back to the worst per-item ETA when any active item has an unknown size (livestreams, course items, which carry bytesDownloaded but no total). It returns null rather than Infinity or NaN when everything is paused or stalled, so formatEta renders nothing and the component drops the segment entirely.

getAggregateSpeedHistory() keeps a sampled ring buffer of the combined rate, capped at the same 60 points as the per-item history, so it feeds DownloadSpeedGraph unchanged.

DownloadStatusBar.svelte reads the store directly, the same way /downloads does. It reuses the .progress / .progress-fill primitives rather than introducing a track of its own, switches to .indeterminate with a byte counter when the total size is unknown, and uses only existing semantic tokens — so it themes correctly across all the theme blocks without adding a colour.

Mounting is a flow sibling after </main> inside .shell-body, not a fixed overlay. Since .shell-body is a column flex container and .content is flex: 1; overflow-y: auto, the bar claims its own row and the content area simply shrinks — no padding compensation, no z-index, no occlusion. It is absent from the .stream-popout branch for free, and hidden in OmniDisc's immersive mode alongside the sidebar.

A ~2s grace period before hiding stops the bar flickering as one item finishes and the next starts.

Accessibility

This was the fiddly part, and worth calling out for review.

Speed changes every frame, so an aria-live region over the readout would make a screen reader talk continuously. Instead:

  • the visible numbers are in a non-live container
  • the track is role="progressbar" with aria-valuenow rounded to an integer, so it changes ~100 times per download rather than per frame, and omits aria-valuenow with an aria-valuetext when the size is unknown
  • one visually hidden aria-live="polite" region announces milestones only — appearance, item-count change, each 25% crossing, completion — driven by a $derived key that only changes at those thresholds
  • the enter animation is CSS @keyframes, not a Svelte transition:, because the global [data-reduce-motion="true"] rule reaches CSS but not JS-driven transitions
  • one tab stop ("View downloads"), :focus-visible ring only
  • status is never colour-alone; icon and text always accompany the fill

Notes for maintainers

No Rust changes. queue.rs, events.rs and download-listener.ts are untouched — the events already carried everything needed. Since the diff is frontend-only I did not run the cargo fmt / clippy / test steps from CONTRIBUTING; nothing in src-tauri/ changed.

Translations are included for all ten locales. I would have left this English-only and let translators follow, but scripts/generate-i18n-keys.js --strict aborts when any locale drifts from en.json, so an English-only change fails pnpm check:i18n. They follow each file's existing downloads.filter.* wording. Please treat them as best-effort and correct anything that reads wrong — I would rather you rewrite them than ship an awkward string. Happy to drop them to English-only if you would prefer translators own these keys and want to relax the gate for new ones.

fa.json was the one judgement call: its downloads block is still English, but its nav block is Persian, so I translated rather than leaving English in a file that is partly localised. Say the word if the convention is the opposite.

Not included: a settings toggle for visibility. Auto-hide means the bar costs nothing when idle, so it seemed like a setting nobody would need to find. It is a small addition to DownloadSettings with #[serde(default)] if you want one.

Verification

Run on Windows 11:

  • pnpm test — 14 files, 106 passed, including 7 new getAggregate() cases (empty queue, summed speeds, unknown-size fallback, all-paused → null ETA, excluded statuses, mixed course + generic, history cap)
  • pnpm check — 1433 files, 0 errors; the changed files add no new warnings
  • pnpm check:i18n — all 10 locales in sync
  • pnpm build — clean
  • exercised in a pnpm tauri dev build: bar appears on queueing, stays live while navigating between routes, indeterminate state on unknown-size items, no Infinity when paused, auto-hides after the queue drains

I have not tested on macOS or Linux. The change is CSS and Svelte only with no platform-specific code, but the flex interaction with .shell-body is worth a glance on a WebKit webview.

Happy to split this up, rename anything, or change the layout if it does not match where you want the app to go.

🤖 Generated with Claude Code

zohaiblazuli and others added 3 commits August 31, 2026 19:12
The store already tracked per-item speed and ETA, but nothing combined
them, so a caller outside /downloads had no way to ask "how fast is the
queue moving, and when is it done".

getAggregate() answers that. Speed is the sum over downloading items.
ETA prefers the batch model — total remaining over total speed — which
is far steadier than taking the slowest item, and falls back to the
worst per-item ETA when any item has an unknown size (livestreams,
course items). It returns null rather than Infinity or NaN when
everything is paused or stalled, so formatEta renders nothing instead
of nonsense.

getAggregateSpeedHistory() keeps a sampled ring buffer of the combined
rate, capped at the same 60 points as the per-item history, so it drops
straight into DownloadSpeedGraph.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds downloads.status_bar.* and regenerates keys.ts.

Translations are included for all ten locales because
scripts/generate-i18n-keys.js --strict refuses to run when any locale
drifts from en.json, so an English-only change fails CI. They follow
each file's existing downloads.filter.* wording, but a native speaker
should feel free to correct them.

Co-Authored-By: Claude <noreply@anthropic.com>
Speed and ETA only existed on /downloads. Leave that page and the only
signal left was a badge count, which is the one thing a download
manager should never make you go looking for. Browsers solved this with
a shelf; this is that.

A slim bar sits below the content, showing the combined speed, the
combined ETA, overall progress and the existing sparkline. It appears
when the queue has work and hides about two seconds after it drains,
so it costs nothing when idle. The grace period keeps it from flickering
as one item finishes and the next starts.

It is a flow sibling inside .shell-body rather than a fixed overlay, so
.content simply shrinks and nothing needs padding compensation. Hidden
in OmniDisc's immersive mode, alongside the sidebar.

Accessibility was the fiddly part. Speed changes every frame, so a live
region over the readout would make a screen reader talk without
stopping. Instead the numbers are inert, the track is a progressbar
whose aria-valuenow is a rounded integer, and one hidden live region
announces only milestones — appearance, item count, each 25%, and
completion. The enter animation is CSS keyframes, not a Svelte
transition, so the global reduce-motion rule actually reaches it.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant