import { useState, useEffect, useRef } from 'react'; import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { useShallow } from 'zustand/react/shallow'; import { useSettingsStore } from '../store/useSettingsStore'; import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics'; import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot'; import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot'; import type { TorrentFileSelectionSnapshot } from '../bindings/TorrentFileSelectionSnapshot'; import type { TorrentDetails } from '../bindings/TorrentDetails'; import type { TorrentWebSeed } from '../bindings/TorrentWebSeed'; import { invokeCommand as invoke } from '../ipc'; import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { resolveCategoryDestination } from '../utils/downloadLocations'; import { getPauseResumeAction, isIdentityLocked as getIdentityLocked, isTransferLocked as getTransferLocked } from '../utils/downloadActions'; import { downloadProgressColorClass, formatDownloadBytes, formatDownloadTotal, resolveDownloadSizeDisplay } from '../utils/downloadProgress'; import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads'; import { useTranslation } from 'react-i18next'; import { formatDateTime, type CalendarPreference } from '../utils/dateTime'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; type LoginMode = 'matching' | 'custom' | 'none'; const formatLastTry = ( value: string | undefined, locale: string, calendar: CalendarPreference ): string => { if (!value) return '-'; return formatDateTime(value, { locale, calendar, options: { dateStyle: 'medium', timeStyle: 'short' } }); }; const isPeerDiagnosticsStatus = (status: string): boolean => ['downloading', 'verifying', 'seeding', 'retrying'].includes(status); const isTorrentFileProgressStatus = (status: string): boolean => ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status); const formatPeerSpeed = (bytesPerSecond: number): string => `${formatDownloadBytes(bytesPerSecond)}/s`; export const PropertiesModal = () => { const { t, i18n } = useTranslation(); const categoryLabel = (category: string) => { switch (category) { case 'Musics': return t($ => $.navigation.categories.musics); case 'Movies': return t($ => $.navigation.categories.movies); case 'Compressed': return t($ => $.navigation.categories.compressed); case 'Documents': return t($ => $.navigation.categories.documents); case 'Pictures': return t($ => $.navigation.categories.pictures); case 'Applications': return t($ => $.navigation.categories.applications); default: return t($ => $.navigation.categories.other); } }; const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId); const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId); const item = useDownloadStore(useShallow(state => selectedPropertiesDownloadId ? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null : null )); const liveProgress = useDownloadProgressStore(useShallow(state => selectedPropertiesDownloadId ? state.progressMap[selectedPropertiesDownloadId] : undefined )); const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore(); // Form states const [url, setUrl] = useState(''); const [fileName, setFileName] = useState(''); const [saveLocation, setSaveLocation] = useState(''); const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections)); const [connectionsDirty, setConnectionsDirty] = useState(false); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState(''); const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState(''); const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState(''); const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState(''); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false); const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState(TORRENT_ENCRYPTION_POLICY_DISABLED); const [torrentFileAllocation, setTorrentFileAllocation] = useState('prealloc'); const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState(''); const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState(''); const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0'); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState(''); const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState(null); const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false); const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false); const [torrentFileProgress, setTorrentFileProgress] = useState(null); const [torrentFileSelection, setTorrentFileSelection] = useState(null); const [torrentDetails, setTorrentDetails] = useState(null); const [torrentDetailsError, setTorrentDetailsError] = useState(false); const [isTorrentDetailsPending, setIsTorrentDetailsPending] = useState(false); const [isTorrentVerifyPending, setIsTorrentVerifyPending] = useState(false); const [torrentFileProgressError, setTorrentFileProgressError] = useState(false); const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false); const [torrentPieceProgress, setTorrentPieceProgress] = useState(null); const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false); const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false); const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false); const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false); const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false); const [torrentWebSeedsText, setTorrentWebSeedsText] = useState(''); const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false); const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false); const [loginMode, setLoginMode] = useState('matching'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [advancedExpanded, setAdvancedExpanded] = useState(false); const [checksumEnabled, setChecksumEnabled] = useState(false); const [checksumAlgorithm, setChecksumAlgorithm] = useState('SHA-256'); const [checksumValue, setChecksumValue] = useState(''); const [cookies, setCookies] = useState(''); const [headers, setHeaders] = useState(''); const [mirrors, setMirrors] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [isPauseResumePending, setIsPauseResumePending] = useState(false); const torrentFileProgressByIndex = new Map( (torrentFileProgress?.files ?? []).map(file => [file.index, file]) ); const actionRequestRef = useRef(0); const peerDiagnosticsRequestRef = useRef(0); const torrentFileProgressRequestRef = useRef(0); const torrentFileSelectionRequestRef = useRef(0); const torrentDetailsRequestRef = useRef(0); const torrentPieceProgressRequestRef = useRef(0); const torrentWebSeedsRequestRef = useRef(0); const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item)); useEffect(() => { // Invalidate native pickers and transfer-control results when the modal // switches items, closes, or reopens for the same download. actionRequestRef.current += 1; setIsLiveSpeedLimitPending(false); setIsLiveTorrentUploadLimitPending(false); setIsLiveTorrentPeerOptionsPending(false); peerDiagnosticsRequestRef.current += 1; setTorrentPeerDiagnostics(null); setTorrentPeerDiagnosticsError(false); setIsTorrentPeerDiagnosticsPending(false); torrentFileProgressRequestRef.current += 1; setTorrentFileProgress(null); setTorrentFileProgressError(false); setIsTorrentFileProgressPending(false); torrentDetailsRequestRef.current += 1; setTorrentDetails(null); setTorrentDetailsError(false); setIsTorrentDetailsPending(false); torrentPieceProgressRequestRef.current += 1; setTorrentPieceProgress(null); setTorrentPieceProgressError(false); setIsTorrentPieceProgressPending(false); torrentWebSeedsRequestRef.current += 1; setTorrentWebSeedsError(false); setIsTorrentWebSeedsPending(false); }, [selectedPropertiesDownloadId]); useEffect(() => { if (selectedPropertiesDownloadId) { const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); if (activeItem) { setUrl(activeItem.url); setFileName(activeItem.fileName); if (activeItem.destination) { setSaveLocation(activeItem.destination); } else { const propertiesDownloadId = selectedPropertiesDownloadId; const requestId = actionRequestRef.current; void resolveCategoryDestination( useSettingsStore.getState(), activeItem.category ).then(location => { if ( requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setSaveLocation(location); } }); } setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections)); setConnectionsDirty(false); if (activeItem.speedLimit) { setSpeedLimitEnabled(true); setSpeedLimitValue(activeItem.speedLimit.replace(/[^0-9]/g, '')); } else { setSpeedLimitEnabled(false); } if (activeItem.username || activeItem.password) { setLoginMode('custom'); setUsername(activeItem.username || ''); setPassword(activeItem.password || ''); } else { setLoginMode('matching'); setUsername(''); setPassword(''); } setHeaders(activeItem.headers || ''); setChecksumEnabled(!!activeItem.checksum); if (activeItem.checksum) { const [algo, val] = activeItem.checksum.split('='); if (val) { setChecksumAlgorithm(algo); setChecksumValue(val); } } else { setChecksumAlgorithm('SHA-256'); setChecksumValue(''); } setCookies(activeItem.cookies || ''); setMirrors(activeItem.mirrors || ''); setLiveTorrentMaxPeersValue( activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers) ); setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || ''); setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true); setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true); setTorrentEncryptionPolicy(normalizeTorrentEncryptionPolicy(activeItem.torrentEncryptionPolicy) || TORRENT_ENCRYPTION_POLICY_DISABLED); setTorrentFileAllocation(normalizeTorrentFileAllocation(activeItem.torrentFileAllocation) || 'prealloc'); setTorrentTrackers(activeItem.torrentTrackers || ''); setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || ''); setTorrentTrackerConnectTimeout(activeItem.torrentTrackerConnectTimeout === undefined ? '' : String(activeItem.torrentTrackerConnectTimeout)); setTorrentTrackerTimeout(activeItem.torrentTrackerTimeout === undefined ? '' : String(activeItem.torrentTrackerTimeout)); setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval)); setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout)); setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || ''); setTorrentWebSeedsText((activeItem.torrentWebSeeds || []) .map(seed => `${seed.fileIndex}|${seed.uri}`) .join('\n')); setErrorMessage(''); } else { setSelectedPropertiesDownloadId(null); } } }, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]); useEffect(() => { torrentFileSelectionRequestRef.current += 1; setTorrentFileSelection(null); if (!selectedPropertiesDownloadId || !item?.isTorrent) return; const requestId = torrentFileSelectionRequestRef.current; const propertiesDownloadId = item.id; void invoke('get_torrent_file_selection', { id: propertiesDownloadId }) .then(snapshot => { if ( requestId === torrentFileSelectionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setTorrentFileSelection(snapshot); } }) .catch(() => { if (requestId === torrentFileSelectionRequestRef.current) setTorrentFileSelection(null); }); }, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]); useEffect(() => { torrentDetailsRequestRef.current += 1; setTorrentDetails(null); setTorrentDetailsError(false); setIsTorrentDetailsPending(false); if (!selectedPropertiesDownloadId || !item?.isTorrent || !item.torrentPath) return; const requestId = torrentDetailsRequestRef.current; const propertiesDownloadId = item.id; setIsTorrentDetailsPending(true); void invoke('get_torrent_details', { id: propertiesDownloadId }) .then(details => { if ( requestId === torrentDetailsRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setTorrentDetails(details); } }) .catch(() => { if ( requestId === torrentDetailsRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setTorrentDetailsError(true); } }) .finally(() => { if (requestId === torrentDetailsRequestRef.current) setIsTorrentDetailsPending(false); }); }, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]); useEffect(() => { const activeLimit = item?.speedLimit?.trim(); setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); }, [item?.speedLimit, selectedPropertiesDownloadId]); useEffect(() => { const activeLimit = item?.torrentUploadLimit?.trim(); setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); }, [item?.torrentUploadLimit, selectedPropertiesDownloadId]); useEffect(() => { peerDiagnosticsRequestRef.current += 1; setTorrentPeerDiagnostics(null); setTorrentPeerDiagnosticsError(false); setIsTorrentPeerDiagnosticsPending(false); }, [item?.id, item?.isTorrent, item?.lastTry, item?.status]); useEffect(() => { torrentFileProgressRequestRef.current += 1; setTorrentFileProgress(null); setTorrentFileProgressError(false); setIsTorrentFileProgressPending(false); if ( !selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status) ) return; const requestId = torrentFileProgressRequestRef.current; const propertiesDownloadId = item.id; setIsTorrentFileProgressPending(true); void invoke('get_torrent_file_progress', { id: propertiesDownloadId }) .then(snapshot => { const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === torrentFileProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isTorrentFileProgressStatus(currentItem.status) ) { setTorrentFileProgress(snapshot); } }) .catch(() => { const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === torrentFileProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isTorrentFileProgressStatus(currentItem.status) ) { setTorrentFileProgressError(true); } }) .finally(() => { if (requestId === torrentFileProgressRequestRef.current) { setIsTorrentFileProgressPending(false); } }); }, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]); useEffect(() => { torrentWebSeedsRequestRef.current += 1; setTorrentWebSeedsError(false); setIsTorrentWebSeedsPending(false); if (!selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status)) return; const requestId = torrentWebSeedsRequestRef.current; const propertiesDownloadId = item.id; setIsTorrentWebSeedsPending(true); void invoke('get_torrent_web_seeds', { id: propertiesDownloadId }) .then(seeds => { if ( requestId === torrentWebSeedsRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n')); useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds }); } }) .catch(() => { if (requestId === torrentWebSeedsRequestRef.current) setTorrentWebSeedsError(true); }) .finally(() => { if (requestId === torrentWebSeedsRequestRef.current) setIsTorrentWebSeedsPending(false); }); }, [item?.id, item?.isTorrent, item?.status, selectedPropertiesDownloadId]); useEffect(() => { torrentPieceProgressRequestRef.current += 1; setTorrentPieceProgress(null); setTorrentPieceProgressError(false); setIsTorrentPieceProgressPending(false); if ( !selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status) ) return; const requestId = torrentPieceProgressRequestRef.current; const propertiesDownloadId = item.id; setIsTorrentPieceProgressPending(true); void invoke('get_torrent_piece_progress', { id: propertiesDownloadId }) .then(snapshot => { const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === torrentPieceProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isTorrentFileProgressStatus(currentItem.status) ) { setTorrentPieceProgress(snapshot); } }) .catch(() => { const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === torrentPieceProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isTorrentFileProgressStatus(currentItem.status) ) { setTorrentPieceProgressError(true); } }) .finally(() => { if (requestId === torrentPieceProgressRequestRef.current) { setIsTorrentPieceProgressPending(false); } }); }, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]); useEffect(() => { setLiveTorrentMaxPeersValue( item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers) ); setLiveTorrentPeerSpeedLimitValue(item?.torrentPeerSpeedLimit || ''); }, [item?.torrentMaxPeers, item?.torrentPeerSpeedLimit, selectedPropertiesDownloadId]); useEffect(() => { if (!selectedPropertiesDownloadId || connectionsDirty) return; const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); if (activeItem && activeItem.connections === undefined) { setConnections(resolveDownloadConnections(undefined, perServerConnections)); } }, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]); useEffect(() => { if (!selectedPropertiesDownloadId) return; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && isTopmostModal(modalRef.current)) { event.preventDefault(); setSelectedPropertiesDownloadId(null); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]); if (!selectedPropertiesDownloadId || !item) return null; const handleBrowse = async () => { if (identityLocked) return; const requestId = ++actionRequestRef.current; const propertiesDownloadId = item.id; try { const selected = await open({ directory: true, multiple: false, defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation }); if ( selected && typeof selected === 'string' && requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setSaveLocation(selected); } } catch (e) { console.error("Failed to select folder:", e); } }; const handleRefreshTorrentPeers = async () => { if ( isTorrentPeerDiagnosticsPending || !item.isTorrent || !isPeerDiagnosticsStatus(item.status) ) return; const requestId = ++peerDiagnosticsRequestRef.current; const propertiesDownloadId = item.id; setIsTorrentPeerDiagnosticsPending(true); setTorrentPeerDiagnosticsError(false); try { const diagnostics = await invoke('get_torrent_peers', { id: propertiesDownloadId }); const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === peerDiagnosticsRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isPeerDiagnosticsStatus(currentItem.status) ) { setTorrentPeerDiagnostics(diagnostics); } } catch { const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === peerDiagnosticsRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isPeerDiagnosticsStatus(currentItem.status) ) { setTorrentPeerDiagnosticsError(true); setTorrentPeerDiagnostics(null); } } finally { if (requestId === peerDiagnosticsRequestRef.current) { setIsTorrentPeerDiagnosticsPending(false); } } }; const handleRefreshTorrentFileProgress = async () => { if ( isTorrentFileProgressPending || !item.isTorrent || !isTorrentFileProgressStatus(item.status) ) return; const requestId = ++torrentFileProgressRequestRef.current; const propertiesDownloadId = item.id; setIsTorrentFileProgressPending(true); setTorrentFileProgressError(false); try { const snapshot = await invoke('get_torrent_file_progress', { id: propertiesDownloadId }); const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === torrentFileProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isTorrentFileProgressStatus(currentItem.status) ) { setTorrentFileProgress(snapshot); } } catch { if ( requestId === torrentFileProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setTorrentFileProgressError(true); setTorrentFileProgress(null); } } finally { if (requestId === torrentFileProgressRequestRef.current) { setIsTorrentFileProgressPending(false); } } }; const handleRefreshTorrentPieceProgress = async () => { if ( isTorrentPieceProgressPending || !item.isTorrent || !isTorrentFileProgressStatus(item.status) ) return; const requestId = ++torrentPieceProgressRequestRef.current; const propertiesDownloadId = item.id; setIsTorrentPieceProgressPending(true); setTorrentPieceProgressError(false); try { const snapshot = await invoke('get_torrent_piece_progress', { id: propertiesDownloadId }); const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); if ( requestId === torrentPieceProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId && currentItem?.isTorrent && isTorrentFileProgressStatus(currentItem.status) ) { setTorrentPieceProgress(snapshot); } } catch { if ( requestId === torrentPieceProgressRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId ) { setTorrentPieceProgressError(true); setTorrentPieceProgress(null); } } finally { if (requestId === torrentPieceProgressRequestRef.current) { setIsTorrentPieceProgressPending(false); } } }; const handleTorrentWebSeedsSave = async () => { if (!item?.isTorrent || isTorrentWebSeedsPending) return; const seeds: TorrentWebSeed[] = []; for (const line of torrentWebSeedsText.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) continue; const separator = trimmed.indexOf('|'); const fileIndex = Number(separator >= 0 ? trimmed.slice(0, separator).trim() : ''); const uri = separator >= 0 ? trimmed.slice(separator + 1).trim() : ''; if (!Number.isInteger(fileIndex) || fileIndex < 0 || !uri) { setTorrentWebSeedsError(true); return; } seeds.push({ fileIndex, uri }); } setIsTorrentWebSeedsPending(true); setTorrentWebSeedsError(false); try { const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds }); setTorrentWebSeedsText(normalized.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n')); useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized }); } catch { setTorrentWebSeedsError(true); } finally { setIsTorrentWebSeedsPending(false); } }; const handleVerifyTorrentData = async () => { if (!item?.isTorrent || isTorrentVerifyPending) return; const restoreStatus = item.status; const previousVerifyOnly = item.torrentVerifyOnly; const previousRestoreStatus = item.torrentVerifyRestoreStatus; // Mark the maintenance lifecycle before invoking the command so a very // fast queued/completed event cannot be mistaken for the normal download // lifecycle. The backend persists the same markers before dispatching. useDownloadStore.getState().updateDownload(item.id, { torrentVerifyOnly: true, torrentVerifyRestoreStatus: restoreStatus }); setIsTorrentVerifyPending(true); try { await invoke('verify_torrent_data', { id: item.id }); } catch (error) { useDownloadStore.getState().updateDownload(item.id, { torrentVerifyOnly: previousVerifyOnly, torrentVerifyRestoreStatus: previousRestoreStatus }); setErrorMessage(error instanceof Error ? error.message : String(error)); } finally { setIsTorrentVerifyPending(false); } }; const handleSave = async () => { if (!url.trim()) { setErrorMessage(t($ => $.properties.enterValidUrl)); return; } if (!fileName.trim()) { setErrorMessage(t($ => $.properties.fileNameEmpty)); return; } const normalizedMaxPeers = liveTorrentMaxPeersValue.trim() ? Number(liveTorrentMaxPeersValue) : undefined; if ( item.isTorrent && normalizedMaxPeers !== undefined && (!Number.isInteger(normalizedMaxPeers) || normalizedMaxPeers < 0 || normalizedMaxPeers > 1000) ) { setErrorMessage(t($ => $.properties.torrentMaxPeersInvalid)); return; } const normalizedPeerSpeedLimit = normalizeSpeedLimitForBackend(liveTorrentPeerSpeedLimitValue); if (item.isTorrent && liveTorrentPeerSpeedLimitValue.trim() && !normalizedPeerSpeedLimit) { setErrorMessage(t($ => $.properties.torrentPeerSpeedLimitInvalid)); return; } if (item.isTorrent && !isValidTorrentTrackerList(torrentTrackers)) { setErrorMessage(t($ => $.properties.torrentTrackersInvalid)); return; } if (item.isTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) { setErrorMessage(t($ => $.properties.torrentExcludeTrackersInvalid)); return; } if (item.isTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) { setErrorMessage(t($ => $.properties.torrentTrackerTimeoutInvalid)); return; } if (item.isTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) { setErrorMessage(t($ => $.properties.torrentTrackerTimeoutInvalid)); return; } if (item.isTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) { setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid)); return; } if (item.isTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) { setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid)); return; } const normalizedStopTimeout = torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined; if ( item.isTorrent && normalizedStopTimeout !== undefined && (!Number.isInteger(normalizedStopTimeout) || normalizedStopTimeout < 0 || normalizedStopTimeout > MAX_TORRENT_STOP_TIMEOUT) ) { setErrorMessage(t($ => $.properties.torrentStopTimeoutInvalid)); return; } if (item.isTorrent && torrentRemoveUnselectedFile && !item.torrentFileIndices?.length) { setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); return; } if (item.isTorrent && !normalizeTorrentEncryptionPolicy(torrentEncryptionPolicy)) { setErrorMessage(t($ => $.properties.torrentEncryptionPolicyInvalid)); return; } const selectedTorrentIndices = torrentFileSelection ? torrentFileSelection.files.filter(file => file.selected).map(file => file.index) : []; const allTorrentFilesSelected = Boolean( torrentFileSelection && selectedTorrentIndices?.length === torrentFileSelection.files.length ); if (torrentFileSelection && selectedTorrentIndices?.length === 0) { setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); return; } if (item.isTorrent && torrentRemoveUnselectedFile && torrentFileSelection && allTorrentFilesSelected) { setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); return; } if ( item.isTorrent && torrentRemoveUnselectedFile && !item.torrentRemoveUnselectedFile && !window.confirm(t($ => $.properties.torrentRemoveUnselectedFileConfirm)) ) { return; } const updates: Partial = { url, fileName, destination: saveLocation, speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined, username: loginMode === 'custom' ? username.trim() : undefined, password: loginMode === 'custom' ? password.trim() : undefined, headers: headers.trim() || undefined, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined, cookies: cookies.trim() || undefined, mirrors: mirrors.trim() || undefined, ...(item.isTorrent ? { torrentMaxPeers: normalizedMaxPeers, torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined, torrentCheckIntegrity, torrentTrackers: torrentTrackers.trim() || undefined, torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined, torrentTrackerConnectTimeout: torrentTrackerConnectTimeout.trim() ? Number(torrentTrackerConnectTimeout) : undefined, torrentTrackerTimeout: torrentTrackerTimeout.trim() ? Number(torrentTrackerTimeout) : undefined, torrentTrackerInterval: torrentTrackerInterval.trim() ? Number(torrentTrackerInterval) : undefined, torrentStopTimeout: normalizedStopTimeout, torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined, torrentFileIndices: torrentFileSelection ? (allTorrentFilesSelected ? undefined : selectedTorrentIndices) : item.torrentFileIndices, torrentRemoveUnselectedFile: (torrentFileSelection ? !allTorrentFilesSelected : item.torrentFileIndices !== undefined) ? torrentRemoveUnselectedFile : undefined, torrentEncryptionPolicy: torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED ? torrentEncryptionPolicy : undefined, torrentFileAllocation, } : {}), ...(connectionsDirty ? { connections: resolveDownloadConnections(connections, perServerConnections) } : {}), }; const requestId = ++actionRequestRef.current; try { setErrorMessage(''); await useDownloadStore.getState().applyProperties(item.id, updates); if ( requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id ) { setSelectedPropertiesDownloadId(null); } } catch (e) { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setErrorMessage(e instanceof Error ? e.message : String(e)); } } }; const handlePauseResume = async () => { const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id); const action = currentItem ? getPauseResumeAction(currentItem.status) : null; if (!currentItem || !action || isPauseResumePending) return; if (action === 'pause' && currentItem.resumable === false) { const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne)); if (!confirmPause) return; } setErrorMessage(''); const requestId = ++actionRequestRef.current; setIsPauseResumePending(true); try { if (action === 'pause') { await useDownloadStore.getState().pauseDownload(currentItem.id); } else { const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id); if (!resumed) { throw new Error(t($ => $.downloadTable.backendRejectedStart)); } } } catch (error) { const detail = error instanceof Error ? error.message : String(error); const message = action === 'pause' ? t($ => $.downloadTable.pauseFailed) : t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName }); if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail })); } } finally { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setIsPauseResumePending(false); } } }; const handleLiveSpeedLimit = async (limit: string | null) => { if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return; setErrorMessage(''); const requestId = ++actionRequestRef.current; setIsLiveSpeedLimitPending(true); try { await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit); if ( limit === null && requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id ) { setLiveSpeedLimitValue(''); } } catch (error) { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, { detail: error instanceof Error ? error.message : String(error) })); } } finally { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setIsLiveSpeedLimitPending(false); } } }; const handleLiveTorrentUploadLimit = async (limit: string | null) => { if ( isLiveTorrentUploadLimitPending || !item.isTorrent || !['downloading', 'seeding', 'retrying'].includes(item.status) ) return; setErrorMessage(''); const requestId = ++actionRequestRef.current; setIsLiveTorrentUploadLimitPending(true); try { await useDownloadStore.getState().setTorrentUploadLimit(item.id, limit); if ( limit === null && requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id ) { setLiveTorrentUploadLimitValue(''); } } catch (error) { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setErrorMessage(t($ => $.properties.liveTorrentUploadLimitFailed, { detail: error instanceof Error ? error.message : String(error) })); } } finally { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setIsLiveTorrentUploadLimitPending(false); } } }; const handleLiveTorrentPeerOptions = async () => { if ( isLiveTorrentPeerOptionsPending || !item.isTorrent || !['downloading', 'seeding', 'retrying'].includes(item.status) ) return; setErrorMessage(''); const requestId = ++actionRequestRef.current; setIsLiveTorrentPeerOptionsPending(true); try { await useDownloadStore.getState().setTorrentPeerOptions( item.id, liveTorrentMaxPeersValue, liveTorrentPeerSpeedLimitValue ); } catch (error) { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setErrorMessage(t($ => $.properties.liveTorrentPeerOptionsFailed, { detail: error instanceof Error ? error.message : String(error) })); } } finally { if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { setIsLiveTorrentPeerOptionsPending(false); } } }; const identityLocked = getIdentityLocked(item.status); const transferLocked = getTransferLocked(item.status); const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status); const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status); const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status); const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status); const torrentPeerDiagnosticsAvailable = item.isTorrent && isPeerDiagnosticsStatus(item.status); const torrentFileSelectionIsEmpty = item.isTorrent && torrentFileSelection !== null && !torrentFileSelection.files.some(file => file.selected); const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections); const observedConnectionTotal = Math.max( 1, liveProgress?.requested_connections ?? configuredConnections ); const observedActiveConnections = liveProgress?.active_connections; const connectionTelemetryActive = item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying'; const connectionStatus = (() => { if (!connectionTelemetryActive) return String(configuredConnections); // yt-dlp exposes the configured fragment limit through Firelink, but its // progress stream does not expose a reliable active-worker count. Keep // the selected limit visible without presenting it as an active count. if (item.isMedia) { return t($ => $.properties.connectionCountUnknown, { total: configuredConnections, }); } if (typeof observedActiveConnections === 'number') { return t($ => $.properties.connectionCount, { active: observedActiveConnections, total: observedConnectionTotal, }); } if (item.status === 'downloading') { return t($ => $.properties.connectionCountUnknown, { total: observedConnectionTotal }); } return t($ => $.properties.connectionCount, { active: 0, total: observedConnectionTotal, }); })(); const displayedFraction = item.status === 'completed' ? 1 : liveProgress?.fraction ?? item.fraction ?? 0; const displayedSpeed = item.status === 'completed' ? '-' : item.status === 'seeding' ? liveProgress?.upload_speed ?? '-' : liveProgress?.speed ?? item.speed ?? '-'; const displayedEta = item.status === 'completed' ? '-' : item.status === 'seeding' ? '-' : liveProgress?.eta ?? item.eta ?? '-'; const sizeDisplay = resolveDownloadSizeDisplay({ downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes, totalBytes: liveProgress?.total_bytes ?? item.totalBytes, totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate, fallbackSize: item.size }); const hasDownloadedAmount = item.status !== 'completed' && Boolean(sizeDisplay.downloaded && sizeDisplay.total); const completedSizeLabel = (() => { const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback; return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value; })(); const statusLabel = t($ => $.downloads.status[item.status]); const pauseResumeAction = getPauseResumeAction(item.status); const pauseResumeLabel = pauseResumeAction === 'pause' ? t($ => $.downloadTable.pause) : t($ => $.downloadTable.resume); const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play; const sizeDescription = sizeDisplay.totalIsEstimate ? t($ => $.downloads.size.downloadedOfApproximate, { downloaded: sizeDisplay.downloaded ?? '', total: sizeDisplay.total ?? '', unit: sizeDisplay.unit ?? '', }) : t($ => $.downloads.size.downloadedOf, { downloaded: sizeDisplay.downloaded ?? '', total: sizeDisplay.total ?? '', unit: sizeDisplay.unit ?? '', }); let statusColor = 'text-text-secondary'; let StatusIcon = Info; if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; } else if (item.status === 'downloading' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; } else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; } else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; } else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; } return (
{ if (event.target === event.currentTarget) setSelectedPropertiesDownloadId(null); }} role="dialog" aria-modal="true" aria-labelledby="properties-modal-title" >
{/* Header Summary */}

