Skip to content

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

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

feat(downloads): status bar with combined speed and ETA#309
zohaiblazuli wants to merge 4 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>

@tonhowtf tonhowtf left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a good addition and the mechanics are solid: tokens all exist in app.css, the .progress primitives are reused correctly, i18n is complete in all 11 locales, keys.ts is regenerated, pnpm check is clean and the tests pass. Three things before merge:

  1. Seeding torrents pin the bar forever with the wrong label. getAggregate() counts seeding in activeCount, and busy derives from it. queue.rs moves a finished magnet to Seeding with speed = 0 and the only exits are cancel/remove, so after any magnet completes the bar stays visible showing "1 downloading" with the indeterminate animation and a "0 B" size. Either exclude seeding from activeCount/busy, or give it its own count and label with no indeterminate track. Please add a test for a seeding-only queue.
  2. Study music player overlaps it. src/routes/study/+layout.svelte renders .global-player-bar as position: fixed; bottom: 0; z-index: 80 while a track plays; the status bar is in-flow at the bottom of .shell-body, so on /study/* it ends up under the player. Offset it when the player is present, or hide it on those routes.
  3. Code comments. CLAUDE.md asks for none — the component has five and the store about six (including the JSDoc on the type). Please strip them.

Minor and optional: aggregateSpeedHistory only resets when the map is empty, but completed items stay in it, so the next session's sparkline starts with stale zero samples. Cosmetic since the graph only shows when speedBps > 0.

The a11y design (milestone-only aria-live, integer aria-valuenow) and the auto-hide behaviour are exactly right. CI still needs first-contributor approval to run on the fork.

A finished magnet moves to Seeding with speed 0 and only ever leaves
that state on cancel or remove, so counting it as active held the bar
open indefinitely — labelled "1 downloading", indeterminate track, 0 B
size. Seeding no longer feeds getAggregate at all, so the bar treats a
seeding-only queue as idle and hides on its usual grace period. getCounts
is left alone, so a seeding torrent still shows in the sidebar badge as
it always has.

The study music player is fixed to the bottom of the viewport at z-index
80 while a track plays, and the status bar is in flow at the bottom of
.shell-body, so on /study/* the player covered it. The bar now clears the
player's 80px when it is present, keyed off the player's own presence in
the DOM so it follows the route exclusions in study/+layout.svelte for
free.

Comments removed from the component and the store.

The aggregate sparkline history only reset when the downloads map was
empty, but completed items stay in the map, so the next session opened
with stale zero samples. It now resets once nothing is downloading,
queued or paused.

Tests cover a seeding-only queue, the sidebar badge still counting
seeding, and the history reset on drain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zohaiblazuli

Copy link
Copy Markdown
Author

Thanks — all three are fixed in 0b83b40.

1. Seeding. seeding no longer feeds getAggregate() at all, so a seeding-only queue reads as idle and the bar hides on its normal grace period. I went with exclusion rather than its own count and label: seeding is indefinite by design, so a dedicated label would still leave the bar pinned open indefinitely — just with better wording.

getCounts() is now untouched by this PR. An earlier pass of mine had also split seeding out of active there, which would have quietly dropped seeding torrents from the sidebar badge — behaviour that predates this branch and isn't mine to change. Two tests: reports a seeding-only queue as idle so the bar does not stay pinned, and keeps counting a seeding torrent in the sidebar badge so that badge behaviour is pinned down and can't drift out from under the bar later.

2. Study player. Offset rather than hidden, so download status survives while music is playing:

:global(body:has(.global-player-bar)) .dl-status-bar {
  margin-bottom: 80px;
}

Keyed off the player bar's presence in the DOM rather than the pathname, which mirrors the :global(body:has(.global-player-bar)) rule already in study/+layout.svelte. That means the player's own exclusions (/study/watch, /study/course/, /study/anki/study, /study/music) come along for free, with no duplicated route condition in the root layout to fall out of sync. Since the bar is a flex item in .shell-body, the margin shrinks .content by the same 80px, so nothing is occluded on either side of it.

The 80px matches .player-bar { height: 80px }. It is a literal in two places now — happy to lift it into a token if you would rather it not be.

3. Comments. Gone from the component and the store, plus the two in the test file.

Sparkline reset. Fixed too, since it was a couple of lines: the history now clears once nothing is downloading, queued or paused, rather than waiting for the map to empty. Covered by drops stale samples once the queue drains.

Verification on Windows 11: pnpm test — 14 files, 109 passed. pnpm check — 0 errors, no new warnings in the changed files. pnpm build — clean, and I checked the compiled CSS to confirm the offset actually ships as body:has(.global-player-bar) .dl-status-bar.svelte-…{margin-bottom:80px} rather than being pruned as an unused selector. No i18n files in this push, so the locales stay in sync.

Still frontend-only, so nothing in src-tauri/ changed. Ready for CI approval whenever you are.

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.

2 participants