fix(properties): stabilize diagnostics and refresh download properties UI

This commit is contained in:
NimBold
2026-08-05 10:49:53 +03:30
parent 48a727798c
commit 4a83ac97c7
11 changed files with 643 additions and 56 deletions
+174 -55
View File
@@ -3,7 +3,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { open, save } from '@tauri-apps/plugin-dialog';
import { Copy, FileDown, FolderOpen, Pause, Play, RefreshCw, Save, X } from 'lucide-react';
import { Activity, Copy, Download, FileDown, FolderOpen, Gauge, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, Timer, Upload, Users, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
import type { TorrentDetails } from '../bindings/TorrentDetails';
@@ -15,16 +15,20 @@ import {
PROPERTIES_WINDOW_REMOVED,
PROPERTIES_WINDOW_SNAPSHOT,
attachAsyncPropertiesListener,
formatPropertiesQueuePlacement,
getPropertiesLifecycleAction,
propertiesLifecycleReachedPostcondition,
propertiesTorrentPeerLimit,
propertiesDiagnosticRequestState,
sendPropertiesActionRequest,
sendPropertiesReady,
isExpectedPropertiesDiagnosticUnavailable,
propertiesDiagnosticPhase,
type PropertiesAction,
type PropertiesActionRequest,
type PropertiesActionResult,
type PropertiesPatch,
type PropertiesDiagnosticPhase,
type PropertiesSnapshot,
type PropertiesSnapshotEvent,
} from '../propertiesBridge';
@@ -64,6 +68,31 @@ const safeTitle = (name: string) => {
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
const propertiesStatusTone = (status: string) => {
if (status === 'paused') return 'paused';
if (status === 'seeding') return 'seeding';
if (status === 'failed') return 'failed';
if (status === 'processing' || status === 'verifying' || status === 'moving') return 'processing';
if (status === 'queued' || status === 'staged') return 'queued';
if (status === 'retrying') return 'retrying';
if (status === 'completed') return 'completed';
return 'downloading';
};
const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string => [
snapshot.id,
snapshot.status,
snapshot.lastTry ?? '',
snapshot.hasBeenDispatched === true,
snapshot.destination ?? '',
snapshot.torrentInfoHash ?? '',
snapshot.torrentFileIndices?.join(',') ?? '',
snapshot.torrentMoveDestination ?? '',
snapshot.torrentMoveRestoreStatus ?? '',
snapshot.torrentVerifyOnly === true,
snapshot.torrentRelocationCheckPending === true,
].join('\u0000');
export const PropertiesWindowApp = () => {
const { t } = useTranslation();
const currentWindow = useMemo(() => getCurrentWindow(), []);
@@ -84,6 +113,8 @@ export const PropertiesWindowApp = () => {
const [details, setDetails] = useState<TorrentDetails | null>(null);
const [diagnosticError, setDiagnosticError] = useState('');
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
const [diagnosticsRefreshing, setDiagnosticsRefreshing] = useState(false);
const [diagnosticPhase, setDiagnosticPhase] = useState<PropertiesDiagnosticPhase>('idle');
// null means the Files tab has no local selection draft yet; [] is an
// explicit user choice to clear every file and must remain visually empty.
const [selectedFiles, setSelectedFiles] = useState<number[] | null>(null);
@@ -129,9 +160,20 @@ export const PropertiesWindowApp = () => {
const snapshotRef = useRef(snapshot);
const activeTabRef = useRef(activeTab);
const downloadIdRef = useRef(downloadId);
const fileProgressRef = useRef(fileProgress);
const peersRef = useRef(peers);
const availabilityRef = useRef(availability);
const detailsRef = useRef(details);
const diagnosticAttemptsRef = useRef(new Set<string>());
const diagnosticLifecycleKeyRef = useRef('');
const diagnosticLifecycleEpochRef = useRef(0);
snapshotRef.current = snapshot;
activeTabRef.current = activeTab;
downloadIdRef.current = downloadId;
fileProgressRef.current = fileProgress;
peersRef.current = peers;
availabilityRef.current = availability;
detailsRef.current = details;
const isTorrent = snapshot?.isTorrent === true;
const tabs = useMemo<PropertiesTab[]>(() => isTorrent
@@ -173,25 +215,50 @@ export const PropertiesWindowApp = () => {
});
}, []);
const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string) => {
const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string, manual = false) => {
if (!isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '')) return;
const requestKey = `${id}:${tab}`;
const diagnosticTabKey = `${id}:${tab}`;
const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current;
const requestKey = `${diagnosticTabKey}:${requestLifecycleEpoch}`;
if (diagnosticsInFlightRef.current.has(requestKey)) return;
diagnosticsInFlightRef.current.add(requestKey);
const isCurrent = () => downloadIdRef.current === id
&& activeTabRef.current === tab
&& isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '');
&& isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '')
&& diagnosticLifecycleEpochRef.current === requestLifecycleEpoch;
const hasCachedResult = () => tab === 'files'
? fileProgressRef.current !== null
: tab === 'peers'
? peersRef.current !== null || availabilityRef.current !== null
: detailsRef.current !== null;
const hasPreviousAttempt = diagnosticAttemptsRef.current.has(diagnosticTabKey);
diagnosticAttemptsRef.current.add(diagnosticTabKey);
if (isCurrent()) {
setDiagnosticError('');
setDiagnosticsLoading(true);
const cached = hasCachedResult();
const requestState = propertiesDiagnosticRequestState(cached, hasPreviousAttempt, manual);
setDiagnosticsLoading(requestState.loading);
setDiagnosticsRefreshing(requestState.refreshing);
// A silent refresh with no cached result must not replace a stable
// unavailable/error message between polling requests. Cached results
// remain visible while their request is refreshed in the background.
if (requestState.resetMessage) {
setDiagnosticError('');
setDiagnosticPhase(requestState.phase);
}
}
try {
if (tab === 'overview') {
const nextDetails = await invoke('get_torrent_details', { id });
if (isCurrent()) setDetails(nextDetails);
if (isCurrent()) {
setDetails(nextDetails);
setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success'));
}
} else if (tab === 'files') {
const nextProgress = await invoke('get_torrent_file_progress', { id });
if (isCurrent()) setFileProgress(nextProgress);
if (isCurrent()) {
setFileProgress(nextProgress);
setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success'));
}
} else if (tab === 'peers') {
const [peerResult, availabilityResult] = await Promise.allSettled([
invoke('get_torrent_peers', { id }),
@@ -199,14 +266,22 @@ export const PropertiesWindowApp = () => {
]);
if (isCurrent()) {
if (peerResult.status === 'fulfilled') setPeers(peerResult.value);
else setPeers(null);
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
else setAvailability(null);
const unexpectedErrors = [peerResult, availabilityResult]
const rejectedResults = [peerResult, availabilityResult]
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => result.reason)
.map(result => result.reason);
const unexpectedErrors = rejectedResults
.filter(error => !isExpectedPropertiesDiagnosticUnavailable(error));
setDiagnosticError(unexpectedErrors.length > 0 ? errorText(unexpectedErrors[0]) : '');
if (rejectedResults.length > 0) {
const hasPeerResult = hasCachedResult()
|| peerResult.status === 'fulfilled'
|| availabilityResult.status === 'fulfilled';
setDiagnosticPhase(propertiesDiagnosticPhase(
hasPeerResult,
unexpectedErrors.length > 0 ? 'unexpected-error' : 'expected-unavailable',
));
} else setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success'));
}
}
} catch (error) {
@@ -217,19 +292,19 @@ export const PropertiesWindowApp = () => {
// not a diagnostic failure, and must not flash a raw backend error.
if (isExpectedPropertiesDiagnosticUnavailable(error)) {
setDiagnosticError('');
if (tab === 'files') setFileProgress(null);
if (tab === 'peers') {
setPeers(null);
setAvailability(null);
}
setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'expected-unavailable'));
} else {
setDiagnosticError(message);
setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'unexpected-error'));
}
}
} finally {
diagnosticsInFlightRef.current.delete(requestKey);
if (downloadIdRef.current === id && activeTabRef.current === tab) {
if (downloadIdRef.current === id
&& activeTabRef.current === tab
&& diagnosticLifecycleEpochRef.current === requestLifecycleEpoch) {
setDiagnosticsLoading(false);
setDiagnosticsRefreshing(false);
}
}
}, []);
@@ -255,6 +330,9 @@ export const PropertiesWindowApp = () => {
if (latestBridgeGenerationRef.current !== event.payload.bridgeGeneration) {
latestBridgeGenerationRef.current = event.payload.bridgeGeneration;
latestSnapshotRevisionRef.current = 0;
diagnosticLifecycleEpochRef.current += 1;
diagnosticLifecycleKeyRef.current = '';
diagnosticAttemptsRef.current.clear();
const lostAction = pendingActionRef.current;
const lostApply = lostAction === 'apply-properties';
// A main-webview restart can lose both the action-result event and
@@ -283,6 +361,12 @@ export const PropertiesWindowApp = () => {
}
if (event.payload.revision <= latestSnapshotRevisionRef.current) return;
latestSnapshotRevisionRef.current = event.payload.revision;
const nextDiagnosticLifecycleKey = propertiesDiagnosticLifecycleKey(event.payload.snapshot);
if (diagnosticLifecycleKeyRef.current !== nextDiagnosticLifecycleKey) {
diagnosticLifecycleKeyRef.current = nextDiagnosticLifecycleKey;
diagnosticLifecycleEpochRef.current += 1;
diagnosticAttemptsRef.current.clear();
}
await changeAppLocale(event.payload.snapshot.appearance.locale);
if (event.payload.revision !== latestSnapshotRevisionRef.current) return;
appearanceCleanupRef.current?.();
@@ -366,6 +450,9 @@ export const PropertiesWindowApp = () => {
window.clearInterval(readyHeartbeatTimer);
readyHeartbeatTimer = undefined;
}
diagnosticLifecycleEpochRef.current += 1;
diagnosticLifecycleKeyRef.current = '';
diagnosticAttemptsRef.current.clear();
setSnapshot(null);
setNotice(t($ => $.downloadTable.noDownloads));
}
@@ -422,12 +509,19 @@ export const PropertiesWindowApp = () => {
setAvailability(null);
setDiagnosticError('');
setDiagnosticsLoading(false);
setDiagnosticsRefreshing(false);
diagnosticLifecycleEpochRef.current += 1;
diagnosticLifecycleKeyRef.current = '';
diagnosticAttemptsRef.current.clear();
setDiagnosticPhase('idle');
return;
}
if (!isTorrentPollingStatus(snapshot.status)) {
setFileProgress(null);
setPeers(null);
setAvailability(null);
diagnosticAttemptsRef.current.clear();
setDiagnosticPhase('idle');
}
void refreshDiagnostics(activeTab, downloadId);
if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return;
@@ -664,16 +758,25 @@ export const PropertiesWindowApp = () => {
? t($ => $.addDownloads.unknownSize)
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
const statusLabel = t($ => $.downloads.status[snapshot.status]);
const torrentMaxPeersLabel = snapshot.torrentMaxPeers === undefined
? `${propertiesTorrentPeerLimit(snapshot.torrentMaxPeers)}${t($ => $.properties.defaultValue)}`
: snapshot.torrentMaxPeers === 0
? t($ => $.speedLimiter.unlimited)
: String(snapshot.torrentMaxPeers);
const connectionMetric = isTorrent
? `${snapshot.connectedPeers ?? '—'} ${t($ => $.properties.torrentConnectedPeers)} · ${torrentMaxPeersLabel} ${t($ => $.properties.torrentMaxPeers)}`
? String(snapshot.connectedPeers ?? '—')
: snapshot.isMedia === true
? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}`
: `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'} ${t($ => $.properties.connections)}`;
const queuePlacement = formatPropertiesQueuePlacement(
snapshot.queueName,
snapshot.queuePosition,
position => t($ => $.properties.queuePosition, { position }),
);
const progressPercent = `${Math.round(progress * 100)}%`;
const statusTone = propertiesStatusTone(snapshot.status);
const lifecycleLabel = lifecycleAction === 'pause'
? t($ => $.downloads.actions.pause)
: lifecycleAction === 'resume'
? t($ => $.downloads.actions.resume)
: lifecycleAction === 'retry'
? t($ => $.downloads.actions.retry)
: t($ => $.downloads.actions.start);
const tabLabel = (tab: PropertiesTab) => {
switch (tab) {
case 'overview': return t($ => $.properties.details);
@@ -688,17 +791,22 @@ export const PropertiesWindowApp = () => {
return (
<main className="properties-window-shell flex h-screen min-h-0 flex-col bg-main-bg text-text-primary" aria-labelledby="properties-window-title">
<header className="shrink-0 border-b border-border-modal bg-sidebar-bg px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 id="properties-window-title" className="truncate text-base font-semibold" title={snapshot.fileName}>{snapshot.fileName}</h1>
<p className="mt-1 text-xs text-text-muted" role="status">{statusLabel} · {Math.round(progress * 100)}% · {total}</p>
<header className="properties-window-header shrink-0 border-b border-border-modal bg-sidebar-bg px-5 py-4">
<div className="properties-window-hero-top">
<div className="properties-window-title-block min-w-0">
<div className="properties-window-title-line">
<h1 id="properties-window-title" className="truncate text-base font-semibold" title={snapshot.fileName}>{snapshot.fileName}</h1>
<span className={`properties-status-pill properties-status-${statusTone}`}>{statusLabel}</span>
</div>
<p className="properties-window-queue text-xs text-text-muted" title={queuePlacement}>{queuePlacement}</p>
</div>
<div className="flex flex-wrap items-center gap-2" aria-label={t($ => $.actions.continue)}>
<div className="properties-window-command-bar" aria-label={t($ => $.actions.continue)}>
{lifecycleAction && <button
type="button"
className="app-button px-3 text-xs"
className="app-button app-button-primary properties-primary-action px-3 text-xs"
disabled={pendingAction !== null}
title={lifecycleLabel}
aria-label={lifecycleLabel}
onClick={() => {
if (lifecycleAction === 'pause'
&& snapshot.resumable === false
@@ -709,31 +817,42 @@ export const PropertiesWindowApp = () => {
}}
>
{lifecycleAction === 'pause' ? <Pause size={14} /> : <Play size={14} />}
{lifecycleAction === 'pause'
? t($ => $.downloads.actions.pause)
: lifecycleAction === 'resume'
? t($ => $.downloads.actions.resume)
: lifecycleAction === 'retry'
? t($ => $.downloads.actions.retry)
: t($ => $.downloads.actions.start)}
<span className="properties-command-label">{lifecycleLabel}</span>
</button>}
{isTorrent && <>
<button type="button" className="app-button px-3 text-xs" disabled={pendingTorrentCommand === 'magnet'} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
<button type="button" className="app-button px-3 text-xs" disabled={pendingTorrentCommand === 'export'} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
<button type="button" className="app-button px-3 text-xs" disabled={pendingTorrentCommand === 'move'} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
<div className="properties-secondary-actions">
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand === 'magnet'} onClick={() => void performTorrentAction('magnet')} title={t($ => $.properties.torrentCopyMagnet)}><Copy size={14} /><span className="properties-command-label">{t($ => $.properties.torrentCopyMagnet)}</span></button>
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand === 'export'} onClick={() => void performTorrentAction('export')} title={t($ => $.properties.torrentExportMetadata)}><FileDown size={14} /><span className="properties-command-label">{t($ => $.properties.torrentExportMetadata)}</span></button>
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand === 'move'} onClick={() => void performTorrentAction('move')} title={t($ => $.properties.torrentMove)}><FolderOpen size={14} /><span className="properties-command-label">{t($ => $.properties.torrentMove)}</span></button>
</div>
<details className="properties-command-overflow">
<summary className="app-icon-button" title={t($ => $.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}><MoreHorizontal size={16} /></summary>
<div className="properties-command-menu">
<button type="button" disabled={pendingTorrentCommand === 'magnet'} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
<button type="button" disabled={pendingTorrentCommand === 'export'} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
<button type="button" disabled={pendingTorrentCommand === 'move'} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
</div>
</details>
</>}
</div>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-item-hover" aria-label={t($ => $.properties.progress)}>
<div className="h-full rounded-full bg-accent transition-[width] motion-reduce:transition-none" style={{ width: `${progress * 100}%` }} />
<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>
<span className="properties-window-progress-percent">{progressPercent}</span>
</div>
<div className="mt-3 grid min-w-0 grid-cols-2 gap-2 text-[11px] text-text-muted sm:grid-cols-4" dir="ltr">
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
<span>{snapshot.speed || '—'}</span>
<span>{snapshot.eta || '—'}</span>
<span>{connectionMetric}</span>
{isTorrent && <span>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</span>}
<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"><Users size={14} /><div><span>{isTorrent ? t($ => $.properties.torrentConnectedPeers) : t($ => $.properties.connections)}</span><strong>{connectionMetric}</strong></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>
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
</>}
</div>
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
</header>
<nav className="properties-window-tabs flex shrink-0 gap-1 overflow-x-auto border-b border-border-modal px-4" role="tablist" aria-label={t($ => $.downloadTable.properties)}>
@@ -762,7 +881,7 @@ export const PropertiesWindowApp = () => {
))}
</nav>
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={`properties-tab-${activeTab}`} className="min-h-0 flex-1 overflow-auto p-5" tabIndex={0}>
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={`properties-tab-${activeTab}`} className="properties-window-panel min-h-0 flex-1 overflow-auto p-5" data-diagnostic-phase={diagnosticPhase} tabIndex={0}>
{activeTab === 'overview' && <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
@@ -775,7 +894,7 @@ export const PropertiesWindowApp = () => {
<div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
<span className="text-text-muted">{t($ => $.properties.dateAdded)}</span><span dir="ltr">{snapshot.dateAdded || '—'}</span>
<span className="text-text-muted">{t($ => $.properties.lastTry)}</span><span dir="ltr">{snapshot.lastTry || '—'}</span>
<span className="text-text-muted">{t($ => $.properties.queueId)}</span><span>{snapshot.queueId || '—'}{snapshot.queuePosition === undefined ? '' : ` · ${snapshot.queuePosition + 1}`}</span>
<span className="text-text-muted">{t($ => $.properties.queueId)}</span><span title={queuePlacement}>{queuePlacement}</span>
<span className="text-text-muted">{t($ => $.properties.resumable)}</span><span>{snapshot.resumable === false ? '—' : '✓'}</span>
{snapshot.lastError && <><span className="text-text-muted">{t($ => $.properties.lastError)}</span><span className="break-words text-red-300">{snapshot.lastError}</span></>}
</div>
@@ -799,10 +918,10 @@ export const PropertiesWindowApp = () => {
</div>}
{activeTab === 'files' && isTorrent && <div className="space-y-3">
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => downloadId && void refreshDiagnostics('files', downloadId)}><RefreshCw size={14} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index + 1} ${file.relativePath}`} /></td><td className="p-2">{file.index + 1}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
{diagnosticsLoading && <p className="text-xs text-text-muted">{t($ => $.properties.torrentPeerDiagnosticsLoading)}</p>}
{!diagnosticsLoading && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
</div>}
@@ -819,7 +938,7 @@ export const PropertiesWindowApp = () => {
</div>}
{activeTab === 'peers' && isTorrent && <div className="space-y-4">
<div className="flex items-center justify-between"><p className="text-sm">{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : diagnosticsLoading ? t($ => $.properties.torrentPeerDiagnosticsLoading) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}</p><button type="button" className="app-button px-3 text-xs" onClick={() => downloadId && void refreshDiagnostics('peers', downloadId)}><RefreshCw size={14} />{t($ => $.properties.torrentPeerDiagnosticsRefresh)}</button></div>
<div className="flex items-center justify-between"><p className="text-sm">{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : diagnosticsLoading ? t($ => $.properties.torrentPeerDiagnosticsLoading) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}</p><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('peers', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentPeerDiagnosticsRefresh)}</button></div>
<div className="grid gap-3 sm:grid-cols-2"><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentAvailability)}</span><p className="mt-1">{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}</p></div><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentPeerDiagnosticsHint)}</span><p className="mt-1">{peers?.truncated ? t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers }) : peers?.peers.length ?? 0}</p></div></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentPeerAddress)}</th><th className="p-2">{t($ => $.properties.torrentPeerDownload)}</th><th className="p-2">{t($ => $.properties.torrentPeerUpload)}</th><th className="p-2">{t($ => $.properties.torrentPeerSeeder)}</th><th className="p-2">{t($ => $.properties.torrentPeerChoking)}</th></tr></thead><tbody>{peers?.peers.map((peer, index) => <tr key={`${peer.ip ?? 'peer'}-${peer.port ?? 'unknown'}-${index}`} className="border-t border-border-modal/60"><td className="p-2 font-mono">{peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'}</td><td className="p-2">{formatDownloadBytes(peer.downloadSpeed)}/s</td><td className="p-2">{formatDownloadBytes(peer.uploadSpeed)}/s</td><td className="p-2">{peer.seeder ? '✓' : '—'}</td><td className="p-2">{peer.peerChoking ? '✓' : '—'}</td></tr>)}</tbody></table></div>
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
@@ -193,8 +193,11 @@ export const PropertiesWindowBridgeHost = () => {
const sendFor = async (windowLabel: string, downloadId: string) => {
const registration = windows.get(windowLabel);
if (!registration || registration.downloadId !== downloadId || disposed) return false;
const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId);
const store = useDownloadStore.getState();
const item = store.downloads.find(download => download.id === downloadId);
if (!item) return false;
const queue = store.queues.find(candidate => candidate.id === item.queueId)
?? store.queues.find(candidate => candidate.isMain);
const settings = useSettingsStore.getState();
const progress = useDownloadProgressStore.getState();
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
@@ -214,6 +217,8 @@ export const PropertiesWindowBridgeHost = () => {
}, {
progress: progress.progressMap[downloadId],
moveProgress: progress.moveProgressMap[downloadId],
}, {
queueName: queue?.name,
}),
});
return true;
+1
View File
@@ -225,6 +225,7 @@ const common = {
connectedPeers: 'connected peers',
details: 'Details',
queueId: 'Queue',
queuePosition: 'Position {{position}}',
resumable: 'Resumable',
connectionCount: '{{active}}/{{total}}',
connectionCountUnknown: '—/{{total}}',
+1
View File
@@ -225,6 +225,7 @@ const fa = {
connectedPeers: 'همتای متصل',
details: 'جزئیات',
queueId: 'صف',
queuePosition: 'موقعیت {{position}}',
resumable: 'قابل ادامه',
connectionCount: '{{active}}/{{total}} فعال',
connectionCountUnknown: '—/{{total}} فعال',
+1
View File
@@ -225,6 +225,7 @@ const he = {
connectedPeers: 'עמיתים מחוברים',
details: 'פרטים',
queueId: 'תור',
queuePosition: 'מיקום {{position}}',
resumable: 'ניתן להמשך',
connectionCount: '{{active}}/{{total}} פעילות',
connectionCountUnknown: '—/{{total}} פעילות',
+1
View File
@@ -225,6 +225,7 @@ const ru = {
connectedPeers: 'подключённых пиров',
details: 'Подробности',
queueId: 'Очередь',
queuePosition: 'Позиция {{position}}',
resumable: 'Возобновляемая',
connectionCount: '{{active}}/{{total}} активных',
connectionCountUnknown: '—/{{total}} активных',
+1
View File
@@ -225,6 +225,7 @@ const uk = {
connectedPeers: 'підключених пірів',
details: 'Деталі',
queueId: 'Черга',
queuePosition: 'Позиція {{position}}',
resumable: 'Можна продовжити',
connectionCount: '{{active}}/{{total}} активних',
connectionCountUnknown: '—/{{total}} активних',
+1
View File
@@ -225,6 +225,7 @@ const zhCN = {
connectedPeers: '已连接对等端',
details: '详细信息',
queueId: '队列',
queuePosition: '位置 {{position}}',
resumable: '可续传',
connectionCount: '{{active}}/{{total}} 个连接',
connectionCountUnknown: '—/{{total}} 个连接',
+346
View File
@@ -571,6 +571,352 @@ html[data-list-density="relaxed"] {
background: hsl(var(--accent-color) / 0.9);
}
.properties-window-shell {
--properties-header-surface: hsl(var(--surface-raised) / 0.88);
--properties-card-surface: hsl(var(--bg-input) / 0.42);
}
.properties-window-header {
background:
radial-gradient(circle at 100% 0%, hsl(var(--accent-color) / 0.1), transparent 38%),
var(--properties-header-surface);
box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025);
}
.properties-window-hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.properties-window-title-block {
min-width: 0;
flex: 1 1 auto;
}
.properties-window-title-line {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
}
.properties-window-title-line h1 {
min-width: 0;
letter-spacing: -0.015em;
}
.properties-window-queue {
display: block;
min-width: 0;
margin-top: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.properties-status-pill {
display: inline-flex;
flex: 0 0 auto;
min-height: 20px;
align-items: center;
padding: 2px 8px;
border: 1px solid hsl(var(--border-modal));
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.properties-status-downloading { color: hsl(var(--status-downloading)); background: hsl(var(--status-downloading) / 0.1); }
.properties-status-paused { color: hsl(var(--status-paused)); background: hsl(var(--status-paused) / 0.1); }
.properties-status-seeding { color: hsl(262 83% 62%); background: hsl(262 83% 58% / 0.12); }
.properties-status-failed { color: hsl(var(--status-failed)); background: hsl(var(--status-failed) / 0.1); }
.properties-status-completed { color: hsl(var(--status-completed)); background: hsl(var(--status-completed) / 0.1); }
.properties-status-processing { color: hsl(199 89% 58%); background: hsl(199 89% 48% / 0.1); }
.properties-status-queued { color: hsl(var(--status-queued)); background: hsl(var(--status-queued) / 0.1); }
.properties-status-retrying { color: hsl(var(--status-retrying)); background: hsl(var(--status-retrying) / 0.1); }
.properties-window-command-bar,
.properties-secondary-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.properties-primary-action {
min-height: 32px;
border-radius: 8px;
box-shadow: 0 3px 10px hsl(var(--accent-color) / 0.2);
}
.properties-command-button {
min-height: 32px;
border-radius: 8px;
}
.properties-command-overflow {
position: relative;
display: none;
}
.properties-command-overflow > summary {
list-style: none;
cursor: default;
}
.properties-command-overflow > summary::-webkit-details-marker {
display: none;
}
.properties-command-menu {
position: absolute;
z-index: 20;
inset-inline-end: 0;
top: calc(100% + 7px);
display: grid;
min-width: 190px;
gap: 2px;
padding: 5px;
border: 1px solid hsl(var(--border-modal));
border-radius: 9px;
background: hsl(var(--surface-overlay) / 0.98);
box-shadow: 0 12px 28px hsl(var(--shadow-color));
}
.properties-command-menu button {
display: flex;
min-height: 30px;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
color: hsl(var(--text-primary));
font-size: 12px;
text-align: start;
}
.properties-command-menu button:hover:not(:disabled),
.properties-command-menu button:focus-visible {
background: hsl(var(--item-hover));
outline: none;
}
.properties-window-progress-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
}
.properties-window-progress-track {
min-width: 0;
flex: 1 1 auto;
height: 10px;
overflow: hidden;
border-radius: 999px;
background: hsl(var(--border-color));
box-shadow: inset 0 1px 2px hsl(0 0% 0% / 0.25);
}
.properties-window-progress-fill {
height: 100%;
border-radius: inherit;
transition: width 180ms ease;
}
.properties-progress-downloading { background: hsl(var(--status-downloading)); }
.properties-progress-paused { background: hsl(var(--status-paused)); }
.properties-progress-seeding { background: hsl(262 83% 58%); }
.properties-progress-failed { background: hsl(var(--status-failed)); }
.properties-progress-completed { background: hsl(var(--status-completed)); }
.properties-progress-processing { background: hsl(199 89% 48%); }
.properties-progress-queued { background: hsl(var(--status-queued)); }
.properties-progress-retrying { background: hsl(var(--status-retrying)); }
.properties-window-progress-percent {
min-width: 42px;
color: hsl(var(--text-secondary));
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: end;
}
.properties-window-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
gap: 7px;
margin-top: 11px;
}
.properties-metric-card {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
border: 1px solid hsl(var(--border-modal) / 0.72);
border-radius: 8px;
background: hsl(var(--bg-input) / 0.28);
}
.properties-metric-card > svg {
flex: 0 0 auto;
margin-top: 2px;
color: hsl(var(--accent-color));
}
.properties-metric-card > div {
display: grid;
min-width: 0;
gap: 2px;
}
.properties-metric-card span {
overflow: hidden;
color: hsl(var(--text-muted));
font-size: 10px;
font-weight: 650;
letter-spacing: 0.025em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.properties-metric-card strong {
overflow: hidden;
color: hsl(var(--text-primary));
font-size: 12px;
font-variant-numeric: tabular-nums;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.properties-window-destination {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
margin-top: 10px;
color: hsl(var(--text-muted));
font-size: 11px;
}
.properties-window-destination span {
min-width: 0;
overflow: hidden;
direction: ltr;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
.properties-window-panel {
scrollbar-gutter: stable;
}
.properties-window-panel .app-control {
min-height: 34px;
padding: 7px 11px;
border-radius: 8px;
}
.properties-window-panel textarea.app-control {
min-height: 104px;
resize: vertical;
line-height: 1.45;
}
.properties-window-panel label:not(:has(> input[type="checkbox"])) {
display: flex;
min-width: 0;
align-items: stretch;
flex-direction: column;
gap: 6px;
line-height: 1.35;
}
.properties-window-panel label:not(:has(> input[type="checkbox"])) > .app-control {
margin-top: 0 !important;
}
.properties-window-panel label:has(> input[type="checkbox"]) {
min-width: 0;
line-height: 1.4;
}
.properties-window-panel label:has(> input[type="checkbox"]) input {
flex: 0 0 auto;
margin-top: 2px;
}
.properties-window-panel > div > p,
.properties-window-panel .text-text-muted {
line-height: 1.45;
}
.properties-window-panel table th,
.properties-window-panel table td {
padding-inline: 10px;
padding-block: 8px;
vertical-align: middle;
}
@media (max-width: 720px) {
.properties-window-hero-top {
align-items: stretch;
flex-direction: column;
gap: 11px;
}
.properties-window-command-bar {
justify-content: flex-end;
}
.properties-secondary-actions {
display: none;
}
.properties-command-overflow {
display: inline-block;
}
.properties-window-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 440px) {
.properties-window-header {
padding-inline: 14px;
}
.properties-window-panel {
padding: 14px;
}
.properties-window-metrics {
grid-template-columns: minmax(0, 1fr);
}
.properties-command-label {
max-width: 26vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (prefers-reduced-motion: reduce) {
.properties-window-progress-fill {
transition: none;
}
}
.app-icon-button {
display: inline-flex;
width: 28px;
+65
View File
@@ -16,8 +16,11 @@ import {
beginExclusivePropertiesAction,
createFrameCoalescer,
enqueuePropertiesAction,
formatPropertiesQueuePlacement,
getPropertiesLifecycleAction,
isExpectedPropertiesDiagnosticUnavailable,
propertiesDiagnosticPhase,
propertiesDiagnosticRequestState,
propertiesLifecycleReachedPostcondition,
propertiesTorrentPeerLimit,
sanitizePropertiesSnapshot,
@@ -156,6 +159,68 @@ describe('Properties window bridge', () => {
expect(normalSnapshot).not.toHaveProperty('connectedPeers');
});
it('adds a user-facing queue name to the sanitized snapshot', () => {
const snapshot = sanitizePropertiesSnapshot({
id: 'queued-1',
fileName: 'example.bin',
url: 'https://example.test/file',
status: 'queued',
queueId: 'internal-queue-id',
queuePosition: 2,
} as DownloadItem, {
theme: 'dark',
fontFamily: 'system',
appFontSize: 'standard',
listRowDensity: 'standard',
locale: 'en',
}, undefined, { queueName: 'Main Queue' });
expect(snapshot.queueName).toBe('Main Queue');
expect(snapshot.queueId).toBe('internal-queue-id');
});
it('keeps diagnostic refreshes quiet when cached data exists', () => {
expect(propertiesDiagnosticPhase(false, 'request-start')).toBe('initial');
expect(propertiesDiagnosticPhase(false, 'request-start', true)).toBe('refreshing');
expect(propertiesDiagnosticPhase(true, 'request-start')).toBe('refreshing');
expect(propertiesDiagnosticPhase(true, 'success')).toBe('idle');
expect(propertiesDiagnosticPhase(true, 'expected-unavailable')).toBe('stale');
expect(propertiesDiagnosticPhase(false, 'expected-unavailable')).toBe('unavailable');
expect(propertiesDiagnosticPhase(true, 'unexpected-error')).toBe('error');
expect(propertiesDiagnosticRequestState(false, false, false)).toMatchObject({
loading: true,
refreshing: false,
resetMessage: true,
phase: 'initial',
});
expect(propertiesDiagnosticRequestState(false, true, false)).toMatchObject({
loading: false,
refreshing: false,
resetMessage: false,
phase: 'refreshing',
});
expect(propertiesDiagnosticRequestState(true, true, true)).toMatchObject({
loading: false,
refreshing: true,
resetMessage: true,
phase: 'refreshing',
});
});
it('formats queue placement without ever using a raw queue id', () => {
const formatPosition = (position: number) => `Position ${position}`;
expect(formatPropertiesQueuePlacement('Main Queue', 2, formatPosition))
.toBe('Main Queue · Position 3');
expect(formatPropertiesQueuePlacement(undefined, 2, formatPosition))
.toBe('Position 3');
expect(formatPropertiesQueuePlacement('Main Queue', undefined, formatPosition))
.toBe('Main Queue');
expect(formatPropertiesQueuePlacement(' Main Queue ', 1.5, formatPosition))
.toBe('Main Queue');
expect(formatPropertiesQueuePlacement(undefined, Number.NaN, formatPosition))
.toBe('—');
});
it('applies explicit secret changes without conflating unchanged fields', () => {
expect(applySecretPatch(undefined, 'existing')).toBe('existing');
expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing');
+46
View File
@@ -97,10 +97,54 @@ export const isExpectedPropertiesDiagnosticUnavailable = (error: unknown): boole
].includes(message);
};
export type PropertiesDiagnosticPhase = 'idle' | 'initial' | 'refreshing' | 'stale' | 'unavailable' | 'error';
export type PropertiesDiagnosticOutcome = 'request-start' | 'success' | 'expected-unavailable' | 'unexpected-error';
export const propertiesDiagnosticPhase = (
hasCachedResult: boolean,
outcome: PropertiesDiagnosticOutcome,
hasPreviousAttempt = false,
): PropertiesDiagnosticPhase => {
if (outcome === 'request-start') return hasCachedResult || hasPreviousAttempt ? 'refreshing' : 'initial';
if (outcome === 'success') return 'idle';
if (outcome === 'expected-unavailable') return hasCachedResult ? 'stale' : 'unavailable';
return 'error';
};
export const propertiesDiagnosticRequestState = (
hasCachedResult: boolean,
hasPreviousAttempt: boolean,
manual: boolean,
) => ({
loading: !hasPreviousAttempt && !hasCachedResult,
refreshing: manual && (hasPreviousAttempt || hasCachedResult),
resetMessage: !hasPreviousAttempt || hasCachedResult,
phase: propertiesDiagnosticPhase(hasCachedResult, 'request-start', hasPreviousAttempt),
});
export const formatPropertiesQueuePlacement = (
queueName: unknown,
queuePosition: unknown,
formatPosition: (position: number) => string,
): string => {
const name = typeof queueName === 'string' ? queueName.trim() : '';
const hasPosition = typeof queuePosition === 'number'
&& Number.isInteger(queuePosition)
&& queuePosition >= 0;
const position = hasPosition ? formatPosition(queuePosition + 1) : '';
if (name && position) return `${name} · ${position}`;
return name || position || '—';
};
type SafePropertiesFields = Pick<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)[number]>;
export type PropertiesSnapshotContext = {
queueName?: string;
};
export type PropertiesSnapshot = SafePropertiesFields & {
appearance: DocumentAppearance;
queueName?: string;
activeConnections?: number;
requestedConnections?: number;
connectedPeers?: number;
@@ -246,6 +290,7 @@ const copyWithoutSecrets = (
progress?: DownloadProgressEvent;
moveProgress?: number;
},
context?: PropertiesSnapshotContext,
): PropertiesSnapshot => {
const safeItem = Object.fromEntries(
PROPERTIES_SNAPSHOT_KEYS.flatMap(key => (
@@ -256,6 +301,7 @@ const copyWithoutSecrets = (
return {
...safeItem,
appearance,
...(context?.queueName ? { queueName: context.queueName } : {}),
...(live?.progress ? {
fraction: live.progress.fraction,
speed: item.status === 'seeding'