feat(ui): add download progress controls
Replace the downloading action button with a dedicated progress component in
both card and detail views. The card now shows percent plus current speed, while
the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance
using the same backend progress payload.
Expose download cancellation as a peer command that cancels the tracked transfer
token and lets the running operation clear the authoritative active-operation
snapshot. Add a View Files action that resolves the game root safely and opens it
with the platform file viewer through Tauri's shell plugin.
Test Plan:
- just fmt
- just frontend-test
- just test
- just build
- just clippy
- git diff --cached --check
Refs: design reference e308009a08
This commit is contained in:
@@ -75,6 +75,7 @@ export const mergeGameUpdate = (
|
||||
|
||||
/** Visual card state — used for state chip color and action button styling. */
|
||||
export const deriveState = (game: Game): DerivedState => {
|
||||
if (game.install_status === InstallStatus.Downloading) return 'downloading';
|
||||
if (isInProgress(game.install_status)) return 'busy';
|
||||
if (game.installed) return 'installed';
|
||||
if (game.downloaded) return 'local';
|
||||
@@ -126,6 +127,48 @@ export const formatBytesPerSecond = (bytesPerSecond: number): string => {
|
||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const DECIMAL_MB = 1_000_000;
|
||||
|
||||
const stripTrailingDecimalZeros = (value: string): string =>
|
||||
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
|
||||
|
||||
export const downloadProgressPercent = (game: Game): number => {
|
||||
const progress = game.download_progress;
|
||||
if (!progress || progress.total_bytes <= 0) return 0;
|
||||
|
||||
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
|
||||
};
|
||||
|
||||
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return `${Math.round(mb)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadBytes = (bytes: number): string => {
|
||||
const safeBytes = Math.max(0, bytes);
|
||||
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
||||
|
||||
const gb = safeBytes / GB;
|
||||
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
|
||||
};
|
||||
|
||||
export const formatDownloadEta = (seconds: number): string => {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
||||
if (seconds < 60) return `${Math.round(seconds)} s`;
|
||||
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
|
||||
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
||||
};
|
||||
|
||||
export const inProgressLabel = (game: Game): string | undefined => {
|
||||
switch (game.install_status) {
|
||||
case InstallStatus.CheckingPeers:
|
||||
@@ -161,19 +204,22 @@ export interface FilterCounts {
|
||||
installed: number;
|
||||
}
|
||||
|
||||
const isDownloading = (game: Game): boolean =>
|
||||
game.install_status === InstallStatus.Downloading;
|
||||
|
||||
const isNetworkGame = (game: Game): boolean =>
|
||||
game.installed || game.downloaded || game.peer_count > 0;
|
||||
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
|
||||
|
||||
export const countByFilter = (games: Game[]): FilterCounts => ({
|
||||
all: games.filter(isNetworkGame).length,
|
||||
local: games.filter(g => g.installed || g.downloaded).length,
|
||||
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
|
||||
installed: games.filter(g => g.installed).length,
|
||||
});
|
||||
|
||||
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
switch (filter) {
|
||||
case 'local':
|
||||
return game.installed || game.downloaded;
|
||||
return game.installed || game.downloaded || isDownloading(game);
|
||||
case 'installed':
|
||||
return game.installed;
|
||||
case 'all':
|
||||
@@ -182,10 +228,11 @@ const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
};
|
||||
|
||||
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
||||
busy: 0,
|
||||
installed: 1,
|
||||
local: 2,
|
||||
none: 3,
|
||||
installed: 0,
|
||||
local: 1,
|
||||
downloading: 2,
|
||||
busy: 3,
|
||||
none: 4,
|
||||
};
|
||||
|
||||
const compareByState = (a: Game, b: Game): number => {
|
||||
|
||||
Reference in New Issue
Block a user