{item.fileName}

{statusLabel}
{t($ => $.properties.progress)}{`${(displayedFraction * 100).toFixed(0)}%`}
{t($ => $.properties.size)} {hasDownloadedAmount ? ( <> {sizeDisplay.downloaded} / {sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} {sizeDisplay.unit} ) : completedSizeLabel}
{t($ => $.properties.speed)}{displayedSpeed}
{t($ => $.properties.eta)}{displayedEta}
{t($ => $.properties.connections)} $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}>{connectionStatus}
{t($ => $.properties.speedCap)}{item.speedLimit || '-'}
{t($ => $.properties.category)}{categoryLabel(item.category)}
{t($ => $.properties.lastTry)}{formatLastTry(item.lastTry, i18n.language, calendarPreference)}
{t($ => $.properties.dateAdded)}{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}
{t($ => $.properties.destination)}{saveLocation || baseDownloadFolder}
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
{t($ => $.properties.lastError)} {item.lastError}
)}
{/* Scrollable Form Content */}
{identityLocked && (
{item.status === 'completed' ? : } {item.status === 'completed' ? t($ => $.properties.identityReadOnly) : t($ => $.properties.transferSettings)}
)} {/* Download Section */}

{t($ => $.properties.download)}

setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> {t($ => $.properties.perFile)} {connectionStatus} {!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && ( )}
{t($ => $.properties.savedPerDownload)}
{speedLimitEnabled && (
setSpeedLimitValue(e.target.value)} disabled={transferLocked} className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" /> KiB/s
)}
{t($ => $.properties.savedPerDownload)}
{item.isTorrent && ( <> setLiveTorrentMaxPeersValue(event.currentTarget.value)} placeholder="55" disabled={transferLocked} className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" /> setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)} placeholder="50K" disabled={transferLocked} className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" />
{t($ => $.properties.torrentPeerOptionsSavedHint)}
{torrentFileSelection && (
{t($ => $.properties.torrentFileSelection)}

{t($ => $.properties.torrentFileSelectionHint)}

{torrentFileSelection.files.map(file => ( ))}
)}
{t($ => $.properties.torrentPieceProgress)}

{t($ => $.properties.torrentPieceProgressHint)}

{!isTorrentFileProgressStatus(item.status) && (

{t($ => $.properties.torrentPieceProgressUnavailable)}

)} {torrentPieceProgressError && (

{t($ => $.properties.torrentPieceProgressFailed)}

)} {torrentPieceProgress && ( <>
{t($ => $.properties.torrentPieceProgressSummary, { completed: torrentPieceProgress.completedPieces, total: torrentPieceProgress.numPieces, size: formatDownloadBytes(torrentPieceProgress.pieceLength), })}
$.properties.torrentPieceProgressMap)} > {torrentPieceProgress.buckets.map((percentage, index) => ( ))}
)}
{t($ => $.properties.torrentFileProgress)}

