mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-20 16:12:17 +00:00
feat(ui): compact torrent add flow and verify magnet associations
This commit is contained in:
@@ -124,6 +124,7 @@ jobs:
|
|||||||
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
|
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
|
||||||
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
|
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
|
||||||
test -n "$DMG"
|
test -n "$DMG"
|
||||||
|
FIRELINK_MACOS_APP="$APP" node scripts/app-associations.node-test.js
|
||||||
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
|
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
|
||||||
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
|
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
|
||||||
ARIA2="$(find "$APP" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
|
ARIA2="$(find "$APP" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -29,3 +30,41 @@ test('declares the native macOS BitTorrent content type', () => {
|
|||||||
test('declares magnet as a desktop deep-link scheme', () => {
|
test('declares magnet as a desktop deep-link scheme', () => {
|
||||||
assert.deepEqual(tauriConfig.plugins['deep-link'].desktop.schemes, ['firelink', 'magnet']);
|
assert.deepEqual(tauriConfig.plugins['deep-link'].desktop.schemes, ['firelink', 'magnet']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const packagedAppPath = process.env.FIRELINK_MACOS_APP;
|
||||||
|
|
||||||
|
if (packagedAppPath) {
|
||||||
|
test('packaged macOS app exports the Torrent UTI and magnet URL scheme', () => {
|
||||||
|
assert.equal(process.platform, 'darwin', 'packaged macOS association checks require macOS');
|
||||||
|
const infoPlistPath = path.join(packagedAppPath, 'Contents', 'Info.plist');
|
||||||
|
assert.ok(fs.existsSync(infoPlistPath), `missing packaged Info.plist: ${infoPlistPath}`);
|
||||||
|
|
||||||
|
const plist = JSON.parse(execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPlistPath], {
|
||||||
|
encoding: 'utf8'
|
||||||
|
}));
|
||||||
|
const urlTypes = Array.isArray(plist.CFBundleURLTypes) ? plist.CFBundleURLTypes : [];
|
||||||
|
const schemes = urlTypes.flatMap(entry => (
|
||||||
|
entry && typeof entry === 'object' && Array.isArray(entry.CFBundleURLSchemes)
|
||||||
|
? entry.CFBundleURLSchemes.filter(scheme => typeof scheme === 'string')
|
||||||
|
: []
|
||||||
|
));
|
||||||
|
assert.ok(schemes.includes('firelink'), 'packaged app must retain the Firelink deep-link scheme');
|
||||||
|
assert.ok(schemes.includes('magnet'), 'packaged app must export the magnet URL scheme');
|
||||||
|
|
||||||
|
const documentTypes = Array.isArray(plist.CFBundleDocumentTypes) ? plist.CFBundleDocumentTypes : [];
|
||||||
|
const torrentDocument = documentTypes.find(entry =>
|
||||||
|
entry
|
||||||
|
&& typeof entry === 'object'
|
||||||
|
&& Array.isArray(entry.CFBundleTypeExtensions)
|
||||||
|
&& entry.CFBundleTypeExtensions.some(extension =>
|
||||||
|
typeof extension === 'string' && extension.toLowerCase() === 'torrent'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.ok(torrentDocument, 'packaged app must claim the .torrent extension');
|
||||||
|
assert.ok(
|
||||||
|
Array.isArray(torrentDocument.LSItemContentTypes)
|
||||||
|
&& torrentDocument.LSItemContentTypes.includes('org.bittorrent.torrent'),
|
||||||
|
'packaged app must claim the standard BitTorrent UTI'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -974,6 +974,24 @@ export const AddDownloadsModal = () => {
|
|||||||
: root;
|
: root;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const revealTorrentAdvanced = (preferredIndex?: number) => {
|
||||||
|
setAdvancedExpanded(true);
|
||||||
|
setSelectedItemIndex(current => {
|
||||||
|
const preferredIsSelectedTorrent = preferredIndex !== undefined
|
||||||
|
&& parsedItems[preferredIndex]?.selected !== false
|
||||||
|
&& parsedItems[preferredIndex]?.isTorrent;
|
||||||
|
if (preferredIsSelectedTorrent) return preferredIndex;
|
||||||
|
|
||||||
|
const currentIsSelectedTorrent = current !== null
|
||||||
|
&& parsedItems[current]?.selected !== false
|
||||||
|
&& parsedItems[current]?.isTorrent;
|
||||||
|
if (currentIsSelectedTorrent) return current;
|
||||||
|
|
||||||
|
const firstSelectedTorrentIndex = parsedItems.findIndex(item => item.selected !== false && item.isTorrent);
|
||||||
|
return firstSelectedTorrentIndex >= 0 ? firstSelectedTorrentIndex : current;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAction = async (action: AddDownloadAction) => {
|
const handleAction = async (action: AddDownloadAction) => {
|
||||||
if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) {
|
if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) {
|
||||||
return;
|
return;
|
||||||
@@ -1000,41 +1018,50 @@ export const AddDownloadsModal = () => {
|
|||||||
&& torrentMaxPeers.trim()
|
&& torrentMaxPeers.trim()
|
||||||
&& (!Number.isInteger(Number(torrentMaxPeers)) || Number(torrentMaxPeers) < 0 || Number(torrentMaxPeers) > 1000)
|
&& (!Number.isInteger(Number(torrentMaxPeers)) || Number(torrentMaxPeers) < 0 || Number(torrentMaxPeers) > 1000)
|
||||||
) {
|
) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentMaxPeersInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentMaxPeersInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && torrentPeerSpeedLimit.trim() && !normalizeSpeedLimitForBackend(torrentPeerSpeedLimit)) {
|
if (hasSelectedTorrent && torrentPeerSpeedLimit.trim() && !normalizeSpeedLimitForBackend(torrentPeerSpeedLimit)) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && !isValidTorrentTrackerList(torrentTrackers)) {
|
if (hasSelectedTorrent && !isValidTorrentTrackerList(torrentTrackers)) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) {
|
if (hasSelectedTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) {
|
if (hasSelectedTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) {
|
if (hasSelectedTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) {
|
if (hasSelectedTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) {
|
if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const item of selectedItems) {
|
for (const [itemIndex, item] of parsedItems.entries()) {
|
||||||
if (!item.isTorrent || !item.torrentFiles?.length) continue;
|
if (item.selected === false || !item.isTorrent || !item.torrentFiles?.length) continue;
|
||||||
const rows = item.torrentWebSeedRows ?? [];
|
const rows = item.torrentWebSeedRows ?? [];
|
||||||
if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) {
|
if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) {
|
||||||
|
revealTorrentAdvanced(itemIndex);
|
||||||
addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1071,7 @@ export const AddDownloadsModal = () => {
|
|||||||
&& torrentStopTimeout.trim()
|
&& torrentStopTimeout.trim()
|
||||||
&& (!Number.isInteger(Number(torrentStopTimeout)) || Number(torrentStopTimeout) < 0 || Number(torrentStopTimeout) > MAX_TORRENT_STOP_TIMEOUT)
|
&& (!Number.isInteger(Number(torrentStopTimeout)) || Number(torrentStopTimeout) < 0 || Number(torrentStopTimeout) > MAX_TORRENT_STOP_TIMEOUT)
|
||||||
) {
|
) {
|
||||||
|
revealTorrentAdvanced();
|
||||||
addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1711,6 +1739,7 @@ export const AddDownloadsModal = () => {
|
|||||||
return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length);
|
return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length);
|
||||||
};
|
};
|
||||||
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||||
|
const selectedItemIsTorrent = selectedItem?.isTorrent === true;
|
||||||
const hasSftpRows = parsedItems.some(item => item.selected !== false
|
const hasSftpRows = parsedItems.some(item => item.selected !== false
|
||||||
&& !item.isTorrent
|
&& !item.isTorrent
|
||||||
&& item.sourceUrl.trim().toLowerCase().startsWith('sftp:'));
|
&& item.sourceUrl.trim().toLowerCase().startsWith('sftp:'));
|
||||||
@@ -1846,6 +1875,30 @@ export const AddDownloadsModal = () => {
|
|||||||
item => item.metadataBlockedReason === 'unsafe-url'
|
item => item.metadataBlockedReason === 'unsafe-url'
|
||||||
).length;
|
).length;
|
||||||
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount;
|
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount;
|
||||||
|
const readyMetadataCount = selectedItems.filter(item => item.status === 'ready').length;
|
||||||
|
const hasCustomTorrentOptions = Boolean(
|
||||||
|
torrentMaxPeers.trim()
|
||||||
|
|| torrentPeerSpeedLimit.trim()
|
||||||
|
|| torrentCheckIntegrity
|
||||||
|
|| torrentRemoveUnselectedFile
|
||||||
|
|| torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
|
||||||
|
|| torrentTrackers.trim()
|
||||||
|
|| torrentExcludeTrackers.trim()
|
||||||
|
|| torrentTrackerConnectTimeout.trim()
|
||||||
|
|| torrentTrackerTimeout.trim()
|
||||||
|
|| (torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) !== 0)
|
||||||
|
|| (torrentStopTimeout.trim() && Number(torrentStopTimeout) !== 0)
|
||||||
|
|| torrentFileAllocation !== 'prealloc'
|
||||||
|
|| torrentPreviewHeadEnabled
|
||||||
|
|| torrentPreviewTailEnabled
|
||||||
|
|| parsedItems.some(item => item.selected !== false && item.isTorrent && (item.torrentWebSeedRows?.length ?? 0) > 0)
|
||||||
|
);
|
||||||
|
const localizedSelectedSummary = t($ => $.addDownloads.selectedSummary, {
|
||||||
|
ready: readyMetadataCount,
|
||||||
|
fallback: fallbackMetadataCount,
|
||||||
|
mediaRetry: failedMediaMetadataCount,
|
||||||
|
blocked: blockedMetadataCount,
|
||||||
|
});
|
||||||
const activePlaylistUrls = new Set(
|
const activePlaylistUrls = new Set(
|
||||||
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
|
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
|
||||||
);
|
);
|
||||||
@@ -2001,15 +2054,33 @@ export const AddDownloadsModal = () => {
|
|||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex justify-between items-center px-1">
|
<div className="add-download-selection-toolbar px-1">
|
||||||
<span className="text-[11px] text-text-muted font-medium">
|
<div
|
||||||
{t($ => $.addDownloads.selectedSummary, {
|
className="add-download-selection-status"
|
||||||
ready: selectedItems.filter(item => item.status === 'ready').length,
|
role="status"
|
||||||
fallback: fallbackMetadataCount,
|
aria-live="polite"
|
||||||
mediaRetry: failedMediaMetadataCount,
|
aria-atomic="true"
|
||||||
blocked: blockedMetadataCount,
|
aria-label={localizedSelectedSummary}
|
||||||
})}
|
>
|
||||||
</span>
|
<span className="sr-only">{localizedSelectedSummary}</span>
|
||||||
|
<span className="add-download-status-chip" data-tone="ready">
|
||||||
|
<strong>{readyMetadataCount}</strong>
|
||||||
|
<span>{t($ => $.addDownloads.selectedSummaryReady)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="add-download-status-chip" data-tone="fallback">
|
||||||
|
<strong>{fallbackMetadataCount}</strong>
|
||||||
|
<span>{t($ => $.addDownloads.selectedSummaryFallback)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="add-download-status-chip" data-tone="media-retry">
|
||||||
|
<strong>{failedMediaMetadataCount}</strong>
|
||||||
|
<span>{t($ => $.addDownloads.selectedSummaryMediaRetry)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="add-download-status-chip" data-tone="blocked">
|
||||||
|
<strong>{blockedMetadataCount}</strong>
|
||||||
|
<span>{t($ => $.addDownloads.selectedSummaryBlocked)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="add-download-selection-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setParsedItems(refreshFailedMetadataRows)}
|
onClick={() => setParsedItems(refreshFailedMetadataRows)}
|
||||||
@@ -2027,6 +2098,7 @@ export const AddDownloadsModal = () => {
|
|||||||
{allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)}
|
{allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
@@ -2158,24 +2230,6 @@ export const AddDownloadsModal = () => {
|
|||||||
</section>
|
</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 && (
|
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||||
<section className="add-download-section relative overflow-hidden p-4">
|
<section className="add-download-section relative overflow-hidden p-4">
|
||||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||||
@@ -2202,6 +2256,7 @@ export const AddDownloadsModal = () => {
|
|||||||
step={1}
|
step={1}
|
||||||
value={torrentSeedTime}
|
value={torrentSeedTime}
|
||||||
onChange={event => setTorrentSeedTime(event.target.value)}
|
onChange={event => setTorrentSeedTime(event.target.value)}
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-20 px-2 py-1 text-end font-mono"
|
className="app-control w-20 px-2 py-1 text-end font-mono"
|
||||||
/>
|
/>
|
||||||
<span className="text-text-muted">{t($ => $.addDownloads.minutes)}</span>
|
<span className="text-text-muted">{t($ => $.addDownloads.minutes)}</span>
|
||||||
@@ -2214,6 +2269,7 @@ export const AddDownloadsModal = () => {
|
|||||||
step={0.1}
|
step={0.1}
|
||||||
value={torrentSeedRatio}
|
value={torrentSeedRatio}
|
||||||
onChange={event => setTorrentSeedRatio(event.target.value)}
|
onChange={event => setTorrentSeedRatio(event.target.value)}
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-20 px-2 py-1 text-end font-mono"
|
className="app-control w-20 px-2 py-1 text-end font-mono"
|
||||||
aria-describedby="torrent-seed-ratio-hint"
|
aria-describedby="torrent-seed-ratio-hint"
|
||||||
/>
|
/>
|
||||||
@@ -2239,12 +2295,45 @@ export const AddDownloadsModal = () => {
|
|||||||
step={128}
|
step={128}
|
||||||
value={torrentUploadLimit}
|
value={torrentUploadLimit}
|
||||||
onChange={event => setTorrentUploadLimit(event.target.value)}
|
onChange={event => setTorrentUploadLimit(event.target.value)}
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||||
aria-label={t($ => $.addDownloads.torrentUploadLimit)}
|
aria-label={t($ => $.addDownloads.torrentUploadLimit)}
|
||||||
/>
|
/>
|
||||||
<span className="text-text-muted">KiB/s</span>
|
<span className="text-text-muted">KiB/s</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdvancedExpanded(expanded => !expanded)}
|
||||||
|
className="add-download-torrent-advanced-toggle flex w-full items-center justify-between gap-3 border-t border-border-modal/50 pt-3 text-start"
|
||||||
|
aria-expanded={advancedExpanded}
|
||||||
|
aria-controls="add-torrent-advanced-options add-transfer-advanced-options"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
{advancedExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span>{t($ => $.addDownloads.torrentAdvancedOptions)}</span>
|
||||||
|
</span>
|
||||||
|
{!advancedExpanded && hasCustomTorrentOptions && (
|
||||||
|
<span className="shrink-0 text-[10px] font-medium text-blue-400">
|
||||||
|
{t($ => $.addDownloads.torrentAdvancedOptionsCustom)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{advancedExpanded && (
|
||||||
|
<div id="add-torrent-advanced-options" className="add-download-torrent-advanced-fields space-y-4">
|
||||||
|
<div className="border-b border-border-modal/50 pb-3">
|
||||||
|
<div className="add-download-advanced-group-title">{t($ => $.properties.torrentWebSeeds)}</div>
|
||||||
|
<p className="mt-1 mb-3 text-[10px] text-text-muted">{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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
|
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -2366,6 +2455,7 @@ export const AddDownloadsModal = () => {
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={torrentTrackers}
|
value={torrentTrackers}
|
||||||
onChange={event => setTorrentTrackers(event.currentTarget.value)}
|
onChange={event => setTorrentTrackers(event.currentTarget.value)}
|
||||||
|
dir="ltr"
|
||||||
placeholder="https://tracker.example/announce"
|
placeholder="https://tracker.example/announce"
|
||||||
aria-describedby="torrent-trackers-hint"
|
aria-describedby="torrent-trackers-hint"
|
||||||
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
||||||
@@ -2383,6 +2473,7 @@ export const AddDownloadsModal = () => {
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={torrentExcludeTrackers}
|
value={torrentExcludeTrackers}
|
||||||
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
|
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
|
||||||
|
dir="ltr"
|
||||||
placeholder="https://tracker.example/announce or *"
|
placeholder="https://tracker.example/announce or *"
|
||||||
aria-describedby="torrent-exclude-trackers-hint"
|
aria-describedby="torrent-exclude-trackers-hint"
|
||||||
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
||||||
@@ -2405,6 +2496,7 @@ export const AddDownloadsModal = () => {
|
|||||||
value={torrentTrackerConnectTimeout}
|
value={torrentTrackerConnectTimeout}
|
||||||
onChange={event => setTorrentTrackerConnectTimeout(event.currentTarget.value)}
|
onChange={event => setTorrentTrackerConnectTimeout(event.currentTarget.value)}
|
||||||
placeholder="60"
|
placeholder="60"
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||||
aria-describedby="torrent-tracker-timing-hint"
|
aria-describedby="torrent-tracker-timing-hint"
|
||||||
/>
|
/>
|
||||||
@@ -2423,6 +2515,7 @@ export const AddDownloadsModal = () => {
|
|||||||
value={torrentTrackerTimeout}
|
value={torrentTrackerTimeout}
|
||||||
onChange={event => setTorrentTrackerTimeout(event.currentTarget.value)}
|
onChange={event => setTorrentTrackerTimeout(event.currentTarget.value)}
|
||||||
placeholder="60"
|
placeholder="60"
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||||
aria-describedby="torrent-tracker-timing-hint"
|
aria-describedby="torrent-tracker-timing-hint"
|
||||||
/>
|
/>
|
||||||
@@ -2440,6 +2533,7 @@ export const AddDownloadsModal = () => {
|
|||||||
step={1}
|
step={1}
|
||||||
value={torrentTrackerInterval}
|
value={torrentTrackerInterval}
|
||||||
onChange={event => setTorrentTrackerInterval(event.currentTarget.value)}
|
onChange={event => setTorrentTrackerInterval(event.currentTarget.value)}
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||||
aria-describedby="torrent-tracker-timing-hint"
|
aria-describedby="torrent-tracker-timing-hint"
|
||||||
/>
|
/>
|
||||||
@@ -2462,6 +2556,7 @@ export const AddDownloadsModal = () => {
|
|||||||
value={torrentMaxPeers}
|
value={torrentMaxPeers}
|
||||||
onChange={event => setTorrentMaxPeers(event.target.value)}
|
onChange={event => setTorrentMaxPeers(event.target.value)}
|
||||||
placeholder="55"
|
placeholder="55"
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||||
aria-describedby="torrent-peer-options-hint"
|
aria-describedby="torrent-peer-options-hint"
|
||||||
/>
|
/>
|
||||||
@@ -2475,6 +2570,7 @@ export const AddDownloadsModal = () => {
|
|||||||
value={torrentPeerSpeedLimit}
|
value={torrentPeerSpeedLimit}
|
||||||
onChange={event => setTorrentPeerSpeedLimit(event.target.value)}
|
onChange={event => setTorrentPeerSpeedLimit(event.target.value)}
|
||||||
placeholder="50K"
|
placeholder="50K"
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||||
aria-describedby="torrent-peer-options-hint"
|
aria-describedby="torrent-peer-options-hint"
|
||||||
/>
|
/>
|
||||||
@@ -2495,6 +2591,7 @@ export const AddDownloadsModal = () => {
|
|||||||
step={1}
|
step={1}
|
||||||
value={torrentStopTimeout}
|
value={torrentStopTimeout}
|
||||||
onChange={event => setTorrentStopTimeout(event.currentTarget.value)}
|
onChange={event => setTorrentStopTimeout(event.currentTarget.value)}
|
||||||
|
dir="ltr"
|
||||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||||
aria-describedby="torrent-stop-timeout-hint"
|
aria-describedby="torrent-stop-timeout-hint"
|
||||||
/>
|
/>
|
||||||
@@ -2504,6 +2601,8 @@ export const AddDownloadsModal = () => {
|
|||||||
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -2836,18 +2935,26 @@ export const AddDownloadsModal = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Advanced */}
|
{/* Advanced */}
|
||||||
<section className="add-download-section add-download-advanced">
|
{(!selectedItemIsTorrent || advancedExpanded) && (
|
||||||
<button
|
<section className="add-download-section add-download-advanced">
|
||||||
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
{selectedItemIsTorrent ? (
|
||||||
className="add-download-advanced-toggle flex items-center gap-2 text-sm font-semibold text-text-primary w-full"
|
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||||
aria-expanded={advancedExpanded}
|
<Settings size={16} className="text-blue-500" /> {t($ => $.addDownloads.advancedTransfer)}
|
||||||
>
|
</div>
|
||||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
) : (
|
||||||
{t($ => $.addDownloads.advancedTransfer)}
|
<button
|
||||||
</button>
|
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
||||||
|
className="add-download-advanced-toggle flex items-center gap-2 text-sm font-semibold text-text-primary w-full"
|
||||||
|
aria-expanded={advancedExpanded}
|
||||||
|
aria-controls="add-transfer-advanced-options"
|
||||||
|
>
|
||||||
|
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
{t($ => $.addDownloads.advancedTransfer)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{advancedExpanded && (
|
{advancedExpanded && (
|
||||||
<div className="add-download-advanced-fields mt-4 space-y-4">
|
<div id="add-transfer-advanced-options" className="add-download-advanced-fields mt-4 space-y-4">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
||||||
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="add-download-checkbox" />
|
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="add-download-checkbox" />
|
||||||
{t($ => $.addDownloads.verifyChecksum)}
|
{t($ => $.addDownloads.verifyChecksum)}
|
||||||
@@ -2910,9 +3017,10 @@ export const AddDownloadsModal = () => {
|
|||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.mirrors)}</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.mirrors)}</label>
|
||||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none" aria-label={t($ => $.addDownloads.mirrors)} />
|
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none" aria-label={t($ => $.addDownloads.mirrors)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1389,14 +1389,6 @@ export const PropertiesWindowApp = () => {
|
|||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{activeTab === 'options' && isTorrent && <div className="properties-options space-y-5 text-xs">
|
{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>
|
|
||||||
|
|
||||||
<section className="properties-option-group" aria-labelledby="properties-options-limits-heading">
|
<section className="properties-option-group" aria-labelledby="properties-options-limits-heading">
|
||||||
<div className="properties-option-group-heading">
|
<div className="properties-option-group-heading">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -19,13 +19,18 @@ const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.pat
|
|||||||
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
|
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
|
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||||
|
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const focusAfterRemoveRef = useRef<number | null>(null);
|
const focusAfterRemoveRef = useRef<number | null>(null);
|
||||||
const filesForValidation = files.map(file => ({ index: file.index }));
|
const filesForValidation = files.map(file => ({ index: file.index }));
|
||||||
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
|
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rowIndex = focusAfterRemoveRef.current;
|
const rowIndex = focusAfterRemoveRef.current;
|
||||||
focusAfterRemoveRef.current = null;
|
focusAfterRemoveRef.current = null;
|
||||||
if (rowIndex !== null) uriRefs.current[rowIndex]?.focus();
|
if (rowIndex === -1) {
|
||||||
|
addButtonRef.current?.focus();
|
||||||
|
} else if (rowIndex !== null) {
|
||||||
|
uriRefs.current[rowIndex]?.focus();
|
||||||
|
}
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
|
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
|
||||||
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
|
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
|
||||||
@@ -33,7 +38,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
|||||||
);
|
);
|
||||||
const removeRow = (rowIndex: number) => {
|
const removeRow = (rowIndex: number) => {
|
||||||
const nextRows = rows.filter((_, index) => index !== rowIndex);
|
const nextRows = rows.filter((_, index) => index !== rowIndex);
|
||||||
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : null;
|
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : -1;
|
||||||
onChange(nextRows);
|
onChange(nextRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +62,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
|||||||
value={files[0].index}
|
value={files[0].index}
|
||||||
onChange={() => undefined}
|
onChange={() => undefined}
|
||||||
disabled
|
disabled
|
||||||
|
dir="ltr"
|
||||||
aria-invalid={!rowIsValid}
|
aria-invalid={!rowIsValid}
|
||||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
|
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
|
||||||
>
|
>
|
||||||
@@ -68,6 +74,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
|||||||
value={row.fileIndex ?? ''}
|
value={row.fileIndex ?? ''}
|
||||||
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
|
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
|
||||||
disabled={disabled || files.length === 0}
|
disabled={disabled || files.length === 0}
|
||||||
|
dir="ltr"
|
||||||
aria-invalid={!rowIsValid}
|
aria-invalid={!rowIsValid}
|
||||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
|
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -91,6 +98,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
|||||||
value={row.uri}
|
value={row.uri}
|
||||||
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
|
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
dir="ltr"
|
||||||
aria-invalid={!rowIsValid}
|
aria-invalid={!rowIsValid}
|
||||||
placeholder="https://mirror.example/torrent/"
|
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"
|
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||||
@@ -115,6 +123,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={addButtonRef}
|
||||||
onClick={addRow}
|
onClick={addRow}
|
||||||
disabled={disabled || files.length === 0}
|
disabled={disabled || files.length === 0}
|
||||||
className="app-button px-3 text-xs disabled:opacity-50"
|
className="app-button px-3 text-xs disabled:opacity-50"
|
||||||
|
|||||||
@@ -656,6 +656,12 @@ const common = {
|
|||||||
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (safe entry limit reached)',
|
safeEntryLimit: ' (safe entry limit reached)',
|
||||||
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
||||||
|
selectedSummaryReady: 'Ready',
|
||||||
|
selectedSummaryFallback: 'Fallback',
|
||||||
|
selectedSummaryMediaRetry: 'Media retry',
|
||||||
|
selectedSummaryBlocked: 'Blocked',
|
||||||
|
torrentAdvancedOptions: 'Advanced Torrent options',
|
||||||
|
torrentAdvancedOptionsCustom: 'Custom settings',
|
||||||
clearSelection: 'Clear selection',
|
clearSelection: 'Clear selection',
|
||||||
selectAll: 'Select all',
|
selectAll: 'Select all',
|
||||||
refreshMetadata: 'Refresh Metadata',
|
refreshMetadata: 'Refresh Metadata',
|
||||||
|
|||||||
@@ -656,6 +656,12 @@ const fa = {
|
|||||||
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
||||||
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
||||||
|
selectedSummaryReady: 'آماده',
|
||||||
|
selectedSummaryFallback: 'جایگزین',
|
||||||
|
selectedSummaryMediaRetry: 'تلاش مجدد رسانه',
|
||||||
|
selectedSummaryBlocked: 'مسدود',
|
||||||
|
torrentAdvancedOptions: 'گزینههای پیشرفتهٔ تورنت',
|
||||||
|
torrentAdvancedOptionsCustom: 'تنظیمات سفارشی',
|
||||||
clearSelection: 'پاک کردن انتخاب',
|
clearSelection: 'پاک کردن انتخاب',
|
||||||
selectAll: 'انتخاب همه',
|
selectAll: 'انتخاب همه',
|
||||||
refreshMetadata: 'تازهسازی متادیتا',
|
refreshMetadata: 'تازهسازی متادیتا',
|
||||||
|
|||||||
@@ -656,6 +656,12 @@ const he = {
|
|||||||
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
|
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
||||||
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
||||||
|
selectedSummaryReady: 'מוכנים',
|
||||||
|
selectedSummaryFallback: 'גיבוי',
|
||||||
|
selectedSummaryMediaRetry: 'ניסיון חוזר במדיה',
|
||||||
|
selectedSummaryBlocked: 'חסומים',
|
||||||
|
torrentAdvancedOptions: 'אפשרויות Torrent מתקדמות',
|
||||||
|
torrentAdvancedOptionsCustom: 'הגדרות מותאמות',
|
||||||
clearSelection: 'ניקוי בחירה',
|
clearSelection: 'ניקוי בחירה',
|
||||||
selectAll: 'בחירת הכל',
|
selectAll: 'בחירת הכל',
|
||||||
refreshMetadata: 'רענון מטא נתונים',
|
refreshMetadata: 'רענון מטא נתונים',
|
||||||
|
|||||||
@@ -656,6 +656,12 @@ const ru = {
|
|||||||
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
|
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
||||||
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
||||||
|
selectedSummaryReady: 'Готово',
|
||||||
|
selectedSummaryFallback: 'Резерв',
|
||||||
|
selectedSummaryMediaRetry: 'Повтор медиа',
|
||||||
|
selectedSummaryBlocked: 'Заблокировано',
|
||||||
|
torrentAdvancedOptions: 'Расширенные параметры Torrent',
|
||||||
|
torrentAdvancedOptionsCustom: 'Пользовательские настройки',
|
||||||
clearSelection: 'Очистить выбор',
|
clearSelection: 'Очистить выбор',
|
||||||
selectAll: 'Выбрать все',
|
selectAll: 'Выбрать все',
|
||||||
refreshMetadata: 'Обновить метаданные',
|
refreshMetadata: 'Обновить метаданные',
|
||||||
|
|||||||
@@ -656,6 +656,12 @@ const uk = {
|
|||||||
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
|
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
||||||
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
||||||
|
selectedSummaryReady: 'Готові',
|
||||||
|
selectedSummaryFallback: 'Резервні',
|
||||||
|
selectedSummaryMediaRetry: 'Повтор медіа',
|
||||||
|
selectedSummaryBlocked: 'Заблоковано',
|
||||||
|
torrentAdvancedOptions: 'Розширені параметри Torrent',
|
||||||
|
torrentAdvancedOptionsCustom: 'Власні налаштування',
|
||||||
clearSelection: 'Очистити вибір',
|
clearSelection: 'Очистити вибір',
|
||||||
selectAll: 'Вибрати всі',
|
selectAll: 'Вибрати всі',
|
||||||
refreshMetadata: 'Оновити метадані',
|
refreshMetadata: 'Оновити метадані',
|
||||||
|
|||||||
@@ -656,6 +656,12 @@ const zhCN = {
|
|||||||
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
|
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (达到安全条目限制)',
|
safeEntryLimit: ' (达到安全条目限制)',
|
||||||
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
||||||
|
selectedSummaryReady: '就绪',
|
||||||
|
selectedSummaryFallback: '后备',
|
||||||
|
selectedSummaryMediaRetry: '媒体重试',
|
||||||
|
selectedSummaryBlocked: '已屏蔽',
|
||||||
|
torrentAdvancedOptions: 'Torrent 高级选项',
|
||||||
|
torrentAdvancedOptionsCustom: '自定义设置',
|
||||||
clearSelection: '清除选择',
|
clearSelection: '清除选择',
|
||||||
selectAll: '全选',
|
selectAll: '全选',
|
||||||
refreshMetadata: '刷新元数据',
|
refreshMetadata: '刷新元数据',
|
||||||
|
|||||||
+96
-29
@@ -1059,23 +1059,12 @@ html[data-list-density="relaxed"] {
|
|||||||
max-width: 940px;
|
max-width: 940px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-options-intro,
|
|
||||||
.properties-option-group {
|
.properties-option-group {
|
||||||
border: 1px solid var(--properties-card-border);
|
border: 1px solid var(--properties-card-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: hsl(var(--surface-raised) / 0.22);
|
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 {
|
.properties-option-group-heading h2 {
|
||||||
color: hsl(var(--text-primary));
|
color: hsl(var(--text-primary));
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1083,24 +1072,6 @@ html[data-list-density="relaxed"] {
|
|||||||
letter-spacing: 0.01em;
|
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 {
|
.properties-option-group {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
@@ -1642,6 +1613,102 @@ html[data-list-density="relaxed"] {
|
|||||||
background: hsl(var(--accent-color) / 0.1);
|
background: hsl(var(--accent-color) / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-download-selection-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-selection-status {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid hsl(var(--border-modal));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--surface-raised) / 0.46);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-status-chip strong {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-status-chip[data-tone="ready"] strong {
|
||||||
|
color: hsl(214 92% 64%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-status-chip[data-tone="fallback"] strong {
|
||||||
|
color: hsl(38 92% 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-status-chip[data-tone="media-retry"] strong {
|
||||||
|
color: hsl(272 78% 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-status-chip[data-tone="blocked"] strong {
|
||||||
|
color: hsl(0 78% 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-selection-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-torrent-advanced-toggle {
|
||||||
|
color: hsl(var(--text-secondary));
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-torrent-advanced-toggle:hover {
|
||||||
|
color: hsl(var(--accent-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-torrent-advanced-toggle:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--accent-color) / 0.65);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-torrent-advanced-fields {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-advanced-group-title {
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.add-download-selection-toolbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-selection-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.add-download-summary-card {
|
.add-download-summary-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px 11px;
|
padding: 10px 11px;
|
||||||
|
|||||||
Reference in New Issue
Block a user