feat(ui): show download progress and speed in the action button

Previously the action button only said "Downloading…" with no indication of
how far along the transfer was or how fast it was going. With multi-gigabyte
game payloads on a LAN this gave the user no signal whether the download had
stalled, was hitting the wire fast, or was about to finish.

Wire a sampled byte-level progress channel from the download pipeline up to
the action button:

- New `DownloadProgressTracker` in `crates/lanspread-peer/src/download/progress.rs`
  holds the total expected bytes plus two atomic counters: `downloaded_bytes`
  (deduplicated per `(relative_path, offset)` chunk key, used for the bar) and
  `transferred_bytes` (raw cumulative, used for the speed sample). The dedup
  prevents a retried chunk from double-counting toward completion while still
  letting speed reflect actual wire activity including retry waste, which is
  the more useful metric for "is the link doing anything right now?".
- `sample_download_progress` wraps the transfer future, emits an initial 0 B/s
  snapshot, then samples on a 500 ms interval (`MissedTickBehavior::Skip` so a
  stalled downloader does not generate a thundering herd of catch-up ticks)
  and emits one final snapshot when the future resolves, so the UI sees the
  closing state before `DownloadGameFilesFinished` arrives.
- New `PeerEvent::DownloadGameFilesProgress(DownloadProgress)` variant carries
  `{ id, downloaded_bytes, total_bytes, bytes_per_second }`. The Tauri shell
  forwards it as `game-download-progress`; the JSONL harness emits it as
  `download-progress`.
- Orchestrator and retry paths refactored to thread a single shared
  `Arc<DownloadProgressTracker>` through both the initial transfer and any
  retry attempts. New `TransferContext`, `RetryContext`, and `ChunkPlanContext`
  structs absorb the parameter-list growth that came with adding the tracker.

Frontend rendering honors the snapshot-is-authoritative decision from commit
`5df82aa` ("fix(ui): derive operation status from snapshots"):

- `Game.download_progress` is an ephemeral overlay carried alongside the card,
  not a status field. `mergeGameUpdate` preserves it only while
  `install_status === Downloading` and otherwise clears it on the next
  snapshot, so the games-list snapshot remains the single authority for when
  the bar should disappear.
- The `game-download-progress` listener writes ONLY `download_progress` — it
  does not touch `install_status`, `status_message`, or `status_level`. This
  preserves the rule that lifecycle events never mutate card status.
- No `game-download-finished` listener; snapshot reconciliation clears the
  overlay automatically when status leaves Downloading.
- `ActionButton` renders a percentage fill behind the icon/label via a
  `--download-progress` CSS custom property; the existing `.act-busy` spinner
  is layered above the fill with `z-index: 1`. `act-downloading` widens the
  button to avoid label jitter as the speed number changes (tabular-nums).
- `actionLabel` for the Downloading status now appends a formatted speed
  ("Downloading… 12.5 MB/s") via the new `formatBytesPerSecond` helper.

Test Plan:
- `just test` — Rust workspace tests including new progress tracker unit tests
  (`tracker_counts_only_new_bytes_for_a_retried_chunk`,
  `tracker_clamps_reported_bytes_to_total`).
- `just frontend-test` — Deno tests including
  `download progress is preserved only while actively downloading` and
  `downloading action label includes current speed`.
- `just clippy` — clean.
- Manual: download a multi-GB game from a peer and watch the action button
  fill, speed update on the half-second, and reset cleanly on completion.

