Files
lanspread/crates/lanspread-tauri-deno-ts/src/components/DownloadProgress.tsx
T
ddidderr 8acb6dc246 feat: show active download peer count
Render the active peer count already carried by download progress events in the
large download progress control. The peer chip appears between speed and ETA,
uses singular/plural copy, and hides after the ETA when the detail modal gets
very narrow.

This keeps the UI aligned with the design reference without changing backend
state ownership or download progress plumbing.

Test Plan:
- git diff --check
- git diff --cached --check
- just frontend-test
- just build
2026-05-21 00:32:57 +02:00

140 lines
5.0 KiB
TypeScript

import { CSSProperties, MouseEvent } from 'react';
import { Game } from '../lib/types';
import {
downloadProgressPercent,
formatDownloadBytes,
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
} from '../lib/gameState';
import { Icon } from './Icon';
interface Props {
game: Game;
size?: 'md' | 'lg';
full?: boolean;
onCancel?: (game: Game) => void;
}
const progressStats = (game: Game) => {
const progress = game.download_progress;
const downloaded = progress?.downloaded_bytes ?? 0;
const total = progress?.total_bytes ?? game.size;
const speed = progress?.bytes_per_second ?? 0;
const remaining = Math.max(0, total - downloaded);
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
return {
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
downloaded,
total,
speed,
eta: etaSeconds,
activePeerCount: progress?.active_peer_count ?? 0,
};
};
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => {
const stats = progressStats(game);
const progressStyle = {
'--download-progress': `${stats.pct}%`,
} as CSSProperties;
const className = [
'dl',
size === 'lg' ? 'dl-lg' : 'dl-md',
full ? 'dl-full' : '',
].filter(Boolean).join(' ');
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCancel?.(game);
};
if (size === 'lg') {
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers';
return (
<div
className={className}
role="progressbar"
aria-label={`Downloading ${game.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden />
<span className="dl-label">Downloading</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
<span className="dl-of"> / {formatDownloadBytes(stats.total)}</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
{stats.activePeerCount > 0 && (
<>
<span className="dl-sep dl-sep-peers">·</span>
<span
className="dl-peers"
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
>
<Icon.users />
<span>
from <strong>{stats.activePeerCount}</strong> {peerUnit}
</span>
</span>
</>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
</div>
<div className="dl-lg-pct">
{stats.pct}
<span className="dl-pct-sym">%</span>
</div>
{onCancel && (
<button
type="button"
className="dl-cancel"
onClick={handleCancel}
aria-label={`Cancel download of ${game.name}`}
>
<Icon.close />
</button>
)}
</div>
</div>
);
}
return (
<div
className={className}
role="progressbar"
aria-label={`Downloading ${game.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-md-row">
<span className="dl-pct">
<span className="dl-pulse" aria-hidden />
{stats.pct}
<span className="dl-pct-sym">%</span>
</span>
<span className="dl-speed">{formatDownloadSpeedShort(stats.speed)}</span>
</div>
</div>
);
};