mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 14:07:57 +00:00
feat(ui): show file allocation phase
- expose transient allocation state around normal Aria2 enqueue - render truthful indeterminate allocation status in the table and Properties window - add localized copy, accessibility semantics, lifecycle cleanup, and regression tests
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { Play, Pause, MoreVertical, Clock } from 'lucide-react';
|
||||
import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import {
|
||||
canPauseDownload,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadItemType;
|
||||
allocationPending: boolean;
|
||||
queueIndex: number;
|
||||
columnOrder: DownloadTableColumnKey[];
|
||||
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
|
||||
@@ -51,6 +52,7 @@ interface DownloadItemProps {
|
||||
|
||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download,
|
||||
allocationPending,
|
||||
queueIndex,
|
||||
columnOrder,
|
||||
columnAlignments,
|
||||
@@ -200,14 +202,18 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
status: download.status,
|
||||
});
|
||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||
const displaySpeed = download.status === 'seeding'
|
||||
const displaySpeed = allocationPending
|
||||
? '-'
|
||||
: download.status === 'seeding'
|
||||
? liveProgress?.upload_speed ?? '-'
|
||||
: download.status === 'downloading' || download.status === 'verifying'
|
||||
? liveProgress?.speed ?? download.speed
|
||||
: download.status === 'processing'
|
||||
? t($ => $.downloads.values.processing)
|
||||
: '-';
|
||||
const displayEta = download.status === 'seeding'
|
||||
const displayEta = allocationPending
|
||||
? '-'
|
||||
: download.status === 'seeding'
|
||||
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
|
||||
? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language)
|
||||
: '-'
|
||||
@@ -228,7 +234,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||
})();
|
||||
const downloadStatusLabel = t($ => $.downloads.status[download.status]);
|
||||
const downloadStatusLabel = allocationPending
|
||||
? t($ => $.downloads.status.allocatingFiles)
|
||||
: t($ => $.downloads.status[download.status]);
|
||||
const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution'
|
||||
? download.status === 'retrying' && download.lastResolverFallback === true
|
||||
? t($ => $.downloads.errors.nameResolutionRetrying)
|
||||
@@ -318,9 +326,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</div>
|
||||
) : (
|
||||
<div className="download-cell-content download-status-content">
|
||||
<div className="download-progress-track">
|
||||
<div className="download-progress-track" aria-label={allocationPending ? downloadStatusLabel : undefined}>
|
||||
<div
|
||||
className={`download-progress-fill ${
|
||||
allocationPending ? 'allocating' :
|
||||
download.status === 'paused' ? 'paused' :
|
||||
download.status === 'seeding' ? 'seeding' :
|
||||
download.status === 'processing' ? 'processing' :
|
||||
@@ -329,12 +338,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||
download.status === 'retrying' ? 'retrying' : ''
|
||||
}`}
|
||||
style={{ width: `${displayFraction * 100}%` }}
|
||||
style={{ width: allocationPending ? undefined : `${displayFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
title={
|
||||
download.lastError && (
|
||||
allocationPending
|
||||
? downloadStatusLabel
|
||||
: download.lastError && (
|
||||
download.status === 'failed'
|
||||
|| download.status === 'retrying'
|
||||
|| download.lastErrorKind === 'destinationAccess'
|
||||
@@ -349,6 +360,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
: downloadStatusLabel
|
||||
}
|
||||
className={`download-status flex items-center gap-1.5 ${
|
||||
allocationPending ? 'download-status-downloading' :
|
||||
download.status === 'paused' ? 'download-status-paused' :
|
||||
download.status === 'seeding' ? 'download-status-seeding' :
|
||||
download.status === 'failed' ? 'download-status-failed' :
|
||||
@@ -360,7 +372,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'retrying' ? 'download-status-retrying' : ''
|
||||
}`}
|
||||
>
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
|
||||
{allocationPending ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{downloadStatusLabel}</span>
|
||||
</>
|
||||
) : (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
|
||||
<>
|
||||
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse motion-reduce:animate-none shrink-0' : 'shrink-0'} />
|
||||
<span className="truncate">
|
||||
|
||||
@@ -161,7 +161,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
moveManyInQueueToPosition,
|
||||
startAll,
|
||||
pauseAll,
|
||||
startSelected
|
||||
startSelected,
|
||||
allocationPendingIds
|
||||
} = useDownloadStore();
|
||||
const progressMap = useDownloadProgressStore(state => state.progressMap);
|
||||
const { addToast } = useToast();
|
||||
@@ -2303,6 +2304,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
<DownloadItemComponent
|
||||
key={d.id}
|
||||
download={d}
|
||||
allocationPending={allocationPendingIds.has(d.id)}
|
||||
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
|
||||
columnOrder={orderedColumns}
|
||||
columnAlignments={columnAlignments}
|
||||
|
||||
@@ -1140,10 +1140,13 @@ export const PropertiesWindowApp = () => {
|
||||
});
|
||||
const isPromptFooter = footerActions.includes('keepEditing');
|
||||
const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status);
|
||||
const allocationPending = snapshot.allocationPending === true;
|
||||
const total = snapshot.size || (snapshot.totalBytes === undefined
|
||||
? t($ => $.addDownloads.unknownSize)
|
||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||
const statusLabel = t($ => $.downloads.status[snapshot.status]);
|
||||
const statusLabel = allocationPending
|
||||
? t($ => $.downloads.status.allocatingFiles)
|
||||
: t($ => $.downloads.status[snapshot.status]);
|
||||
const connectionPresentation = getPropertiesConnectionPresentation(snapshot);
|
||||
const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
||||
? t($ => $.properties.fragmentConcurrency)
|
||||
@@ -1183,8 +1186,8 @@ export const PropertiesWindowApp = () => {
|
||||
snapshot.queuePosition,
|
||||
position => t($ => $.properties.queuePosition, { position }),
|
||||
);
|
||||
const progressPercent = `${Math.round(progress * 100)}%`;
|
||||
const statusTone = propertiesStatusTone(snapshot.status);
|
||||
const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
|
||||
const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
|
||||
const lifecycleLabel = lifecycleAction === 'pause'
|
||||
? t($ => $.downloads.actions.pause)
|
||||
: lifecycleAction === 'resume'
|
||||
@@ -1269,15 +1272,27 @@ export const PropertiesWindowApp = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="properties-window-progress-row" dir="ltr">
|
||||
<div className="properties-window-progress-track" aria-label={t($ => $.properties.progress)} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}>
|
||||
<div className={`properties-window-progress-fill properties-progress-${statusTone}`} style={{ width: `${progress * 100}%` }} />
|
||||
<div
|
||||
className="properties-window-progress-track"
|
||||
aria-label={t($ => $.properties.progress)}
|
||||
aria-busy={allocationPending}
|
||||
aria-valuetext={allocationPending ? statusLabel : undefined}
|
||||
role="progressbar"
|
||||
aria-valuemin={allocationPending ? undefined : 0}
|
||||
aria-valuemax={allocationPending ? undefined : 100}
|
||||
aria-valuenow={allocationPending ? undefined : Math.round(progress * 100)}
|
||||
>
|
||||
<div
|
||||
className={`properties-window-progress-fill ${allocationPending ? 'properties-progress-allocating' : `properties-progress-${statusTone}`}`}
|
||||
style={{ width: allocationPending ? undefined : `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="properties-window-progress-percent">{progressPercent}</span>
|
||||
</div>
|
||||
<div className="properties-window-metrics" dir="ltr">
|
||||
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{allocationPending ? '—' : snapshot.eta || '—'}</strong></div></div>
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span className={connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : undefined}>{connectionHeaderLabel}</span>{connectionValue}</div></div>}
|
||||
{isTorrent && <>
|
||||
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||
|
||||
@@ -308,6 +308,7 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
}, {
|
||||
queueName: queue?.name,
|
||||
windowChrome,
|
||||
allocationPending: store.allocationPendingIds.has(downloadId),
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
@@ -688,7 +689,10 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
snapshotRevisions.delete(windowLabel);
|
||||
clearWindowActionState(windowLabel);
|
||||
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
||||
} else if (next !== before) {
|
||||
} else if (
|
||||
next !== before
|
||||
|| state.allocationPendingIds.has(downloadId) !== previous.allocationPendingIds.has(downloadId)
|
||||
) {
|
||||
snapshotCoalescer.schedule(windowLabel);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user