feat(torrent): complete lifecycle controls and diagnostics

- add durable Torrent telemetry, availability, sharing, relocation, and web-seed workflows\n- fence queue ownership, lifecycle recovery, persistence, and native control races\n- add localized UI, generated bindings, regression coverage, and smoke validation
This commit is contained in:
NimBold
2026-08-04 01:18:24 +03:30
parent 55a905df14
commit 579a8f7f80
33 changed files with 3505 additions and 139 deletions
+108 -21
View File
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
@@ -52,6 +52,7 @@ import {
type MediaSelection
} from '../utils/addDownloadMetadata';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
const formatBytes = (bytes: number) => {
const k = 1024;
@@ -241,7 +242,17 @@ export const AddDownloadsModal = () => {
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [torrentFileAllocation, setTorrentFileAllocation] = useState<TorrentFileAllocation>('prealloc');
const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false);
const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M');
const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false);
const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M');
const torrentPreviewPriority = serializeTorrentPreviewPriority(
torrentPreviewHeadEnabled,
torrentPreviewHeadSize,
torrentPreviewTailEnabled,
torrentPreviewTailSize
);
const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0);
@@ -389,6 +400,11 @@ export const AddDownloadsModal = () => {
setTorrentTrackerTimeout('');
setTorrentTrackerInterval('0');
setTorrentStopTimeout('0');
setTorrentFileAllocation('prealloc');
setTorrentPreviewHeadEnabled(false);
setTorrentPreviewHeadSize('1M');
setTorrentPreviewTailEnabled(false);
setTorrentPreviewTailSize('1M');
setUseAuth(false);
setUsername('');
setPassword('');
@@ -1005,10 +1021,18 @@ export const AddDownloadsModal = () => {
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) {
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
return;
}
for (const item of selectedItems) {
if (!item.isTorrent || !item.torrentFiles?.length) continue;
const rows = item.torrentWebSeedRows ?? [];
if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) {
addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true });
return;
}
}
if (
hasSelectedTorrent
&& torrentStopTimeout.trim()
@@ -1529,7 +1553,11 @@ export const AddDownloadsModal = () => {
? Number(torrentTrackerInterval)
: undefined,
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined,
torrentPrioritizePiece: item.isTorrent ? normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined : undefined,
torrentPrioritizePiece: item.isTorrent ? torrentPreviewPriority || undefined : undefined,
torrentFileAllocation: item.isTorrent ? torrentFileAllocation : undefined,
torrentWebSeeds: item.isTorrent && item.torrentFiles
? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined
: undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
@@ -2112,6 +2140,24 @@ export const AddDownloadsModal = () => {
</section>
)}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
<section className="add-download-section relative overflow-hidden p-4">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<Link size={16} className="text-blue-500" /> {t($ => $.properties.torrentWebSeeds)}
</div>
<p className="text-[11px] text-text-muted mb-3">{t($ => $.properties.torrentWebSeedsHint)}</p>
<TorrentWebSeedEditor
files={parsedItems[selectedItemIndex].torrentFiles ?? []}
rows={parsedItems[selectedItemIndex].torrentWebSeedRows ?? []}
onChange={rows => setParsedItems(items => items.map((item, index) => index === selectedItemIndex
? { ...item, torrentWebSeedRows: rows }
: item
))}
idPrefix="add-torrent-web-seed"
/>
</section>
)}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
<section className="add-download-section relative overflow-hidden p-4">
<div className="add-download-section-title flex items-center gap-2 mb-3">
@@ -2195,6 +2241,64 @@ export const AddDownloadsModal = () => {
</span>
</span>
</label>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-file-allocation" className="text-text-muted">
{t($ => $.properties.torrentFileAllocation)}
</label>
<select
id="torrent-file-allocation"
value={torrentFileAllocation}
onChange={event => setTorrentFileAllocation(event.currentTarget.value as TorrentFileAllocation)}
aria-describedby="torrent-file-allocation-hint"
className="app-control max-w-56 px-2 py-1 text-xs"
>
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
</select>
<p id="torrent-file-allocation-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.properties.torrentFileAllocationHint)}
</p>
</div>
<div className="space-y-2 pt-2 border-t border-border-modal/50">
<span className="block text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}</span>
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentPreviewHeadEnabled}
onChange={event => setTorrentPreviewHeadEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceHead)}
<input
type="text"
value={torrentPreviewHeadSize}
onChange={event => setTorrentPreviewHeadSize(event.currentTarget.value)}
disabled={!torrentPreviewHeadEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentPreviewTailEnabled}
onChange={event => setTorrentPreviewTailEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceTail)}
<input
type="text"
value={torrentPreviewTailSize}
onChange={event => setTorrentPreviewTailSize(event.currentTarget.value)}
disabled={!torrentPreviewTailEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<p id="torrent-prioritize-piece-hint" className="text-[10px] text-text-muted">
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-encryption-policy" className="text-text-muted">
{t($ => $.addDownloads.torrentEncryptionPolicy)}
@@ -2382,23 +2486,6 @@ export const AddDownloadsModal = () => {
{t($ => $.addDownloads.torrentStopTimeoutHint)}
</p>
</div>
<div className="pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-prioritize-piece" className="block text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePiece)}
</label>
<input
id="torrent-prioritize-piece"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
aria-describedby="torrent-prioritize-piece-hint"
className="app-control mt-1 w-full px-2.5 py-1.5 text-xs font-mono"
/>
<p id="torrent-prioritize-piece-hint" className="mt-1 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePieceHint)}
</p>
</div>
</div>
</section>
)}
+11 -3
View File
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { formatDateTime } from '../utils/dateTime';
import {
downloadProgressColorClass,
formatTorrentDuration,
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
@@ -72,6 +73,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const { t, i18n } = useTranslation();
const calendarPreference = useSettingsStore(state => state.calendarPreference);
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(null);
const [isRowHovered, setIsRowHovered] = React.useState(false);
const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
@@ -178,7 +180,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
};
}, [isActionVisible, updateActionPosition]);
const displayFraction = download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
const displayFraction = download.status === 'moving'
? moveProgress ?? download.fraction ?? 0
: download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
? liveProgress?.fraction ?? download.fraction ?? 0
: download.fraction ?? 0;
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
@@ -190,7 +194,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'seeding'
? '-'
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language)
: '-'
: download.status === 'downloading' || download.status === 'verifying'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
@@ -298,6 +304,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' :
download.status === 'verifying' ? 'processing' :
download.status === 'moving' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
}`}
@@ -322,6 +329,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'verifying' ? 'download-status-processing' :
download.status === 'moving' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
@@ -334,7 +342,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{downloadStatusLabel} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' || download.status === 'verifying' ? (
) : download.status === 'downloading' || download.status === 'verifying' || download.status === 'moving' ? (
displayPercent
) : download.status === 'seeding' ? (
displayPercent
+18
View File
@@ -25,6 +25,7 @@ import {
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
import { readClipboardDownloadUrls } from '../utils/clipboard';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { useTranslation } from 'react-i18next';
import {
sortDownloads,
@@ -2552,6 +2553,23 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
{t($ => $.downloadTable.copyAddress)}
</button>
{contextItem.isTorrent && (
<button
onClick={async () => {
setContextMenu(null);
try {
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
await writeClipboardText(magnet);
} catch (error) {
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
{t($ => $.downloadTable.copyMagnet)}
</button>
)}
{contextItem.status === 'completed' && (
<button
onClick={async () => {
+303 -48
View File
@@ -8,10 +8,11 @@ import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgres
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 type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
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 { open, save } from '@tauri-apps/plugin-dialog';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { resolveCategoryDestination } from '../utils/downloadLocations';
import {
getPauseResumeAction,
@@ -22,12 +23,15 @@ import {
downloadProgressColorClass,
formatDownloadBytes,
formatDownloadTotal,
formatTorrentDuration,
formatTorrentRatio,
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 { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, parseTorrentPreviewPriority, serializeTorrentPreviewPriority, torrentWebSeedDraftsFromSeeds, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation, type TorrentWebSeedDraft } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -50,6 +54,9 @@ const isPeerDiagnosticsStatus = (status: string): boolean =>
const isTorrentFileProgressStatus = (status: string): boolean =>
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
const isTorrentAvailabilityStatus = (status: string): boolean =>
['downloading', 'verifying', 'seeding', 'retrying', 'paused'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
@@ -78,6 +85,9 @@ export const PropertiesModal = () => {
? state.progressMap[selectedPropertiesDownloadId]
: undefined
));
const moveProgress = useDownloadProgressStore(state =>
selectedPropertiesDownloadId ? state.moveProgressMap[selectedPropertiesDownloadId] : undefined
);
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
@@ -104,7 +114,10 @@ export const PropertiesModal = () => {
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false);
const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M');
const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false);
const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M');
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
@@ -119,10 +132,15 @@ export const PropertiesModal = () => {
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false);
const [torrentAvailability, setTorrentAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
const [torrentAvailabilityError, setTorrentAvailabilityError] = useState(false);
const [isTorrentAvailabilityPending, setIsTorrentAvailabilityPending] = useState(false);
const [torrentShareMessage, setTorrentShareMessage] = useState('');
const [isTorrentMovePending, setIsTorrentMovePending] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
const [torrentWebSeedRows, setTorrentWebSeedRows] = useState<TorrentWebSeedDraft[]>([]);
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
@@ -149,6 +167,7 @@ export const PropertiesModal = () => {
const torrentFileSelectionRequestRef = useRef(0);
const torrentDetailsRequestRef = useRef(0);
const torrentPieceProgressRequestRef = useRef(0);
const torrentAvailabilityRequestRef = useRef(0);
const torrentWebSeedsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
@@ -175,9 +194,16 @@ export const PropertiesModal = () => {
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
torrentAvailabilityRequestRef.current += 1;
setTorrentAvailability(null);
setTorrentAvailabilityError(false);
setIsTorrentAvailabilityPending(false);
setTorrentShareMessage('');
setIsTorrentMovePending(false);
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
setTorrentWebSeedRows([]);
}, [selectedPropertiesDownloadId]);
useEffect(() => {
@@ -251,10 +277,12 @@ export const PropertiesModal = () => {
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'));
const previewPriority = parseTorrentPreviewPriority(activeItem.torrentPrioritizePiece);
setTorrentPreviewHeadEnabled(Boolean(previewPriority.head));
setTorrentPreviewHeadSize(previewPriority.head || '1M');
setTorrentPreviewTailEnabled(Boolean(previewPriority.tail));
setTorrentPreviewTailSize(previewPriority.tail || '1M');
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(activeItem.torrentWebSeeds));
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -389,7 +417,7 @@ export const PropertiesModal = () => {
requestId === torrentWebSeedsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(seeds));
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds });
}
})
@@ -611,26 +639,126 @@ export const PropertiesModal = () => {
}
};
const handleRefreshTorrentAvailability = async () => {
if (
isTorrentAvailabilityPending
|| !item.isTorrent
|| !isTorrentAvailabilityStatus(item.status)
) return;
const requestId = ++torrentAvailabilityRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentAvailabilityPending(true);
setTorrentAvailabilityError(false);
try {
const snapshot = await invoke('get_torrent_availability', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentAvailabilityRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentAvailabilityStatus(currentItem.status)
) {
setTorrentAvailability(snapshot);
}
} catch {
if (
requestId === torrentAvailabilityRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentAvailabilityError(true);
setTorrentAvailability(null);
}
} finally {
if (requestId === torrentAvailabilityRequestRef.current) setIsTorrentAvailabilityPending(false);
}
};
const handleCopyTorrentMagnet = async () => {
if (!item.isTorrent) return;
setTorrentShareMessage('');
try {
const magnet = await invoke('get_torrent_magnet_link', { id: item.id });
await writeClipboardText(magnet);
setTorrentShareMessage(t($ => $.properties.torrentMagnetCopied));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMagnetCopyFailed));
}
};
const handleExportTorrentMetadata = async () => {
if (!item.isTorrent) return;
setTorrentShareMessage('');
try {
const destination = await save({
defaultPath: `${item.fileName.replace(/\.torrent$/i, '')}.torrent`,
filters: [{ name: 'Torrent metadata', extensions: ['torrent'] }]
});
if (!destination) return;
await invoke('export_torrent_metadata', { id: item.id, destination });
setTorrentShareMessage(t($ => $.properties.torrentMetadataExported));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMetadataExportFailed));
}
};
const handleMoveTorrentData = async () => {
if (!item.isTorrent || isTorrentMovePending || !['paused', 'completed', 'failed'].includes(item.status)) return;
const propertiesDownloadId = item.id;
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (!selected || typeof selected !== 'string') return;
if (!window.confirm(t($ => $.properties.torrentMoveConfirm))) return;
setIsTorrentMovePending(true);
setTorrentShareMessage('');
try {
await invoke('move_torrent_data', { id: propertiesDownloadId, destination: selected });
useDownloadStore.getState().updateDownload(propertiesDownloadId, {
destination: selected,
torrentRelocationCheckPending: item.torrentRelocationCheckPending === true
|| item.status === 'paused'
|| item.status === 'failed'
? true
: undefined
});
setSaveLocation(selected);
setTorrentShareMessage(t($ => $.properties.torrentMoveCompleted));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMoveFailed));
} finally {
setIsTorrentMovePending(false);
}
};
const handleCancelTorrentMove = async () => {
if (!item?.isTorrent || !isTorrentMovePending) return;
try {
await invoke('cancel_torrent_move_data', { id: item.id });
setTorrentShareMessage(t($ => $.properties.torrentMoveCancelRequested));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMoveFailed));
}
};
const torrentWebSeedFiles = (torrentFileSelection?.files ?? torrentFileProgress?.files ?? [])
.map(file => ({ index: file.index, relativePath: file.relativePath }));
const torrentWebSeedsMetadataPending = torrentWebSeedRows.length > 0 && torrentWebSeedFiles.length === 0;
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 });
const files = torrentWebSeedFiles.map(file => ({ index: file.index }));
const seeds = normalizeTorrentWebSeedDrafts(torrentWebSeedRows, files);
if (!seeds) {
setTorrentWebSeedsError(true);
return;
}
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'));
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(normalized));
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
} catch {
setTorrentWebSeedsError(true);
@@ -711,7 +839,13 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid));
return;
}
if (item.isTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
const torrentPrioritizePiece = serializeTorrentPreviewPriority(
torrentPreviewHeadEnabled,
torrentPreviewHeadSize,
torrentPreviewTailEnabled,
torrentPreviewTailSize
);
if (item.isTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPrioritizePiece) {
setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid));
return;
}
@@ -786,7 +920,7 @@ export const PropertiesModal = () => {
? Number(torrentTrackerInterval)
: undefined,
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
torrentPrioritizePiece: torrentPrioritizePiece || undefined,
torrentFileIndices: torrentFileSelection
? (allTorrentFilesSelected ? undefined : selectedTorrentIndices)
: item.torrentFileIndices,
@@ -1018,6 +1152,20 @@ export const PropertiesModal = () => {
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const torrentUploadedBytes = item.isTorrent
? liveProgress?.uploaded_bytes ?? item.torrentUploadedBytes ?? 0
: 0;
const torrentSeededSeconds = item.isTorrent
? liveProgress?.torrent_seeded_seconds ?? item.torrentSeededSeconds ?? 0
: 0;
const torrentRatioDenominator = item.isTorrent
? torrentFileSelection?.files.length
? torrentFileSelection.files.reduce((total, file) => total + (file.selected ? file.length : 0), 0)
: torrentDetails?.totalBytes ?? item.totalBytes ?? 0
: 0;
const torrentRatio = formatTorrentRatio(torrentUploadedBytes, torrentRatioDenominator, i18n.language);
const torrentSeederCount = liveProgress?.num_seeders ?? torrentPeerDiagnostics?.totalSeeders;
const torrentConnectedPeerCount = torrentPeerDiagnostics?.totalPeers;
const statusLabel = t($ => $.downloads.status[item.status]);
const pauseResumeAction = getPauseResumeAction(item.status);
const pauseResumeLabel = pauseResumeAction === 'pause'
@@ -1040,7 +1188,7 @@ export const PropertiesModal = () => {
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 === 'processing' || item.status === 'moving') { 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; }
@@ -1113,6 +1261,19 @@ export const PropertiesModal = () => {
</div>
)}
</div>
{item.isTorrent && (
<div className="mt-3 rounded-lg border border-border-modal/70 bg-bg-input/20 p-2.5" aria-label={t($ => $.properties.torrentStatistics)}>
<div className="mb-2 text-[11px] font-semibold text-text-primary">{t($ => $.properties.torrentStatistics)}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px] sm:grid-cols-3">
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentUploaded)}</span><span className="truncate text-text-secondary">{formatDownloadBytes(torrentUploadedBytes)}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentRatio)}</span><span className="truncate text-text-secondary">{torrentRatio}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentSeededDuration)}</span><span className="truncate text-text-secondary">{formatTorrentDuration(torrentSeededSeconds, i18n.language)}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentConnectedPeers)}</span><span className="truncate text-text-secondary">{torrentConnectedPeerCount ?? '—'}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentSeeders)}</span><span className="truncate text-text-secondary">{torrentSeederCount ?? '—'}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentUploadSpeed)}</span><span className="truncate text-text-secondary">{liveProgress?.upload_speed ?? '—'}</span></div>
</div>
</div>
)}
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
@@ -1347,6 +1508,54 @@ export const PropertiesModal = () => {
</>
)}
</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.torrentAvailability)}</div>
<button
type="button"
onClick={() => void handleRefreshTorrentAvailability()}
disabled={!isTorrentAvailabilityStatus(item.status) || isTorrentAvailabilityPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentAvailabilityPending
? t($ => $.properties.torrentAvailabilityLoading)
: t($ => $.properties.torrentAvailabilityRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentAvailabilityHint)}</p>
{!isTorrentAvailabilityStatus(item.status) && (
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentAvailabilityUnavailable)}</p>
)}
{torrentAvailabilityError && (
<p className="text-[11px] text-red-400">{t($ => $.properties.torrentAvailabilityFailed)}</p>
)}
{torrentAvailability && (
<>
<div className="text-[11px] text-text-secondary">
{t($ => $.properties.torrentAvailabilitySummary, {
availability: new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 2 }).format(torrentAvailability.availability),
peers: torrentAvailability.connectedPeers,
pieces: torrentAvailability.pieceCount
})}
</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, torrentAvailability.buckets.length))}, minmax(0, 1fr))` }}
role="img"
aria-label={t($ => $.properties.torrentAvailabilityMap)}
>
{torrentAvailability.buckets.map((bucket, index) => (
<span
key={index}
className="aspect-square min-w-1 rounded-sm bg-emerald-500 motion-safe:transition-opacity motion-reduce:transition-none"
style={{ opacity: Math.min(1, 0.2 + bucket.minimumCopies / Math.max(1, torrentAvailability.availability + 1)) }}
title={t($ => $.properties.torrentAvailabilityBucket, { copies: bucket.minimumCopies })}
/>
))}
</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">
@@ -1610,21 +1819,45 @@ export const PropertiesModal = () => {
{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">
<div className="col-span-2 space-y-2">
<span className="block text-xs text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}</span>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input
type="checkbox"
checked={torrentPreviewHeadEnabled}
onChange={event => setTorrentPreviewHeadEnabled(event.target.checked)}
disabled={transferLocked}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceHead)}
<input
type="text"
value={torrentPreviewHeadSize}
onChange={event => setTorrentPreviewHeadSize(event.currentTarget.value)}
disabled={transferLocked || !torrentPreviewHeadEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input
type="checkbox"
checked={torrentPreviewTailEnabled}
onChange={event => setTorrentPreviewTailEnabled(event.target.checked)}
disabled={transferLocked}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceTail)}
<input
type="text"
value={torrentPreviewTailSize}
onChange={event => setTorrentPreviewTailSize(event.currentTarget.value)}
disabled={transferLocked || !torrentPreviewTailEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
@@ -1904,6 +2137,29 @@ export const PropertiesModal = () => {
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentDetails)}
</h3>
<div className="mb-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => void handleCopyTorrentMagnet()} className="app-button px-3 text-xs">
{t($ => $.properties.torrentCopyMagnet)}
</button>
<button type="button" onClick={() => void handleExportTorrentMetadata()} className="app-button px-3 text-xs">
{t($ => $.properties.torrentExportMetadata)}
</button>
{(isTorrentMovePending || ['paused', 'completed', 'failed'].includes(item.status)) && (
<button
type="button"
onClick={() => void (isTorrentMovePending ? handleCancelTorrentMove() : handleMoveTorrentData())}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentMovePending ? t($ => $.properties.torrentMoveCancel) : t($ => $.properties.torrentMove)}
</button>
)}
{isTorrentMovePending && moveProgress !== undefined && (
<span className="text-[11px] text-text-secondary tabular-nums" role="status">
{Math.round(moveProgress * 100)}%
</span>
)}
{torrentShareMessage && <span className="text-[11px] text-text-secondary" role="status">{torrentShareMessage}</span>}
</div>
{isTorrentDetailsPending && (
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
)}
@@ -1972,13 +2228,12 @@ export const PropertiesModal = () => {
{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)}
<TorrentWebSeedEditor
files={torrentWebSeedFiles}
rows={torrentWebSeedRows}
onChange={setTorrentWebSeedRows}
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"
idPrefix="properties-torrent-web-seed"
/>
<div className="flex items-center justify-between mt-2 gap-2">
<span className="text-xs text-red-500">
@@ -1987,7 +2242,7 @@ export const PropertiesModal = () => {
<button
type="button"
onClick={() => void handleTorrentWebSeedsSave()}
disabled={isTorrentWebSeedsPending}
disabled={isTorrentWebSeedsPending || torrentWebSeedsMetadataPending}
className="app-button px-3 text-xs"
>
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
+126
View File
@@ -0,0 +1,126 @@
import type { TorrentFile } from '../bindings/TorrentFile';
import type { TorrentFileSelectionEntry } from '../bindings/TorrentFileSelectionEntry';
import { normalizeTorrentWebSeedDrafts, type TorrentWebSeedDraft } from '../utils/downloads';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
type TorrentWebSeedFile = Pick<TorrentFile, 'index' | 'path'> | Pick<TorrentFileSelectionEntry, 'index' | 'relativePath'>;
type Props = {
files: readonly TorrentWebSeedFile[];
rows: readonly TorrentWebSeedDraft[];
onChange: (rows: TorrentWebSeedDraft[]) => void;
disabled?: boolean;
idPrefix: string;
};
const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.path : file.relativePath;
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
const { t } = useTranslation();
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
const focusAfterRemoveRef = useRef<number | null>(null);
const filesForValidation = files.map(file => ({ index: file.index }));
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
useEffect(() => {
const rowIndex = focusAfterRemoveRef.current;
focusAfterRemoveRef.current = null;
if (rowIndex !== null) uriRefs.current[rowIndex]?.focus();
}, [rows]);
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
rows.map((row, index) => index === rowIndex ? { ...row, ...update } : row)
);
const removeRow = (rowIndex: number) => {
const nextRows = rows.filter((_, index) => index !== rowIndex);
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : null;
onChange(nextRows);
};
return (
<div className="space-y-2">
{rows.length === 0 && (
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentWebSeedsEmpty)}</p>
)}
{rows.map((row, rowIndex) => {
const rowId = `${idPrefix}-${rowIndex}`;
const rowIsValid = normalizeTorrentWebSeedDrafts([row], filesForValidation) !== null;
return (
<div key={rowId} className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] gap-2 items-end">
<div className="min-w-0">
<label htmlFor={`${rowId}-file`} className="block text-[10px] text-text-muted mb-1">
{t($ => $.properties.torrentWebSeedsFile)}
</label>
{files.length === 1 ? (
<select
id={`${rowId}-file`}
value={files[0].index}
onChange={() => undefined}
disabled
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
>
<option value={files[0].index}>{files[0].index + 1}: {filePath(files[0])}</option>
</select>
) : (
<select
id={`${rowId}-file`}
value={row.fileIndex ?? ''}
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
disabled={disabled || files.length === 0}
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
>
<option value="" disabled>{t($ => $.properties.torrentWebSeedsFile)}</option>
{files.map(file => (
<option key={file.index} value={file.index}>
{file.index + 1}: {filePath(file)}
</option>
))}
</select>
)}
</div>
<div className="min-w-0">
<label htmlFor={`${rowId}-uri`} className="block text-[10px] text-text-muted mb-1">
{t($ => $.properties.torrentWebSeedsUri)}
</label>
<input
id={`${rowId}-uri`}
type="url"
ref={element => { uriRefs.current[rowIndex] = element; }}
value={row.uri}
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
disabled={disabled}
aria-invalid={!rowIsValid}
placeholder="https://mirror.example/torrent/"
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={() => removeRow(rowIndex)}
disabled={disabled}
aria-label={t($ => $.properties.torrentWebSeedsRemove)}
className="app-button min-h-[30px] px-2 text-xs disabled:opacity-50"
>
×
</button>
</div>
);
})}
{rows.length > 0 && !rowsAreValid && (
<p className="text-[11px] text-red-500" role="alert">
{t($ => $.properties.torrentWebSeedsInvalid)}
</p>
)}
<button
type="button"
onClick={addRow}
disabled={disabled || files.length === 0}
className="app-button px-3 text-xs disabled:opacity-50"
>
+ {t($ => $.properties.torrentWebSeedsAdd)}
</button>
</div>
);
};