{t($ => $.properties.torrentFileProgressHint)}

{!isTorrentFileProgressStatus(item.status) && (

{t($ => $.properties.torrentFileProgressUnavailable)}

)} {torrentFileProgressError && (

{t($ => $.properties.torrentFileProgressFailed)}

)} {torrentFileProgress && (
{torrentFileProgress.files.map(file => { const percentage = file.length === 0 ? 100 : Math.round((file.completedLength / file.length) * 100); return ( ); })}
# {t($ => $.properties.torrentFileProgressPath)} {t($ => $.properties.torrentFileProgressCompleted)} {t($ => $.properties.torrentFileProgressSelected)}
{file.index} {file.relativePath} {formatDownloadBytes(file.completedLength)} / {formatDownloadBytes(file.length)} ({percentage}%) {file.selected ? t($ => $.properties.torrentFileProgressSelected) : t($ => $.properties.torrentFileProgressUnselected)}
)}
{t($ => $.properties.torrentPeerDiagnostics)}

{t($ => $.properties.torrentPeerDiagnosticsHint)}

{!torrentPeerDiagnosticsAvailable && (

{t($ => $.properties.torrentPeerDiagnosticsUnavailable)}

)} {torrentPeerDiagnosticsError && (

{t($ => $.properties.torrentPeerDiagnosticsFailed)}

)} {torrentPeerDiagnostics && ( <>
{t($ => $.properties.torrentPeerCount, { total: torrentPeerDiagnostics.totalPeers, seeders: torrentPeerDiagnostics.totalSeeders })}
{torrentPeerDiagnostics.peers.map((peer, index) => ( ))}
# {t($ => $.properties.torrentPeerDownload)} {t($ => $.properties.torrentPeerUpload)} {t($ => $.properties.torrentPeerSeeder)} {t($ => $.properties.torrentPeerAmChoking)} {t($ => $.properties.torrentPeerChoking)}
{index + 1} {formatPeerSpeed(peer.downloadSpeed)} {formatPeerSpeed(peer.uploadSpeed)} {peer.seeder ? '✓' : '—'} {peer.amChoking ? '✓' : '—'} {peer.peerChoking ? '✓' : '—'}
{torrentPeerDiagnostics.truncated && (

{t($ => $.properties.torrentPeerShowing, { shown: torrentPeerDiagnostics.peers.length, total: torrentPeerDiagnostics.totalPeers })}

)} )}
)}
{/* Footer */}
{errorMessage}
{pauseResumeAction && ( )}
); };