feat(downloads): status bar with combined speed and ETA - #309
feat(downloads): status bar with combined speed and ETA#309zohaiblazuli wants to merge 4 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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:
- Seeding torrents pin the bar forever with the wrong label.
getAggregate()countsseedinginactiveCount, andbusyderives from it.queue.rsmoves a finished magnet toSeedingwithspeed = 0and 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 excludeseedingfromactiveCount/busy, or give it its own count and label with no indeterminate track. Please add a test for a seeding-only queue. - Study music player overlaps it.
src/routes/study/+layout.svelterenders.global-player-barasposition: fixed; bottom: 0; z-index: 80while 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. - 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>
|
Thanks — all three are fixed in 0b83b40. 1. Seeding.
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 The 80px matches 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 Verification on Windows 11: Still frontend-only, so nothing in |
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.
QueueItemProgressalready carriesspeed_bytes_per_sec,downloaded_bytes,total_bytesandeta_seconds, and the frontend already hasformatSpeed/formatEta/formatBytesplusDownloadSpeedGraph. What was missing was an aggregate selector and somewhere app-wide to mount it.How it works
getAggregate()indownload-store.svelte.ts, alongsidegetCounts(). Speed is the sum overdownloadingitems. 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 carrybytesDownloadedbut no total). It returnsnullrather thanInfinityorNaNwhen everything is paused or stalled, soformatEtarenders 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 feedsDownloadSpeedGraphunchanged.DownloadStatusBar.sveltereads the store directly, the same way/downloadsdoes. It reuses the.progress/.progress-fillprimitives rather than introducing a track of its own, switches to.indeterminatewith 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-bodyis a column flex container and.contentisflex: 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-popoutbranch 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-liveregion over the readout would make a screen reader talk continuously. Instead:role="progressbar"witharia-valuenowrounded to an integer, so it changes ~100 times per download rather than per frame, and omitsaria-valuenowwith anaria-valuetextwhen the size is unknownaria-live="polite"region announces milestones only — appearance, item-count change, each 25% crossing, completion — driven by a$derivedkey that only changes at those thresholds@keyframes, not a Sveltetransition:, because the global[data-reduce-motion="true"]rule reaches CSS but not JS-driven transitions:focus-visiblering onlyNotes for maintainers
No Rust changes.
queue.rs,events.rsanddownload-listener.tsare untouched — the events already carried everything needed. Since the diff is frontend-only I did not run thecargo fmt/clippy/teststeps from CONTRIBUTING; nothing insrc-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 --strictaborts when any locale drifts fromen.json, so an English-only change failspnpm check:i18n. They follow each file's existingdownloads.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.jsonwas the one judgement call: itsdownloadsblock is still English, but itsnavblock 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
DownloadSettingswith#[serde(default)]if you want one.Verification
Run on Windows 11:
pnpm test— 14 files, 106 passed, including 7 newgetAggregate()cases (empty queue, summed speeds, unknown-size fallback, all-paused →nullETA, excluded statuses, mixed course + generic, history cap)pnpm check— 1433 files, 0 errors; the changed files add no new warningspnpm check:i18n— all 10 locales in syncpnpm build— cleanpnpm tauri devbuild: bar appears on queueing, stays live while navigating between routes, indeterminate state on unknown-size items, noInfinitywhen paused, auto-hides after the queue drainsI 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-bodyis 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