Files
Firelink/src/components/PropertiesModal.tsx
T
2026-08-03 21:47:02 +03:30

2094 lines
108 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [torrentFileAllocation, setTorrentFileAllocation] = useState<TorrentFileAllocation>('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<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
const [torrentFileProgress, setTorrentFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
const [torrentFileSelection, setTorrentFileSelection] = useState<TorrentFileSelectionSnapshot | null>(null);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails | null>(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<TorrentPieceProgressSnapshot | null>(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<LoginMode>('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<DownloadItem> = {
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 (
<div
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
onClick={(event) => {
if (event.target === event.currentTarget) setSelectedPropertiesDownloadId(null);
}}
role="dialog"
aria-modal="true"
aria-labelledby="properties-modal-title"
>
<div
ref={modalRef}
tabIndex={-1}
data-modal-surface="true"
className="app-modal properties-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm"
>
{/* Header Summary */}
<div className="p-4 px-5 bg-sidebar-bg/50">
<div className="flex items-center justify-between mb-3">
<h2 id="properties-modal-title" className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
<StatusIcon size={14} />
{statusLabel}
</span>
</div>
<div className="w-full bg-border-color rounded-full h-1.5 overflow-hidden mb-4">
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${displayedFraction * 100}%` }}></div>
</div>
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.progress)}</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
<div className="flex gap-1.5 min-w-0">
<span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.size)}</span>
<span
className="truncate"
title={hasDownloadedAmount
? sizeDescription
: completedSizeLabel}
>
{hasDownloadedAmount ? (
<>
<span className={downloadProgressColorClass(item.status)}>{sizeDisplay.downloaded}</span>
<span className="text-text-muted"> / </span>
<span className="text-text-secondary">
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} {sizeDisplay.unit}
</span>
</>
) : completedSizeLabel}
</span>
</div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.speed)}</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">{t($ => $.properties.eta)}</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry, i18n.language, calendarPreference)}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
<div className="flex gap-1.5 col-span-4 min-w-0">
<span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.lastError)}</span>
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
</div>
)}
</div>
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
{/* Scrollable Form Content */}
<div className="flex-1 overflow-y-auto bg-main-bg/30 p-5 space-y-7">
{identityLocked && (
<div className="flex gap-2.5 items-center text-xs text-text-secondary bg-border-color/30 p-3 rounded-md border border-border-modal">
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
<span>
{item.status === 'completed'
? t($ => $.properties.identityReadOnly)
: t($ => $.properties.transferSettings)}
</span>
</div>
)}
{/* Download Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">{t($ => $.properties.download)}</h3>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
<label className="text-xs text-text-muted text-right">{t($ => $.properties.url)}</label>
<input type="text" value={url} onChange={e => 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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.fileName)}</label>
<input type="text" value={fileName} onChange={e => 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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.saveLocation)}</label>
<div className="flex gap-2">
<input type="text" value={saveLocation} readOnly disabled={identityLocked} className="flex-1 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" />
<button onClick={handleBrowse} disabled={identityLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
<FolderPlus size={14} /> {t($ => $.properties.select)}
</button>
</div>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.connections)}</label>
<div className="flex items-center gap-2">
<input type="number" value={connections} min={1} max={16} onChange={e=>{ 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" />
<span className="text-xs text-text-muted">{t($ => $.properties.perFile)}</span>
<span className="text-xs text-text-secondary font-mono" aria-live="polite"><bdi>{connectionStatus}</bdi></span>
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
<button
type="button"
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
className="text-[11px] text-accent hover:underline whitespace-nowrap"
>
{t($ => $.properties.useCurrentDefault, { count: perServerConnections })}
</button>
)}
</div>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.savedPerDownload)}
</div>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speedCap)}</label>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex min-h-7 items-center gap-2 rounded-md border border-border-modal bg-bg-input px-2.5 py-1.5 text-xs text-text-primary">
<input
type="checkbox"
checked={speedLimitEnabled}
onChange={e => setSpeedLimitEnabled(e.target.checked)}
disabled={transferLocked}
className="accent-accent disabled:opacity-50"
/>
{t($ => $.properties.limit)}
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
<input
type="number"
value={speedLimitValue}
min={1}
step={128}
onChange={e => 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"
/>
<span className="text-xs text-text-muted">KiB/s</span>
</div>
)}
</div>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.savedPerDownload)}
</div>
{item.isTorrent && (
<>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentMaxPeers)}</label>
<input
type="number"
min={0}
max={1000}
step={1}
value={liveTorrentMaxPeersValue}
onChange={event => 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"
/>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentPeerSpeedLimit)}</label>
<input
type="text"
inputMode="decimal"
value={liveTorrentPeerSpeedLimitValue}
onChange={event => 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"
/>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
{torrentFileSelection && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentFileSelection)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="app-button px-2.5 text-[11px] disabled:opacity-50"
disabled={transferLocked || torrentFileSelection.files.length === 0}
onClick={() => setTorrentFileSelection(current => current
? { ...current, files: current.files.map(file => ({ ...file, selected: true })) }
: current)}
>
{t($ => $.properties.torrentFileSelectionAll)}
</button>
<button
type="button"
className="app-button px-2.5 text-[11px] disabled:opacity-50"
disabled={transferLocked || torrentFileSelection.files.length === 0}
onClick={() => setTorrentFileSelection(current => current
? { ...current, files: current.files.map(file => ({ ...file, selected: false })) }
: current)}
>
{t($ => $.properties.torrentFileSelectionClear)}
</button>
</div>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileSelectionHint)}
</p>
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
{torrentFileSelection.files.map(file => (
<label key={file.index} className="flex items-center gap-2 border-b border-border-modal/40 px-2 py-1.5 text-[11px] last:border-b-0">
<input
type="checkbox"
checked={file.selected}
disabled={transferLocked}
onChange={event => setTorrentFileSelection(current => current
? {
...current,
files: current.files.map(candidate => candidate.index === file.index
? { ...candidate, selected: event.currentTarget.checked }
: candidate)
}
: current)}
className="accent-accent disabled:opacity-50"
/>
<span className="font-mono text-text-muted">{file.index}</span>
<span className="min-w-0 flex-1 truncate" dir="auto" title={file.relativePath}>{file.relativePath}</span>
<span className="text-end text-text-muted whitespace-nowrap">
{(() => {
const progress = torrentFileProgressByIndex.get(file.index);
const completedLength = progress?.completedLength ?? file.completedLength ?? 0;
const percentage = file.length === 0
? 100
: Math.round((completedLength / file.length) * 100);
return `${formatDownloadBytes(completedLength)} / ${formatDownloadBytes(file.length)} (${percentage}%)`;
})()}
</span>
</label>
))}
</div>
</div>
)}
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentPieceProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentPieceProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentPieceProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentPieceProgressPending
? t($ => $.properties.torrentPieceProgressLoading)
: t($ => $.properties.torrentPieceProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressUnavailable)}
</p>
)}
{torrentPieceProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentPieceProgressFailed)}
</p>
)}
{torrentPieceProgress && (
<>
<div className="text-[11px] text-text-secondary">
{t($ => $.properties.torrentPieceProgressSummary, {
completed: torrentPieceProgress.completedPieces,
total: torrentPieceProgress.numPieces,
size: formatDownloadBytes(torrentPieceProgress.pieceLength),
})}
</div>
<div
className="grid gap-0.5 rounded border border-border-modal/60 bg-bg-input p-1"
style={{ gridTemplateColumns: `repeat(${Math.min(16, Math.max(1, torrentPieceProgress.buckets.length))}, minmax(0, 1fr))` }}
role="img"
aria-label={t($ => $.properties.torrentPieceProgressMap)}
>
{torrentPieceProgress.buckets.map((percentage, index) => (
<span
key={index}
className="aspect-square min-w-1 rounded-sm bg-blue-500 motion-safe:transition-opacity motion-reduce:transition-none"
style={{ opacity: Math.max(0.15, percentage / 100) }}
title={`${percentage}%`}
/>
))}
</div>
</>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentFileProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentFileProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentFileProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentFileProgressPending
? t($ => $.properties.torrentFileProgressLoading)
: t($ => $.properties.torrentFileProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressUnavailable)}
</p>
)}
{torrentFileProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentFileProgressFailed)}
</p>
)}
{torrentFileProgress && (
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-bg-input text-text-muted">
<tr>
<th className="px-2 py-1 text-start">#</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressPath)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressCompleted)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressSelected)}</th>
</tr>
</thead>
<tbody>
{torrentFileProgress.files.map(file => {
const percentage = file.length === 0
? 100
: Math.round((file.completedLength / file.length) * 100);
return (
<tr key={file.index} className="border-t border-border-modal/40 text-text-primary">
<td className="px-2 py-1 font-mono">{file.index}</td>
<td className="px-2 py-1 max-w-[220px] truncate" title={file.relativePath} dir="auto">{file.relativePath}</td>
<td className="px-2 py-1 font-mono whitespace-nowrap">
{formatDownloadBytes(file.completedLength)} / {formatDownloadBytes(file.length)} ({percentage}%)
</td>
<td className="px-2 py-1">
{file.selected
? t($ => $.properties.torrentFileProgressSelected)
: t($ => $.properties.torrentFileProgressUnselected)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentPeerDiagnostics)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentPeers()}
disabled={!torrentPeerDiagnosticsAvailable || isTorrentPeerDiagnosticsPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentPeerDiagnosticsPending
? t($ => $.properties.torrentPeerDiagnosticsLoading)
: t($ => $.properties.torrentPeerDiagnosticsRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerDiagnosticsHint)}
</p>
{!torrentPeerDiagnosticsAvailable && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerDiagnosticsUnavailable)}
</p>
)}
{torrentPeerDiagnosticsError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentPeerDiagnosticsFailed)}
</p>
)}
{torrentPeerDiagnostics && (
<>
<div className="text-[11px] font-medium text-text-primary">
{t($ => $.properties.torrentPeerCount, {
total: torrentPeerDiagnostics.totalPeers,
seeders: torrentPeerDiagnostics.totalSeeders
})}
</div>
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-bg-input text-text-muted">
<tr>
<th className="px-2 py-1 text-start">#</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerDownload)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerUpload)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerSeeder)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerAmChoking)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerChoking)}</th>
</tr>
</thead>
<tbody>
{torrentPeerDiagnostics.peers.map((peer, index) => (
<tr key={`${index}-${peer.downloadSpeed}-${peer.uploadSpeed}`} className="border-t border-border-modal/40 text-text-primary">
<td className="px-2 py-1 font-mono">{index + 1}</td>
<td className="px-2 py-1 font-mono">{formatPeerSpeed(peer.downloadSpeed)}</td>
<td className="px-2 py-1 font-mono">{formatPeerSpeed(peer.uploadSpeed)}</td>
<td className="px-2 py-1">{peer.seeder ? '✓' : '—'}</td>
<td className="px-2 py-1">{peer.amChoking ? '✓' : '—'}</td>
<td className="px-2 py-1">{peer.peerChoking ? '✓' : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{torrentPeerDiagnostics.truncated && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerShowing, {
shown: torrentPeerDiagnostics.peers.length,
total: torrentPeerDiagnostics.totalPeers
})}
</p>
)}
</>
)}
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-trackers-properties">
{t($ => $.properties.torrentTrackers)}
</label>
<div>
<textarea
id="torrent-trackers-properties"
rows={3}
value={torrentTrackers}
onChange={event => setTorrentTrackers(event.currentTarget.value)}
placeholder="https://tracker.example/announce"
disabled={transferLocked}
aria-describedby="torrent-trackers-properties-hint"
className="app-control min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<p id="torrent-trackers-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentTrackersHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-exclude-trackers-properties">
{t($ => $.properties.torrentExcludeTrackers)}
</label>
<div>
<textarea
id="torrent-exclude-trackers-properties"
rows={3}
value={torrentExcludeTrackers}
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
placeholder="https://tracker.example/announce or *"
disabled={transferLocked}
aria-describedby="torrent-exclude-trackers-properties-hint"
className="app-control min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<p id="torrent-exclude-trackers-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentExcludeTrackersHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-tracker-connect-timeout-properties">
{t($ => $.properties.torrentTrackerConnectTimeout)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-tracker-connect-timeout-properties"
type="number"
min={1}
max={MAX_TORRENT_TRACKER_TIMEOUT}
step={1}
value={torrentTrackerConnectTimeout}
onChange={event => setTorrentTrackerConnectTimeout(event.currentTarget.value)}
placeholder="60"
disabled={transferLocked}
aria-describedby="torrent-tracker-timing-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-tracker-timeout-properties">
{t($ => $.properties.torrentTrackerTimeout)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-tracker-timeout-properties"
type="number"
min={1}
max={MAX_TORRENT_TRACKER_TIMEOUT}
step={1}
value={torrentTrackerTimeout}
onChange={event => setTorrentTrackerTimeout(event.currentTarget.value)}
placeholder="60"
disabled={transferLocked}
aria-describedby="torrent-tracker-timing-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-tracker-interval-properties">
{t($ => $.properties.torrentTrackerInterval)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-tracker-interval-properties"
type="number"
min={0}
max={MAX_TORRENT_TRACKER_INTERVAL}
step={1}
value={torrentTrackerInterval}
onChange={event => setTorrentTrackerInterval(event.currentTarget.value)}
disabled={transferLocked}
aria-describedby="torrent-tracker-timing-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
<p id="torrent-tracker-timing-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentTrackerTimingHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-stop-timeout-properties">
{t($ => $.properties.torrentStopTimeout)}
</label>
<div>
<div className="flex items-center gap-2">
<input
id="torrent-stop-timeout-properties"
type="number"
min={0}
max={MAX_TORRENT_STOP_TIMEOUT}
step={1}
value={torrentStopTimeout}
onChange={event => setTorrentStopTimeout(event.currentTarget.value)}
disabled={transferLocked}
aria-describedby="torrent-stop-timeout-properties-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
</div>
<p id="torrent-stop-timeout-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentStopTimeoutHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-prioritize-piece-properties">
{t($ => $.properties.torrentPrioritizePiece)}
</label>
<div>
<input
id="torrent-prioritize-piece-properties"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
disabled={transferLocked}
aria-describedby="torrent-prioritize-piece-properties-hint"
className="app-control w-full px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<p id="torrent-prioritize-piece-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-file-allocation-properties">
{t($ => $.properties.torrentFileAllocation)}
</label>
<div>
<select
id="torrent-file-allocation-properties"
value={torrentFileAllocation}
onChange={event => setTorrentFileAllocation(event.currentTarget.value as TorrentFileAllocation)}
disabled={transferLocked}
aria-describedby="torrent-file-allocation-properties-hint"
className="app-control max-w-56 px-2.5 py-1.5 text-xs disabled:opacity-50"
>
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
</select>
<p id="torrent-file-allocation-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentFileAllocationHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-encryption-policy-properties">
{t($ => $.properties.torrentEncryptionPolicy)}
</label>
<div>
<select
id="torrent-encryption-policy-properties"
value={torrentEncryptionPolicy}
onChange={event => setTorrentEncryptionPolicy(event.currentTarget.value as TorrentEncryptionPolicy)}
disabled={transferLocked}
aria-describedby="torrent-encryption-policy-properties-hint"
className="app-control max-w-56 px-2.5 py-1.5 text-xs disabled:opacity-50"
>
<option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>
{t($ => $.properties.torrentEncryptionDisabled)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>
{t($ => $.properties.torrentEncryptionRequireCrypto)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>
{t($ => $.properties.torrentEncryptionForceEncryption)}
</option>
</select>
<p id="torrent-encryption-policy-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentEncryptionPolicyHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
{t($ => $.properties.torrentVerifyIntegrity)}
</label>
<label className="flex items-start gap-2 text-xs text-text-primary">
<input
id="torrent-check-integrity"
type="checkbox"
checked={torrentCheckIntegrity}
onChange={event => setTorrentCheckIntegrity(event.currentTarget.checked)}
disabled={transferLocked}
className="accent-accent mt-0.5 disabled:opacity-50"
aria-describedby="torrent-check-integrity-hint"
/>
<span id="torrent-check-integrity-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentVerifyIntegrityHint)}
</span>
</label>
<div className="col-start-2">
<button
type="button"
onClick={() => void handleVerifyTorrentData()}
disabled={isTorrentVerifyPending || getTransferLocked(item.status) || !['completed', 'paused', 'failed'].includes(item.status)}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentVerifyPending
? t($ => $.properties.torrentVerifyNowLoading)
: t($ => $.properties.torrentVerifyNow)}
</button>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-remove-unselected-file">
{t($ => $.properties.torrentRemoveUnselectedFile)}
</label>
<label className="flex items-start gap-2 text-xs text-text-primary">
<input
id="torrent-remove-unselected-file"
type="checkbox"
checked={torrentRemoveUnselectedFile}
onChange={event => setTorrentRemoveUnselectedFile(event.currentTarget.checked)}
disabled={transferLocked || !item.torrentFileIndices?.length}
className="accent-red-500 mt-0.5 disabled:opacity-50"
aria-describedby="torrent-remove-unselected-file-hint"
/>
<span id="torrent-remove-unselected-file-hint" className="text-[11px] text-text-muted">
{item.torrentFileIndices?.length
? t($ => $.properties.torrentRemoveUnselectedFileHint)
: t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)}
</span>
</label>
</>
)}
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
{liveSpeedLimitAvailable ? (
<>
<label htmlFor="live-speed-limit" className="block text-xs font-semibold text-text-primary">
{t($ => $.properties.liveSpeedLimit)}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
id="live-speed-limit"
type="text"
inputMode="decimal"
value={liveSpeedLimitValue}
onChange={event => setLiveSpeedLimitValue(event.currentTarget.value)}
placeholder={t($ => $.properties.liveSpeedLimitPlaceholder)}
disabled={isLiveSpeedLimitPending}
aria-describedby="live-speed-limit-hint"
className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<button
type="button"
onClick={() => void handleLiveSpeedLimit(liveSpeedLimitValue)}
disabled={isLiveSpeedLimitPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitApply)}
</button>
<button
type="button"
onClick={() => void handleLiveSpeedLimit(null)}
disabled={isLiveSpeedLimitPending || !liveSpeedLimitValue}
className="app-button px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitClear)}
</button>
</div>
<p id="live-speed-limit-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveSpeedLimitHint)}
</p>
</>
) : (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.liveSpeedLimitUnavailable)}
</p>
)}
</div>
)}
{liveTorrentUploadLimitAvailable && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<label htmlFor="live-torrent-upload-limit" className="block text-xs font-semibold text-text-primary">
{t($ => $.properties.liveTorrentUploadLimit)}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
id="live-torrent-upload-limit"
type="text"
inputMode="decimal"
value={liveTorrentUploadLimitValue}
onChange={event => setLiveTorrentUploadLimitValue(event.currentTarget.value)}
placeholder={t($ => $.properties.liveTorrentUploadLimitPlaceholder)}
disabled={isLiveTorrentUploadLimitPending}
aria-describedby="live-torrent-upload-limit-hint"
className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<button
type="button"
onClick={() => void handleLiveTorrentUploadLimit(liveTorrentUploadLimitValue)}
disabled={isLiveTorrentUploadLimitPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitApply)}
</button>
<button
type="button"
onClick={() => void handleLiveTorrentUploadLimit(null)}
disabled={isLiveTorrentUploadLimitPending || !liveTorrentUploadLimitValue}
className="app-button px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitClear)}
</button>
</div>
<p id="live-torrent-upload-limit-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveTorrentUploadLimitHint)}
</p>
</div>
)}
{liveTorrentPeerOptionsAvailable && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.liveTorrentPeerOptions)}
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-2">
<label htmlFor="live-torrent-max-peers" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentMaxPeers)}
</label>
<input
id="live-torrent-max-peers"
type="number"
min={0}
max={1000}
step={1}
value={liveTorrentMaxPeersValue}
onChange={event => setLiveTorrentMaxPeersValue(event.currentTarget.value)}
placeholder="55"
disabled={isLiveTorrentPeerOptionsPending}
aria-describedby="live-torrent-peer-options-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<label htmlFor="live-torrent-peer-speed-limit" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerSpeedLimit)}
</label>
<input
id="live-torrent-peer-speed-limit"
type="text"
inputMode="decimal"
value={liveTorrentPeerSpeedLimitValue}
onChange={event => setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)}
placeholder="50K"
disabled={isLiveTorrentPeerOptionsPending}
aria-describedby="live-torrent-peer-options-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={() => void handleLiveTorrentPeerOptions()}
disabled={isLiveTorrentPeerOptionsPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveTorrentPeerOptionsApply)}
</button>
<p id="live-torrent-peer-options-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveTorrentPeerOptionsHint)}
</p>
</div>
)}
</div>
</section>
{/* Site Login Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{item.status === 'completed' ? t($ => $.properties.siteLoginRedownload) : t($ => $.properties.siteLogin)}
</h3>
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
{(['matching', 'custom', 'none'] as const).map((mode) => (
<button
key={mode}
onClick={() => !transferLocked && setLoginMode(mode)}
disabled={transferLocked}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
>
{mode === 'matching' ? t($ => $.properties.matchingSiteLogin) : mode === 'custom' ? t($ => $.properties.customCredentials) : t($ => $.properties.noLogin)}
</button>
))}
</div>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
{loginMode === 'matching' && (
<div className="col-start-2 text-xs text-text-secondary italic">
{t($ => $.properties.useSavedLogin)}
</div>
)}
{loginMode === 'custom' && (
<>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.username)}</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.username)} className="max-w-[250px] 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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.password)}</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.password)} className="max-w-[250px] 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" />
</>
)}
</div>
</section>
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentDetails)}
</h3>
{isTorrentDetailsPending && (
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
)}
{torrentDetailsError && (
<p className="text-xs text-red-400">{t($ => $.properties.torrentDetailsUnavailable)}</p>
)}
{torrentDetails && (
<div className="grid grid-cols-[100px_1fr] gap-x-4 gap-y-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs">
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsDisplayName)}</span>
<span className="text-text-primary break-words" dir="auto">{torrentDetails.displayName}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsInfoHash)}</span>
<span className="font-mono text-text-primary break-all">{torrentDetails.infoHash}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsSize)}</span>
<span className="text-text-primary">{formatDownloadBytes(torrentDetails.totalBytes)}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsFiles)}</span>
<span className="text-text-primary">{torrentDetails.fileCount}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsPieces)}</span>
<span className="text-text-primary">{torrentDetails.pieceCount} × {formatDownloadBytes(torrentDetails.pieceLength)}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsPrivate)}</span>
<span className="text-text-primary">{torrentDetails.private
? t($ => $.properties.torrentDetailsPrivateYes)
: t($ => $.properties.torrentDetailsPrivateNo)}</span>
{torrentDetails.creationDate && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsCreated)}</span>
<span className="text-text-primary">{formatDateTime(torrentDetails.creationDate, {
locale: i18n.language,
calendar: calendarPreference,
options: { dateStyle: 'medium', timeStyle: 'short' }
})}</span>
</>
)}
{torrentDetails.creator && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsCreator)}</span>
<span className="text-text-primary break-words" dir="auto">{torrentDetails.creator}</span>
</>
)}
{torrentDetails.comment && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsComment)}</span>
<span className="whitespace-pre-wrap break-words text-text-primary" dir="auto">{torrentDetails.comment}</span>
</>
)}
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsTrackers)}</span>
<span className="text-text-primary break-words" dir="auto">
{torrentDetails.trackers.length > 0 ? torrentDetails.trackers.join(', ') : '—'}
</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsWebSeeds)}</span>
<span className="text-text-primary break-words" dir="auto">
{torrentDetails.webSeeds.length > 0 ? torrentDetails.webSeeds.join(', ') : '—'}
</span>
{torrentDetails.private && (
<p className="col-span-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentDetailsPrivateHint)}
</p>
)}
</div>
)}
</section>
)}
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentWebSeeds)}
</h3>
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
<textarea
value={torrentWebSeedsText}
onChange={event => setTorrentWebSeedsText(event.target.value)}
placeholder={t($ => $.properties.torrentWebSeedsPlaceholder)}
disabled={isTorrentWebSeedsPending}
aria-label={t($ => $.properties.torrentWebSeeds)}
className="w-full h-20 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 resize-none"
/>
<div className="flex items-center justify-between mt-2 gap-2">
<span className="text-xs text-red-500">
{torrentWebSeedsError ? t($ => $.properties.torrentWebSeedsFailed) : ''}
</span>
<button
type="button"
onClick={() => void handleTorrentWebSeedsSave()}
disabled={isTorrentWebSeedsPending}
className="app-button px-3 text-xs"
>
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
</button>
</div>
</section>
)}
{/* Advanced Transfer Section */}
<section>
<button
onClick={() => setAdvancedExpanded(!advancedExpanded)}
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{item.status === 'completed' ? t($ => $.properties.advancedTransferRedownload) : t($ => $.properties.advancedTransfer)}
</button>
{advancedExpanded && (
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
<label className="text-xs text-text-muted text-right">{t($ => $.properties.checksum)}</label>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
{t($ => $.properties.verify)}
</label>
{checksumEnabled && (
<>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.algorithm)}</label>
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] 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">
<option value="MD5">MD5</option>
<option value="SHA-1">SHA-1</option>
<option value="SHA-256">SHA-256</option>
<option value="SHA-512">SHA-512</option>
</select>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.digest)}</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.expectedDigest)} 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" />
</>
)}
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
<input type="password" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} autoComplete="off" placeholder={t($ => $.properties.cookies)} 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" />
<div className="col-span-2 mt-2">
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} className="w-full h-16 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 resize-none"></textarea>
</div>
<div className="col-span-2">
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.mirrors)}</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} className="w-full h-16 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 resize-none"></textarea>
</div>
</div>
)}
</section>
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
{/* Footer */}
<div className="p-3 px-4 bg-sidebar-bg flex items-center justify-between shrink-0">
<div className="text-red-500 text-xs truncate max-w-[400px]">
{errorMessage}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button px-4 text-xs"
>
{t($ => $.properties.cancel)}
</button>
{pauseResumeAction && (
<button
type="button"
onClick={() => void handlePauseResume()}
disabled={isPauseResumePending}
aria-label={pauseResumeLabel}
title={pauseResumeLabel}
className={`app-button px-4 text-xs ${isPauseResumePending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<PauseResumeIcon size={14} fill="currentColor" />
{pauseResumeLabel}
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={transferLocked || torrentFileSelectionIsEmpty}
className={`app-button app-button-primary px-4 text-xs ${transferLocked || torrentFileSelectionIsEmpty ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
{t($ => $.properties.save)}
</button>
</div>
</div>
</div>
</div>
);
};