fix(ui): clarify download properties inputs

This commit is contained in:
NimBold
2026-08-06 23:58:06 +03:30
parent c9046f8273
commit e2654510af
12 changed files with 679 additions and 51 deletions
+253 -44
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
@@ -16,9 +16,9 @@ import {
PROPERTIES_WINDOW_SNAPSHOT,
attachAsyncPropertiesListener,
DEFAULT_PROPERTIES_WINDOW_CHROME,
encodePropertiesPatchValue,
formatPropertiesQueuePlacement,
getPropertiesLifecycleAction,
propertiesTorrentPeerLimit,
propertiesDiagnosticRequestState,
sendPropertiesActionRequest,
sendPropertiesReady,
@@ -73,6 +73,68 @@ const safeTitle = (name: string) => {
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
const PropertiesHelp = ({ text }: { text: string }) => (
<button type="button" className="properties-help" aria-label={text}>
<Info size={13} strokeWidth={2} aria-hidden="true" />
<span className="properties-help-tooltip" aria-hidden="true">{text}</span>
</button>
);
const PropertiesField = ({
label,
controlId,
hint,
meta,
format,
children,
className = '',
}: {
label: ReactNode;
controlId: string;
hint?: string;
meta?: ReactNode;
format?: ReactNode;
children: ReactNode;
className?: string;
}) => (
<div className={`properties-field ${className}`}>
<div className="properties-field-label">
<label className="properties-field-label-text" htmlFor={controlId}>
<span className="min-w-0">{label}</span>
</label>
{hint && <PropertiesHelp text={hint} />}
{meta && <span className="properties-field-meta">{meta}</span>}
</div>
{children}
{format && <span className="properties-field-format">{format}</span>}
</div>
);
const PropertiesOptionToggle = ({
label,
hint,
checked,
disabled,
onChange,
}: {
label: ReactNode;
hint: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) => {
const controlId = useId();
return (
<div className="properties-option-toggle">
<input id={controlId} type="checkbox" checked={checked} onChange={event => onChange(event.target.checked)} disabled={disabled} />
<span className="properties-option-toggle-copy">
<label className="properties-option-toggle-label" htmlFor={controlId}>{label}</label>
</span>
<PropertiesHelp text={hint} />
</div>
);
};
const propertiesStatusTone = (status: string) => {
if (status === 'paused') return 'paused';
if (status === 'seeding') return 'seeding';
@@ -156,8 +218,8 @@ export const PropertiesWindowApp = () => {
const [removeUnselectedFile, setRemoveUnselectedFile] = useState(false);
const [stopTimeout, setStopTimeout] = useState('');
const [prioritizePiece, setPrioritizePiece] = useState('');
const [encryptionPolicy, setEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [fileAllocation, setFileAllocation] = useState<TorrentFileAllocation>('prealloc');
const [encryptionPolicy, setEncryptionPolicy] = useState<TorrentEncryptionPolicy | ''>('');
const [fileAllocation, setFileAllocation] = useState<TorrentFileAllocation | ''>('');
const [trackerConnectTimeout, setTrackerConnectTimeout] = useState('');
const [trackerTimeout, setTrackerTimeout] = useState('');
const [trackerInterval, setTrackerInterval] = useState('');
@@ -335,8 +397,8 @@ export const PropertiesWindowApp = () => {
setRemoveUnselectedFile(next.torrentRemoveUnselectedFile === true);
setStopTimeout(next.torrentStopTimeout === undefined ? '' : String(next.torrentStopTimeout));
setPrioritizePiece(next.torrentPrioritizePiece ?? '');
setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED);
setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc');
setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? '');
setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? '');
setTrackerConnectTimeout(next.torrentTrackerConnectTimeout === undefined ? '' : String(next.torrentTrackerConnectTimeout));
setTrackerTimeout(next.torrentTrackerTimeout === undefined ? '' : String(next.torrentTrackerTimeout));
setTrackerInterval(next.torrentTrackerInterval === undefined ? '' : String(next.torrentTrackerInterval));
@@ -773,7 +835,9 @@ export const PropertiesWindowApp = () => {
const patch: PropertiesPatch = {};
if (activeTab === 'overview') {
if (fileName !== snapshot.fileName) patch.fileName = fileName;
if (destination !== (snapshot.destination ?? '')) patch.destination = destination || undefined;
const nextDestination = destination.trim() ? destination : undefined;
const currentDestination = snapshot.destination?.trim() ? snapshot.destination : undefined;
if (nextDestination !== currentDestination) patch.destination = encodePropertiesPatchValue(nextDestination);
if (!isTorrent && connections.trim() && Number(connections) !== snapshot.connections) {
patch.connections = Number(connections);
}
@@ -796,19 +860,23 @@ export const PropertiesWindowApp = () => {
await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles });
return;
} else if (activeTab === 'trackers') {
if (trackers !== (snapshot.torrentTrackers ?? '')) patch.torrentTrackers = trackers;
if (excludedTrackers !== (snapshot.torrentExcludeTrackers ?? '')) patch.torrentExcludeTrackers = excludedTrackers;
const nextTrackers = trackers.trim();
const currentTrackers = (snapshot.torrentTrackers ?? '').trim();
if (nextTrackers !== currentTrackers) patch.torrentTrackers = encodePropertiesPatchValue(nextTrackers || undefined);
const nextExcludedTrackers = excludedTrackers.trim();
const currentExcludedTrackers = (snapshot.torrentExcludeTrackers ?? '').trim();
if (nextExcludedTrackers !== currentExcludedTrackers) patch.torrentExcludeTrackers = encodePropertiesPatchValue(nextExcludedTrackers || undefined);
if (trackerConnectTimeout !== String(snapshot.torrentTrackerConnectTimeout ?? '')) {
patch.torrentTrackerConnectTimeout = trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined;
patch.torrentTrackerConnectTimeout = encodePropertiesPatchValue(trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined);
}
if (trackerTimeout !== String(snapshot.torrentTrackerTimeout ?? '')) {
patch.torrentTrackerTimeout = trackerTimeout.trim() ? Number(trackerTimeout) : undefined;
patch.torrentTrackerTimeout = encodePropertiesPatchValue(trackerTimeout.trim() ? Number(trackerTimeout) : undefined);
}
if (trackerInterval !== String(snapshot.torrentTrackerInterval ?? '')) {
patch.torrentTrackerInterval = trackerInterval.trim() ? Number(trackerInterval) : undefined;
patch.torrentTrackerInterval = encodePropertiesPatchValue(trackerInterval.trim() ? Number(trackerInterval) : undefined);
}
} else if (activeTab === 'options' || activeTab === 'transfer') {
if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = downloadLimit;
if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = encodePropertiesPatchValue(downloadLimit.trim() ? downloadLimit : undefined);
if (activeTab === 'transfer' && !isTorrent && connections.trim()) {
const nextConnections = Number(connections);
if (nextConnections !== snapshot.connections) patch.connections = nextConnections;
@@ -820,28 +888,27 @@ export const PropertiesWindowApp = () => {
switchAfterSaveRef.current = null;
return;
}
if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = uploadLimit;
if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = encodePropertiesPatchValue(uploadLimit.trim() ? uploadLimit : undefined);
if (maxPeers !== String(snapshot.torrentMaxPeers ?? '')) {
patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined;
patch.torrentMaxPeers = encodePropertiesPatchValue(maxPeers.trim() ? Number(maxPeers) : undefined);
}
if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = peerSpeedLimit;
if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = encodePropertiesPatchValue(peerSpeedLimit.trim() ? peerSpeedLimit : undefined);
if (seedTime !== String(snapshot.torrentSeedTime ?? '')) {
patch.torrentSeedTime = seedTime.trim() ? Number(seedTime) : undefined;
patch.torrentSeedTime = encodePropertiesPatchValue(seedTime.trim() ? Number(seedTime) : undefined);
}
if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) {
patch.torrentSeedRatio = seedRatio.trim() ? Number(seedRatio) : undefined;
patch.torrentSeedRatio = encodePropertiesPatchValue(seedRatio.trim() ? Number(seedRatio) : undefined);
}
if (checkIntegrity !== (snapshot.torrentCheckIntegrity === true)) patch.torrentCheckIntegrity = checkIntegrity;
if (removeUnselectedFile !== (snapshot.torrentRemoveUnselectedFile === true)) patch.torrentRemoveUnselectedFile = removeUnselectedFile;
if (stopTimeout !== String(snapshot.torrentStopTimeout ?? '')) {
patch.torrentStopTimeout = stopTimeout.trim() ? Number(stopTimeout) : undefined;
patch.torrentStopTimeout = encodePropertiesPatchValue(stopTimeout.trim() ? Number(stopTimeout) : undefined);
}
if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = prioritizePiece.trim() || undefined;
const snapshotEncryptionPolicy = (snapshot.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED;
if (encryptionPolicy !== snapshotEncryptionPolicy) {
patch.torrentEncryptionPolicy = encryptionPolicy === TORRENT_ENCRYPTION_POLICY_DISABLED ? undefined : encryptionPolicy;
}
if (fileAllocation !== ((snapshot.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc')) patch.torrentFileAllocation = fileAllocation;
if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = encodePropertiesPatchValue(prioritizePiece.trim() || undefined);
const nextEncryptionPolicy = encryptionPolicy || undefined;
if (nextEncryptionPolicy !== snapshot.torrentEncryptionPolicy) patch.torrentEncryptionPolicy = encodePropertiesPatchValue(nextEncryptionPolicy);
const nextFileAllocation = fileAllocation || undefined;
if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation);
}
} else if (activeTab === 'advanced') {
for (const name of SECRET_NAMES) {
@@ -994,7 +1061,7 @@ export const PropertiesWindowApp = () => {
<div className="properties-window-titlebar" data-tauri-drag-region>
<span data-tauri-drag-region>{snapshot.fileName} - {t($ => $.downloadTable.properties)} - Firelink</span>
</div>
<header className="properties-window-header shrink-0 border-b border-border-modal bg-sidebar-bg px-5 py-4">
<header className="properties-window-header shrink-0 border-b border-border-modal px-5 py-4">
<div className="properties-window-hero-top">
<div className="properties-window-title-block min-w-0">
<div className="properties-window-title-line">
@@ -1201,31 +1268,173 @@ export const PropertiesWindowApp = () => {
{activeTab === 'transfer' && <div className="space-y-4">
<div className="grid max-w-2xl gap-4 sm:grid-cols-2">
<label className="text-xs text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
<PropertiesField
label={t($ => $.properties.speedCap)}
controlId="properties-transfer-speed-cap"
hint={t($ => $.properties.speedLimitHint)}
meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
>
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
</PropertiesField>
<div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.isMedia === true ? snapshot.connections ?? '—' : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}</p></div>
</div>
<label className="block max-w-2xl text-xs text-text-muted">{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}<div className="mt-2 flex items-center gap-3"><input type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={t($ => $.properties.connections)} /><span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span></div></label>
<p className="text-xs text-text-muted">{t($ => $.properties.transferSettings)}</p>
</div>}
{activeTab === 'options' && isTorrent && <div className="space-y-5 text-xs">
<div className="grid max-w-3xl gap-4 sm:grid-cols-2">
<label className="text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
<label className="text-text-muted">{t($ => $.properties.liveTorrentUploadLimit)}<input className="app-control mt-1 w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
<label className="text-text-muted">{t($ => $.properties.torrentMaxPeers)}<input className="app-control mt-1 w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={String(propertiesTorrentPeerLimit(undefined))} disabled={!editingEnabled} /></label>
<label className="text-text-muted">{t($ => $.properties.torrentPeerSpeedLimit)}<input className="app-control mt-1 w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder="50K" disabled={!editingEnabled} /></label>
<label className="text-text-muted">{t($ => $.addDownloads.seedTime)}<input className="app-control mt-1 w-full" value={seedTime} onChange={event => { setSeedTime(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.defaultValue)} disabled={!editingEnabled} /></label>
<label className="text-text-muted">{t($ => $.addDownloads.seedRatio)}<input className="app-control mt-1 w-full" value={seedRatio} onChange={event => { setSeedRatio(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder="0" disabled={!editingEnabled} /></label>
<label className="text-text-muted">{t($ => $.properties.torrentStopTimeout)}<input className="app-control mt-1 w-full" value={stopTimeout} onChange={event => { setStopTimeout(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder="0" disabled={!editingEnabled} /></label>
<label className="text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}<input className="app-control mt-1 w-full" value={prioritizePiece} onChange={event => { setPrioritizePiece(event.target.value); setDraftTab('options'); }} placeholder="head=1M,tail=1M" disabled={!editingEnabled} /></label>
{activeTab === 'options' && isTorrent && <div className="properties-options space-y-5 text-xs">
<div className="properties-options-intro">
<div className="min-w-0">
<p className="properties-section-eyebrow">{t($ => $.properties.tabs.options)}</p>
<p className="mt-1 text-text-muted">{t($ => $.properties.torrentPeerOptionsSavedHint)}</p>
</div>
<span className="properties-default-legend"><span className="properties-default-legend-dot" />{t($ => $.properties.blankUsesDefault)}</span>
</div>
<div className="grid max-w-3xl gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2"><input type="checkbox" checked={checkIntegrity} onChange={event => { setCheckIntegrity(event.target.checked); setDraftTab('options'); }} disabled={!editingEnabled} />{t($ => $.properties.torrentVerifyIntegrity)}</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={removeUnselectedFile} onChange={event => { setRemoveUnselectedFile(event.target.checked); setDraftTab('options'); }} disabled={!editingEnabled || (!removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0))} />{t($ => $.properties.torrentRemoveUnselectedFile)}</label>
<label className="flex items-center gap-2 text-text-muted">{t($ => $.properties.torrentFileAllocation)}<select className="app-control" value={fileAllocation} onChange={event => { setFileAllocation(event.target.value as TorrentFileAllocation); setDraftTab('options'); }} disabled={!editingEnabled}><option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option><option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option></select></label>
<label className="flex items-center gap-2 text-text-muted">{t($ => $.properties.torrentEncryptionPolicy)}<select className="app-control" value={encryptionPolicy} onChange={event => { setEncryptionPolicy(event.target.value as TorrentEncryptionPolicy); setDraftTab('options'); }} disabled={!editingEnabled}><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></label>
</div>
<p className="max-w-3xl text-text-muted">{t($ => $.properties.torrentPeerOptionsSavedHint)}</p>
<section className="properties-option-group" aria-labelledby="properties-options-limits-heading">
<div className="properties-option-group-heading">
<div>
<h2 id="properties-options-limits-heading">{t($ => $.properties.liveTorrentPeerOptions)}</h2>
<p>{t($ => $.properties.speedLimitHint)}</p>
</div>
</div>
<div className="grid max-w-4xl gap-4 sm:grid-cols-2">
<PropertiesField
label={t($ => $.properties.speedCap)}
controlId="properties-options-speed-cap"
hint={t($ => $.properties.speedLimitHint)}
meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
>
<input id="properties-options-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
</PropertiesField>
<PropertiesField
label={t($ => $.properties.liveTorrentUploadLimit)}
controlId="properties-options-upload-limit"
hint={t($ => $.properties.liveTorrentUploadLimitHint)}
meta={uploadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
>
<input id="properties-options-upload-limit" className="app-control w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
</PropertiesField>
<PropertiesField
label={t($ => $.properties.torrentMaxPeers)}
controlId="properties-options-max-peers"
hint={t($ => $.properties.torrentPeerOptionsSavedHint)}
meta={maxPeers.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatMaxPeers) })}
>
<input id="properties-options-max-peers" className="app-control w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleMaxPeers)} disabled={!editingEnabled} />
</PropertiesField>
<PropertiesField
label={t($ => $.properties.torrentPeerSpeedLimit)}
controlId="properties-options-peer-speed-limit"
hint={t($ => $.properties.torrentPeerOptionsSavedHint)}
meta={peerSpeedLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
>
<input id="properties-options-peer-speed-limit" className="app-control w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
</PropertiesField>
</div>
</section>
<section className="properties-option-group" aria-labelledby="properties-options-seeding-heading">
<div className="properties-option-group-heading">
<div>
<h2 id="properties-options-seeding-heading">{t($ => $.addDownloads.torrentSeeding)}</h2>
<p>{t($ => $.addDownloads.seedRatioHint)}</p>
</div>
</div>
<div className="grid max-w-4xl gap-4 sm:grid-cols-2">
<PropertiesField
label={t($ => $.addDownloads.seedTime)}
controlId="properties-options-seed-time"
hint={t($ => $.properties.torrentSeedTimeHint)}
meta={seedTime.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSeedTime) })}
>
<input id="properties-options-seed-time" className="app-control w-full" value={seedTime} onChange={event => { setSeedTime(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.inputExampleSeedTime)} disabled={!editingEnabled} />
</PropertiesField>
<PropertiesField
label={t($ => $.addDownloads.seedRatio)}
controlId="properties-options-seed-ratio"
hint={t($ => $.addDownloads.seedRatioHint)}
meta={seedRatio.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSeedRatio) })}
>
<input id="properties-options-seed-ratio" className="app-control w-full" value={seedRatio} onChange={event => { setSeedRatio(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.inputExampleSeedRatio)} disabled={!editingEnabled} />
</PropertiesField>
<PropertiesField
label={t($ => $.properties.torrentStopTimeout)}
controlId="properties-options-stop-timeout"
hint={t($ => $.properties.torrentStopTimeoutHint)}
meta={stopTimeout.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatStopTimeout) })}
>
<input id="properties-options-stop-timeout" className="app-control w-full" value={stopTimeout} onChange={event => { setStopTimeout(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleStopTimeout)} disabled={!editingEnabled} />
</PropertiesField>
<PropertiesField
label={t($ => $.properties.torrentPrioritizePiece)}
controlId="properties-options-prioritize-piece"
hint={t($ => $.properties.torrentPrioritizePieceHint)}
meta={prioritizePiece.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatPiecePriority) })}
>
<input id="properties-options-prioritize-piece" className="app-control w-full" value={prioritizePiece} onChange={event => { setPrioritizePiece(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExamplePiecePriority)} disabled={!editingEnabled} />
</PropertiesField>
</div>
</section>
<section className="properties-option-group" aria-labelledby="properties-options-behavior-heading">
<div className="properties-option-group-heading">
<div>
<h2 id="properties-options-behavior-heading">{t($ => $.properties.torrentOptionsBehavior)}</h2>
<p>{t($ => $.properties.torrentOptionsBehaviorHint)}</p>
</div>
</div>
<div className="grid max-w-4xl gap-3 sm:grid-cols-2">
<PropertiesOptionToggle
label={t($ => $.properties.torrentVerifyIntegrity)}
hint={t($ => $.properties.torrentVerifyIntegrityHint)}
checked={checkIntegrity}
onChange={checked => { setCheckIntegrity(checked); setDraftTab('options'); }}
disabled={!editingEnabled}
/>
<PropertiesOptionToggle
label={t($ => $.properties.torrentRemoveUnselectedFile)}
hint={t($ => $.properties.torrentRemoveUnselectedFileHint)}
checked={removeUnselectedFile}
onChange={checked => { setRemoveUnselectedFile(checked); setDraftTab('options'); }}
disabled={!editingEnabled || (!removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0))}
/>
<PropertiesField
label={t($ => $.properties.torrentFileAllocation)}
controlId="properties-options-file-allocation"
hint={t($ => $.properties.torrentFileAllocationHint)}
meta={fileAllocation === '' ? t($ => $.properties.usingDefault) : t($ => $.properties.customPerDownload)}
>
<select id="properties-options-file-allocation" className="app-control w-full" value={fileAllocation} onChange={event => { setFileAllocation(event.target.value as TorrentFileAllocation | ''); setDraftTab('options'); }} disabled={!editingEnabled}>
<option value="">{t($ => $.properties.usingDefault)}</option>
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
</select>
</PropertiesField>
<PropertiesField
label={t($ => $.properties.torrentEncryptionPolicy)}
controlId="properties-options-encryption-policy"
hint={t($ => $.properties.torrentEncryptionPolicyHint)}
meta={encryptionPolicy === '' ? t($ => $.properties.usingDefault) : t($ => $.properties.customPerDownload)}
>
<select id="properties-options-encryption-policy" className="app-control w-full" value={encryptionPolicy} onChange={event => { setEncryptionPolicy(event.target.value as TorrentEncryptionPolicy | ''); setDraftTab('options'); }} disabled={!editingEnabled}>
<option value="">{t($ => $.properties.usingDefault)}</option>
<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>
</PropertiesField>
</div>
</section>
</div>}
{activeTab === 'advanced' && <div className="space-y-4">
@@ -25,9 +25,11 @@ import {
beginExclusivePropertiesAction,
classifyPropertiesActionRequest,
createFrameCoalescer,
decodePropertiesPatchValue,
enqueuePropertiesAction,
getPropertiesLifecycleAction,
propertiesActionRequestKey,
PROPERTIES_PATCH_CLEARABLE_KEYS,
sanitizePropertiesSnapshot,
sendPropertiesActionResult,
sendPropertiesRemoved,
@@ -86,6 +88,13 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
'torrentFileAllocation',
] as const) copy(key);
for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) {
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
const value = (rawPatch as Record<string, unknown>)[key];
(safePatch as Record<string, unknown>)[key] = decodePropertiesPatchValue(value);
}
}
if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') {
throw new Error('Invalid file name');
}
+20
View File
@@ -247,6 +247,20 @@ const common = {
connectionCountUnknown: '—/{{total}}',
connectionsUnavailable: '—',
speedCap: 'Speed cap',
inputFormat: 'Format: {{format}}',
inputFormatSpeedLimit: '512K, 2M, or 1G',
inputFormatMaxPeers: '01000; 0 means unlimited',
inputFormatSeedTime: 'minutes, e.g. 60',
inputFormatSeedRatio: 'decimal, e.g. 1.5; 0 means time-only',
inputFormatStopTimeout: 'whole seconds; 0 disables it',
inputFormatPiecePriority: 'head=1M,tail=1M',
inputExampleSpeedLimit: 'e.g. 512K',
inputExampleMaxPeers: 'e.g. 55',
inputExampleSeedTime: 'e.g. 60',
inputExampleSeedRatio: 'e.g. 1.5',
inputExampleStopTimeout: 'e.g. 300',
inputExamplePiecePriority: 'e.g. head=1M,tail=1M',
speedLimitHint: 'Leave blank to use the global default; enter a value to set a cap for this download.',
liveSpeedLimit: 'Live speed cap',
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
liveSpeedLimitPlaceholder: 'e.g. 1024K',
@@ -336,6 +350,7 @@ const common = {
torrentUploaded: 'Uploaded',
torrentRatio: 'Ratio',
torrentSeededDuration: 'Seeded',
torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.',
torrentConnectedPeers: 'Peers',
torrentSeeders: 'Seeders',
torrentUploadSpeed: 'Upload speed',
@@ -354,6 +369,8 @@ const common = {
torrentFileAllocationPrealloc: 'Preallocate files',
torrentFileAllocationNone: 'Allocate as needed',
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
torrentOptionsBehavior: 'Torrent behavior',
torrentOptionsBehaviorHint: 'Controls verification, storage allocation, encryption, and cleanup for this Torrent.',
torrentDetails: 'Torrent details',
torrentCopyMagnet: 'Copy magnet link',
torrentExportMetadata: 'Export .torrent',
@@ -412,6 +429,9 @@ const common = {
defaultValue: ' (default)',
savedTooltip: 'Saved for this download; Settings changes apply to new downloads.',
defaultTooltip: 'Using the current default for new downloads.',
blankUsesDefault: 'Leave blank · use default',
usingDefault: 'Using default',
customPerDownload: 'Custom for this download',
identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.',
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
download: 'Download',
+20
View File
@@ -247,6 +247,20 @@ const fa = {
connectionCountUnknown: '—/{{total}} فعال',
connectionsUnavailable: '—',
speedCap: 'سقف سرعت',
inputFormat: 'قالب: {{format}}',
inputFormatSpeedLimit: '512K، 2M یا 1G',
inputFormatMaxPeers: '0 تا 1000؛ 0 یعنی نامحدود',
inputFormatSeedTime: 'دقیقه، مثلاً 60',
inputFormatSeedRatio: 'عدد اعشاری، مثلاً 1.5؛ 0 یعنی فقط بر اساس زمان',
inputFormatStopTimeout: 'تعداد ثانیهٔ کامل؛ 0 غیرفعال می‌کند',
inputFormatPiecePriority: 'head=1M,tail=1M',
inputExampleSpeedLimit: 'مثلاً 512K',
inputExampleMaxPeers: 'مثلاً 55',
inputExampleSeedTime: 'مثلاً 60',
inputExampleSeedRatio: 'مثلاً 1.5',
inputExampleStopTimeout: 'مثلاً 300',
inputExamplePiecePriority: 'مثلاً head=1M,tail=1M',
speedLimitHint: 'برای استفاده از مقدار سراسری خالی بگذارید؛ برای این دانلود یک سقف مشخص وارد کنید.',
liveSpeedLimit: 'سقف سرعت زنده',
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال می‌شود. سرعت دانلودهای رسانه‌ای هنگام اجرا قابل تغییر نیست.',
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
@@ -336,6 +350,7 @@ const fa = {
torrentUploaded: 'آپلودشده',
torrentRatio: 'نسبت',
torrentSeededDuration: 'مدت سید',
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه می‌دهد. برای استفاده از پیش‌فرض خالی بگذارید.',
torrentConnectedPeers: 'همتاها',
torrentSeeders: 'سیدها',
torrentUploadSpeed: 'سرعت آپلود',
@@ -354,6 +369,8 @@ const fa = {
torrentFileAllocationPrealloc: 'تخصیص از پیش',
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
torrentFileAllocationHint: 'تخصیص از پیش فضای فایل‌های انتخاب‌شده را قبل از انتقال رزرو می‌کند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمی‌دهد.',
torrentOptionsBehavior: 'رفتار تورنت',
torrentOptionsBehaviorHint: 'بررسی صحت، تخصیص فضا، رمزنگاری و پاک‌سازی این تورنت را کنترل می‌کند.',
torrentDetails: 'جزئیات تورنت',
torrentCopyMagnet: 'کپی پیوند مگنت',
torrentExportMetadata: 'خروجی .torrent',
@@ -412,6 +429,9 @@ const fa = {
defaultValue: ' (پیش‌فرض)',
savedTooltip: 'برای این دانلود ذخیره‌شده است؛ تغییرات تنظیمات روی دانلودهای جدید اعمال می‌شود.',
defaultTooltip: 'استفاده از پیش‌فرض کنونی برای دانلودهای جدید.',
blankUsesDefault: 'خالی = استفاده از پیش‌فرض',
usingDefault: 'استفاده از پیش‌فرض',
customPerDownload: 'سفارشی برای این دانلود',
identityReadOnly: 'هویت فایل فقط‌خواندنی است. تنظیمات انتقال برای دانلود مجدد ذخیره می‌شوند.',
transferSettings: 'تنظیمات انتقال را می‌توان پس از توقف تغییر داد. انتقال‌های کنونی گزینه‌های فعلی خود را حفظ می‌کنند.',
download: 'دانلود',
+20
View File
@@ -247,6 +247,20 @@ const he = {
connectionCountUnknown: '—/{{total}} פעילות',
connectionsUnavailable: '—',
speedCap: 'הגבלת מהירות',
inputFormat: 'תבנית: {{format}}',
inputFormatSpeedLimit: '512K, 2M או 1G',
inputFormatMaxPeers: '01000; ‏0 פירושו ללא הגבלה',
inputFormatSeedTime: 'בדקות, למשל 60',
inputFormatSeedRatio: 'מספר עשרוני, למשל 1.5; ‏0 פירושו לפי זמן בלבד',
inputFormatStopTimeout: 'שניות שלמות; ‏0 משבית',
inputFormatPiecePriority: 'head=1M,tail=1M',
inputExampleSpeedLimit: 'לדוגמה 512K',
inputExampleMaxPeers: 'לדוגמה 55',
inputExampleSeedTime: 'לדוגמה 60',
inputExampleSeedRatio: 'לדוגמה 1.5',
inputExampleStopTimeout: 'לדוגמה 300',
inputExamplePiecePriority: 'לדוגמה head=1M,tail=1M',
speedLimitHint: 'השאר ריק כדי להשתמש בערך הגלובלי, או הזן הגבלה עבור הורדה זו.',
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
@@ -336,6 +350,7 @@ const he = {
torrentUploaded: 'הועלה',
torrentRatio: 'יחס',
torrentSeededDuration: 'משך שיתוף',
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
torrentConnectedPeers: 'עמיתים',
torrentSeeders: 'משתפים',
torrentUploadSpeed: 'מהירות העלאה',
@@ -354,6 +369,8 @@ const he = {
torrentFileAllocationPrealloc: 'הקצאה מראש',
torrentFileAllocationNone: 'הקצאה לפי הצורך',
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
torrentOptionsBehavior: 'התנהגות Torrent',
torrentOptionsBehaviorHint: 'שולט באימות, הקצאת האחסון, ההצפנה והניקוי של Torrent זה.',
torrentDetails: 'פרטי טורנט',
torrentCopyMagnet: 'העתקת קישור מגנט',
torrentExportMetadata: 'ייצוא .torrent',
@@ -412,6 +429,9 @@ const he = {
defaultValue: ' (ברירת מחדל)',
savedTooltip: 'נשמר עבור הורדה זו; שינויים בהגדרות יחולו על הורדות חדשות.',
defaultTooltip: 'שימוש בברירת המחדל הנוכחית להורדות חדשות.',
blankUsesDefault: 'ריק · שימוש בברירת המחדל',
usingDefault: 'ברירת מחדל',
customPerDownload: 'מותאם להורדה זו',
identityReadOnly: 'זהות הקובץ היא לקריאה בלבד. הגדרות ההעברה נשמרות להורדה מחדש.',
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
download: 'הורדה',
+20
View File
@@ -247,6 +247,20 @@ const ru = {
connectionCountUnknown: '—/{{total}} активных',
connectionsUnavailable: '—',
speedCap: 'Ограничение скорости',
inputFormat: 'Формат: {{format}}',
inputFormatSpeedLimit: '512K, 2M или 1G',
inputFormatMaxPeers: '01000; 0 — без ограничений',
inputFormatSeedTime: 'минуты, например 60',
inputFormatSeedRatio: 'десятичное число, например 1.5; 0 — только по времени',
inputFormatStopTimeout: 'целые секунды; 0 отключает',
inputFormatPiecePriority: 'head=1M,tail=1M',
inputExampleSpeedLimit: 'например, 512K',
inputExampleMaxPeers: 'например, 55',
inputExampleSeedTime: 'например, 60',
inputExampleSeedRatio: 'например, 1.5',
inputExampleStopTimeout: 'например, 300',
inputExamplePiecePriority: 'например, head=1M,tail=1M',
speedLimitHint: 'Оставьте пустым для глобального значения по умолчанию или задайте ограничение для этой загрузки.',
liveSpeedLimit: 'Текущее ограничение скорости',
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
liveSpeedLimitPlaceholder: 'например, 1024K',
@@ -336,6 +350,7 @@ const ru = {
torrentUploaded: 'Отдано',
torrentRatio: 'Коэффициент',
torrentSeededDuration: 'Время раздачи',
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
torrentConnectedPeers: 'Пиры',
torrentSeeders: 'Сиды',
torrentUploadSpeed: 'Скорость отдачи',
@@ -354,6 +369,8 @@ const ru = {
torrentFileAllocationPrealloc: 'Предварительное выделение',
torrentFileAllocationNone: 'Выделять по мере необходимости',
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
torrentOptionsBehavior: 'Поведение Torrent',
torrentOptionsBehaviorHint: 'Управляет проверкой, выделением места, шифрованием и очисткой этого Torrent.',
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
torrentDetails: 'Сведения о Torrent',
torrentCopyMagnet: 'Копировать magnet-ссылку',
@@ -412,6 +429,9 @@ const ru = {
defaultValue: ' (по умолчанию)',
savedTooltip: 'Сохранено для этой загрузки. Изменения в Настройках будут применяться к новым загрузкам.',
defaultTooltip: 'Используется текущее значение по умолчанию для новых загрузок.',
blankUsesDefault: 'Пусто · значение по умолчанию',
usingDefault: 'По умолчанию',
customPerDownload: 'Для этой загрузки',
identityReadOnly: 'Идентификация файла доступна только для чтения. Настройки передачи сохранены для повторного скачивания.',
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
download: 'Загрузка',
+20
View File
@@ -247,6 +247,20 @@ const uk = {
connectionCountUnknown: '—/{{total}} активних',
connectionsUnavailable: '—',
speedCap: 'Обмеження швидкості',
inputFormat: 'Формат: {{format}}',
inputFormatSpeedLimit: '512K, 2M або 1G',
inputFormatMaxPeers: '01000; 0 означає без обмежень',
inputFormatSeedTime: 'хвилини, наприклад 60',
inputFormatSeedRatio: 'десяткове число, наприклад 1.5; 0 — лише за часом',
inputFormatStopTimeout: 'цілі секунди; 0 вимикає',
inputFormatPiecePriority: 'head=1M,tail=1M',
inputExampleSpeedLimit: 'наприклад, 512K',
inputExampleMaxPeers: 'наприклад, 55',
inputExampleSeedTime: 'наприклад, 60',
inputExampleSeedRatio: 'наприклад, 1.5',
inputExampleStopTimeout: 'наприклад, 300',
inputExamplePiecePriority: 'наприклад, head=1M,tail=1M',
speedLimitHint: 'Залиште порожнім для глобального значення за замовчуванням або задайте обмеження для цього завантаження.',
liveSpeedLimit: 'Поточне обмеження швидкості',
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
@@ -336,6 +350,7 @@ const uk = {
torrentUploaded: 'Віддано',
torrentRatio: 'Коефіцієнт',
torrentSeededDuration: 'Час роздачі',
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
torrentConnectedPeers: 'Піри',
torrentSeeders: 'Сіди',
torrentUploadSpeed: 'Швидкість віддачі',
@@ -354,6 +369,8 @@ const uk = {
torrentFileAllocationPrealloc: 'Попереднє виділення',
torrentFileAllocationNone: 'Виділяти за потреби',
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
torrentOptionsBehavior: 'Поведінка Torrent',
torrentOptionsBehaviorHint: 'Керує перевіркою, виділенням місця, шифруванням і очищенням цього Torrent.',
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
torrentDetails: 'Відомості про Torrent',
torrentCopyMagnet: 'Копіювати magnet-посилання',
@@ -412,6 +429,9 @@ const uk = {
defaultValue: ' (за замовчуванням)',
savedTooltip: 'Збережено для цього завантаження; зміни в налаштуваннях застосовуються до нових завантажень.',
defaultTooltip: 'Використовується поточне значення за замовчуванням для нових завантажень.',
blankUsesDefault: 'Порожньо · значення за замовчуванням',
usingDefault: 'За замовчуванням',
customPerDownload: 'Для цього завантаження',
identityReadOnly: 'Ідентифікатор файлу доступний лише для читання. Налаштування передачі збережено для повторного завантаження.',
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
download: 'Завантаження',
+20
View File
@@ -247,6 +247,20 @@ const zhCN = {
connectionCountUnknown: '—/{{total}} 个连接',
connectionsUnavailable: '—',
speedCap: '速度上限',
inputFormat: '格式:{{format}}',
inputFormatSpeedLimit: '512K、2M 或 1G',
inputFormatMaxPeers: '010000 表示不限',
inputFormatSeedTime: '分钟,例如 60',
inputFormatSeedRatio: '小数,例如 1.50 表示仅按时间',
inputFormatStopTimeout: '整数秒;0 表示禁用',
inputFormatPiecePriority: 'head=1M,tail=1M',
inputExampleSpeedLimit: '例如 512K',
inputExampleMaxPeers: '例如 55',
inputExampleSeedTime: '例如 60',
inputExampleSeedRatio: '例如 1.5',
inputExampleStopTimeout: '例如 300',
inputExamplePiecePriority: '例如 head=1M,tail=1M',
speedLimitHint: '留空以使用全局默认值;输入数值可为此下载设置上限。',
liveSpeedLimit: '实时速度上限',
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
liveSpeedLimitPlaceholder: '例如 1024K',
@@ -336,6 +350,7 @@ const zhCN = {
torrentUploaded: '已上传',
torrentRatio: '分享率',
torrentSeededDuration: '做种时长',
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
torrentConnectedPeers: '连接数',
torrentSeeders: '种子数',
torrentUploadSpeed: '上传速度',
@@ -354,6 +369,8 @@ const zhCN = {
torrentFileAllocationPrealloc: '预分配文件',
torrentFileAllocationNone: '按需分配',
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
torrentOptionsBehavior: 'Torrent 行为',
torrentOptionsBehaviorHint: '控制此 Torrent 的校验、存储分配、加密和清理。',
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
torrentDetails: 'Torrent 详细信息',
torrentCopyMagnet: '复制磁力链接',
@@ -412,6 +429,9 @@ const zhCN = {
defaultValue: ' (默认)',
savedTooltip: '已为此下载保存;设置中的更改将应用于新的下载。',
defaultTooltip: '对新的下载使用当前默认值。',
blankUsesDefault: '留空 · 使用默认值',
usingDefault: '使用默认值',
customPerDownload: '此下载的自定义值',
identityReadOnly: '文件标识为只读。传输设置会保存以备重新下载使用。',
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
download: '下载',
+1
View File
@@ -112,6 +112,7 @@ describe('translation catalogs', () => {
'settings.network.firefoxMacos',
'settings.network.safariMacos',
'settings.network.torrentExternalIpPlaceholder',
'properties.inputFormatPiecePriority',
]);
const unexpectedDuplicates = duplicates
+247 -6
View File
@@ -572,8 +572,8 @@ html[data-list-density="relaxed"] {
}
.properties-window-shell {
--properties-header-surface: hsl(var(--surface-raised));
--properties-card-surface: hsl(var(--bg-input) / 0.42);
--properties-header-surface: hsl(var(--main-bg));
--properties-card-surface: hsl(var(--surface-raised) / 0.38);
position: relative;
min-height: 100%;
overflow: hidden;
@@ -612,8 +612,10 @@ html[data-list-density="relaxed"] {
}
.properties-window-header {
background: var(--properties-header-surface);
box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025);
background:
radial-gradient(circle at 100% 0%, hsl(var(--accent-color) / 0.055), transparent 38%),
var(--properties-header-surface);
box-shadow: inset 0 -1px 0 hsl(var(--text-primary) / 0.025);
}
.properties-window-hero-top {
@@ -793,7 +795,8 @@ html[data-list-density="relaxed"] {
padding: 8px 10px;
border: 1px solid hsl(var(--border-modal) / 0.72);
border-radius: 8px;
background: hsl(var(--bg-input) / 0.28);
background: var(--properties-card-surface);
box-shadow: inset 0 1px 0 hsl(var(--text-primary) / 0.025);
}
.properties-metric-card > svg {
@@ -856,7 +859,7 @@ html[data-list-density="relaxed"] {
gap: 10px;
padding: 7px 16px;
border-bottom: 1px solid hsl(var(--border-modal));
background: hsl(var(--surface-raised));
background: hsl(var(--surface-raised) / 0.42);
}
.properties-window-tab-navigation--overflow .properties-window-tabs {
@@ -968,6 +971,209 @@ html[data-list-density="relaxed"] {
border-radius: 8px;
}
.properties-options {
max-width: 940px;
}
.properties-options-intro,
.properties-option-group {
border: 1px solid hsl(var(--border-modal) / 0.82);
border-radius: 12px;
background: hsl(var(--surface-raised) / 0.22);
}
.properties-options-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 14px 16px;
box-shadow: inset 0 1px 0 hsl(var(--text-primary) / 0.025);
}
.properties-section-eyebrow,
.properties-option-group-heading h2 {
color: hsl(var(--text-primary));
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
}
.properties-default-legend {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
color: hsl(var(--text-muted));
font-size: 11px;
white-space: nowrap;
}
.properties-default-legend-dot {
width: 7px;
height: 7px;
border: 1px solid hsl(var(--accent-color) / 0.72);
border-radius: 50%;
background: hsl(var(--accent-color) / 0.18);
}
.properties-option-group {
padding: 16px;
}
.properties-option-group-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding-bottom: 11px;
border-bottom: 1px solid hsl(var(--border-modal) / 0.64);
}
.properties-option-group-heading p {
max-width: 680px;
margin-top: 4px;
color: hsl(var(--text-muted));
font-size: 11px;
line-height: 1.45;
}
.properties-field {
min-width: 0;
}
.properties-field-label {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
color: hsl(var(--text-muted));
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.properties-field-meta {
overflow: hidden;
margin-inline-start: auto;
color: hsl(var(--accent-color) / 0.88);
font-size: 10px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.properties-help {
position: relative;
display: inline-flex;
flex: 0 0 auto;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 0;
padding: 0;
background: transparent;
color: hsl(var(--text-muted));
cursor: help;
font: inherit;
outline: none;
}
.properties-help:hover,
.properties-help:focus-visible {
color: hsl(var(--accent-color));
background: hsl(var(--accent-color) / 0.1);
}
.properties-help:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.72);
outline-offset: 2px;
}
.properties-help-tooltip {
position: absolute;
z-index: 30;
inset-inline-start: 0;
top: calc(100% + 7px);
width: min(260px, 56vw);
padding: 8px 10px;
border: 1px solid hsl(var(--border-modal));
border-radius: 8px;
background: hsl(var(--surface-overlay) / 0.98);
box-shadow: 0 10px 26px hsl(var(--shadow-color));
color: hsl(var(--text-primary));
font-size: 11px;
font-weight: 500;
line-height: 1.45;
opacity: 0;
pointer-events: none;
transform: translateY(-2px);
transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
visibility: hidden;
}
.properties-help:hover .properties-help-tooltip,
.properties-help:focus-visible .properties-help-tooltip {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
.properties-options .properties-option-group:last-child .properties-help-tooltip {
top: auto;
bottom: calc(100% + 7px);
transform: translateY(2px);
}
.properties-options .properties-option-group:last-child .properties-help:hover .properties-help-tooltip,
.properties-options .properties-option-group:last-child .properties-help:focus-visible .properties-help-tooltip {
transform: translateY(0);
}
.properties-option-toggle {
display: flex !important;
min-width: 0;
min-height: 56px;
align-items: flex-start !important;
flex-direction: row !important;
gap: 10px !important;
padding: 10px 11px;
border: 1px solid hsl(var(--border-modal) / 0.82);
border-radius: 9px;
background: hsl(var(--bg-input) / 0.2);
}
.properties-option-toggle:has(input:not(:disabled)):hover {
border-color: hsl(var(--accent-color) / 0.42);
background: hsl(var(--item-hover));
}
.properties-option-toggle input {
margin-top: 2px !important;
}
.properties-option-toggle-copy {
display: grid;
min-width: 0;
gap: 3px;
flex: 1 1 auto;
}
.properties-option-toggle-label {
color: hsl(var(--text-primary));
font-size: 12px;
line-height: 1.35;
}
.properties-option-toggle-hint {
color: hsl(var(--text-muted));
font-size: 10px;
line-height: 1.4;
}
.properties-window-panel textarea.app-control {
min-height: 104px;
resize: vertical;
@@ -997,6 +1203,41 @@ html[data-list-density="relaxed"] {
margin-top: 2px;
}
.properties-window-panel .properties-field-label {
display: flex;
min-height: 20px;
align-items: center;
flex-direction: row;
gap: 6px;
line-height: 1.35;
}
.properties-field-label-text {
display: inline-flex !important;
min-width: 0;
align-items: center;
color: inherit;
font: inherit;
line-height: inherit;
}
.properties-field-format {
display: block;
margin-top: 5px;
color: hsl(var(--text-muted) / 0.88);
font-size: 10px;
line-height: 1.35;
}
.properties-option-toggle-label {
display: block !important;
margin: 0;
color: hsl(var(--text-primary));
font-size: 12px;
font-weight: 500;
line-height: 1.35;
}
.properties-window-panel > div > p,
.properties-window-panel .text-text-muted {
line-height: 1.45;
+13
View File
@@ -17,6 +17,8 @@ import {
beginExclusivePropertiesAction,
classifyPropertiesActionRequest,
createFrameCoalescer,
decodePropertiesPatchValue,
encodePropertiesPatchValue,
enqueuePropertiesAction,
formatPropertiesQueuePlacement,
getPropertiesLifecycleAction,
@@ -33,6 +35,17 @@ import {
} from './propertiesBridge';
describe('Properties window bridge', () => {
it('keeps optional override resets explicit across the JSON IPC boundary', () => {
const encoded = encodePropertiesPatchValue<string>(undefined);
expect(encoded).toBeNull();
expect(JSON.parse(JSON.stringify({ torrentEncryptionPolicy: encoded }))).toEqual({
torrentEncryptionPolicy: null,
});
expect(decodePropertiesPatchValue(encoded)).toBeUndefined();
expect(decodePropertiesPatchValue('prealloc')).toBe('prealloc');
});
it('uses a WebviewWindow target for directed child events', () => {
expect(propertiesWindowEventTarget('properties-1')).toEqual({
kind: 'WebviewWindow',
+36 -1
View File
@@ -193,13 +193,48 @@ export type SecretPatch =
| { kind: 'replace'; value: string }
| { kind: 'clear' };
export type PropertiesPatch = Partial<Omit<DownloadItem, 'password' | 'cookies' | 'headers' | 'username'>> & {
export const PROPERTIES_PATCH_CLEARABLE_KEYS = [
'destination',
'speedLimit',
'torrentTrackers',
'torrentExcludeTrackers',
'torrentSeedTime',
'torrentSeedRatio',
'torrentUploadLimit',
'torrentMaxPeers',
'torrentPeerSpeedLimit',
'torrentTrackerConnectTimeout',
'torrentTrackerTimeout',
'torrentTrackerInterval',
'torrentStopTimeout',
'torrentPrioritizePiece',
'torrentEncryptionPolicy',
'torrentFileAllocation',
] as const;
type PropertiesPatchClearableKey = typeof PROPERTIES_PATCH_CLEARABLE_KEYS[number];
type PropertiesPatchClearableValues = {
[Key in PropertiesPatchClearableKey]?: DownloadItem[Key] | null;
};
export type PropertiesPatch = Partial<Omit<DownloadItem,
'password' | 'cookies' | 'headers' | 'username' | PropertiesPatchClearableKey
>> & PropertiesPatchClearableValues & {
username?: SecretPatch;
password?: SecretPatch;
cookies?: SecretPatch;
headers?: SecretPatch;
};
// Tauri command arguments cross a JSON boundary. `undefined` object members
// may be omitted before they reach Rust, so nullable fields are the explicit
// wire-level sentinel for clearing an optional per-download override.
export const encodePropertiesPatchValue = <T>(value: T | undefined): T | null => value ?? null;
export const decodePropertiesPatchValue = <T>(value: T | null | undefined): T | undefined =>
value === null ? undefined : value;
export type PropertiesAction =
| 'apply-properties'
| 'set-torrent-file-selection'