Refs: download progress visibility, snapshot-authoritative UI architecture
This commit is contained in:
2026-05-20 22:11:09 +02:00
parent 0f10108438
commit 01712f248b
14 changed files with 724 additions and 205 deletions
@@ -1,7 +1,7 @@
import { JSX, MouseEvent } from 'react';
import { CSSProperties, JSX, MouseEvent } from 'react';
import { Icon } from './Icon';
import { Game } from '../lib/types';
import { Game, InstallStatus } from '../lib/types';
import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState';
interface Props {
@@ -18,16 +18,29 @@ const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
download: <Icon.download />,
};
const downloadProgressPercent = (game: Game): number | undefined => {
const progress = game.download_progress;
if (!progress || progress.total_bytes <= 0) return undefined;
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
};
/** Color-coded primary action: Play / Install / Update / Download / busy. */
export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => {
const action = primaryActionFor(game);
const isDownloading = game.install_status === InstallStatus.Downloading;
const progressPercent = downloadProgressPercent(game);
const cls = [
'act-btn',
`act-${action}`,
isDownloading ? 'act-downloading' : '',
size === 'lg' ? 'act-lg' : '',
full ? 'act-full' : '',
].filter(Boolean).join(' ');
const disabled = action === 'busy' || action === 'disabled';
const style = progressPercent === undefined
? undefined
: ({ '--download-progress': `${progressPercent}%` } as CSSProperties);
const handle = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -36,9 +49,10 @@ export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props
};
return (
<button className={cls} onClick={handle} disabled={disabled}>
<button className={cls} onClick={handle} disabled={disabled} style={style}>
{isDownloading && <span className="act-progress-fill" aria-hidden />}
{ICON_FOR_ACTION[action]}
<span>{actionLabel(game)}</span>
<span className="act-label">{actionLabel(game)}</span>
</button>
);
};
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import {
DownloadProgressPayload,
Game,
GamesListPayload,
InstallStatus,
@@ -51,7 +52,10 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
const markChecking = useCallback((id: string) => {
setGames(prev => prev.map(item =>
item.id === id && !isInProgress(item.install_status)
? applyPatch(item, { install_status: InstallStatus.CheckingPeers, clearStatus: true })
? applyPatch(item, {
install_status: InstallStatus.CheckingPeers,
clearStatus: true,
})
: item
));
}, []);
@@ -81,6 +85,7 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
: InstallStatus.NotInstalled,
status_message: message,
status_level: 'error',
download_progress: undefined,
}
: item));
if (triggerRescan) rescanRef.current();
@@ -115,6 +120,16 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
});
}));
unlisteners.push(await listen('game-download-progress', (e) => {
const { id, ...download_progress } = e.payload as DownloadProgressPayload;
setGames(prev => prev.map(item => item.id === id
? {
...item,
download_progress,
}
: item));
}));
unlisteners.push(await listen('game-no-peers', (e) => {
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
}));
@@ -66,6 +66,9 @@ export const mergeGameUpdate = (
install_status: installStatus,
status_message: clearStatus ? undefined : previous?.status_message,
status_level: clearStatus ? undefined : previous?.status_level,
download_progress: installStatus === InstallStatus.Downloading
? previous?.download_progress
: undefined,
peer_count: incoming.peer_count ?? 0,
};
};
@@ -108,12 +111,29 @@ export const primaryActionFor = (game: Game): PrimaryAction => {
return 'play';
};
export const inProgressLabel = (status: InstallStatus): string | undefined => {
switch (status) {
export const formatBytesPerSecond = (bytesPerSecond: number): string => {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = Math.max(0, bytesPerSecond);
let unitIndex = 0;
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex += 1;
}
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
};
export const inProgressLabel = (game: Game): string | undefined => {
switch (game.install_status) {
case InstallStatus.CheckingPeers:
return 'Checking peers…';
case InstallStatus.Downloading:
return 'Downloading…';
return game.download_progress
? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}`
: 'Downloading…';
case InstallStatus.Installing:
return 'Installing…';
case InstallStatus.Uninstalling:
@@ -126,7 +146,7 @@ export const inProgressLabel = (status: InstallStatus): string | undefined => {
};
export const actionLabel = (game: Game): string => {
const busy = inProgressLabel(game.install_status);
const busy = inProgressLabel(game);
if (busy) return busy;
if (isUnavailable(game)) return 'Unavailable';
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
@@ -23,6 +23,16 @@ export enum ActiveOperationKind {
export type StatusLevel = 'info' | 'error';
export interface DownloadProgress {
downloaded_bytes: number;
total_bytes: number;
bytes_per_second: number;
}
export interface DownloadProgressPayload extends DownloadProgress {
id: string;
}
export interface Game {
id: string;
name: string;
@@ -45,6 +55,7 @@ export interface Game {
genre?: string;
status_message?: string;
status_level?: StatusLevel;
download_progress?: DownloadProgress;
peer_count: number;
}
@@ -737,14 +737,21 @@
font: inherit;
font-weight: 600;
font-size: 12.5px;
letter-spacing: 0.005em;
letter-spacing: 0;
cursor: pointer;
position: relative;
overflow: hidden;
transition:
transform 0.12s,
filter 0.12s,
background 0.15s;
white-space: nowrap;
}
.act-btn > svg,
.act-btn > .act-label {
position: relative;
z-index: 1;
}
.act-btn:hover:not(:disabled) {
filter: brightness(1.12);
}
@@ -800,6 +807,20 @@
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--bd-1);
}
.act-downloading {
min-width: 148px;
font-variant-numeric: tabular-nums;
}
.act-lg.act-downloading {
min-width: 174px;
}
.act-progress-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--download-progress, 0%);
background: color-mix(in srgb, var(--accent) 28%, transparent);
transition: width 0.45s linear;
}
.act-busy::before {
content: "";
display: inline-block;
@@ -809,6 +830,9 @@
border: 1.6px solid color-mix(in srgb, var(--accent) 60%, transparent);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
position: relative;
z-index: 1;
flex: 0 0 auto;
}
@keyframes spin {
to {