mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 01:44:01 +00:00
feat(ui): modernize desktop interactions
This commit is contained in:
+72
-7
@@ -16,6 +16,9 @@ import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
import DiagnosticsView from "./components/DiagnosticsView";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
|
||||
function App() {
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
@@ -29,6 +32,9 @@ function App() {
|
||||
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
|
||||
const activeView = useSettingsStore(state => state.activeView);
|
||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
||||
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
|
||||
const showNotifications = useSettingsStore(state => state.showNotifications);
|
||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
@@ -90,12 +96,20 @@ function App() {
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const token = useSettingsStore.getState().extensionPairingToken;
|
||||
if (token) {
|
||||
void navigator.clipboard.writeText(token);
|
||||
try {
|
||||
if (token) {
|
||||
await navigator.clipboard.writeText(token);
|
||||
}
|
||||
acknowledgePairingTokenChange();
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not copy pairing token: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
acknowledgePairingTokenChange();
|
||||
}}
|
||||
>
|
||||
Copy token
|
||||
@@ -127,6 +141,50 @@ function App() {
|
||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||
}, [appFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-list-density', listRowDensity);
|
||||
}, [listRowDensity]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkForUpdate = () => {
|
||||
if (!useSettingsStore.getState().autoCheckUpdates || automaticUpdateCheckStarted) return;
|
||||
automaticUpdateCheckStarted = true;
|
||||
|
||||
invoke('check_for_updates')
|
||||
.then(result => {
|
||||
if (result.type !== 'UpdateAvailable') return;
|
||||
addToast({
|
||||
variant: 'info',
|
||||
isActionable: true,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Firelink {result.update.version} is available.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={() => {
|
||||
void openUrl(result.update.release_url);
|
||||
}}
|
||||
>
|
||||
View release
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
automaticUpdateCheckStarted = false;
|
||||
console.error('Automatic update check failed:', error);
|
||||
});
|
||||
};
|
||||
|
||||
if (useSettingsStore.persist.hasHydrated()) {
|
||||
checkForUpdate();
|
||||
return;
|
||||
}
|
||||
return useSettingsStore.persist.onFinishHydration(checkForUpdate);
|
||||
}, [addToast, autoCheckUpdates]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_concurrent_limit', { limit: maxConcurrentDownloads }).catch(console.error);
|
||||
}, [maxConcurrentDownloads]);
|
||||
@@ -191,15 +249,22 @@ function App() {
|
||||
}, [downloads, schedulerRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
// Request notification permissions
|
||||
const initNotifications = async () => {
|
||||
if (!useSettingsStore.getState().showNotifications) return;
|
||||
let permissionGranted = await isPermissionGranted();
|
||||
if (!permissionGranted) {
|
||||
await requestPermission();
|
||||
}
|
||||
};
|
||||
initNotifications();
|
||||
}, []);
|
||||
|
||||
if (useSettingsStore.persist.hasHydrated()) {
|
||||
void initNotifications();
|
||||
return;
|
||||
}
|
||||
return useSettingsStore.persist.onFinishHydration(() => {
|
||||
void initNotifications();
|
||||
});
|
||||
}, [showNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
|
||||
+14
-5
@@ -29,11 +29,20 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', color: 'red', backgroundColor: '#222', height: '100vh', width: '100vw', whiteSpace: 'pre-wrap', overflow: 'auto' }}>
|
||||
<h1>Something went wrong.</h1>
|
||||
<p>{this.state.error?.toString()}</p>
|
||||
<hr />
|
||||
<p>{this.state.errorInfo?.componentStack}</p>
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-main-bg p-8 text-text-primary">
|
||||
<div className="app-card max-w-lg space-y-4 p-6 text-center">
|
||||
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
The error was written to Diagnostics. Reload the interface to reconnect to the running download service.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button app-button-primary px-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Firelink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type EnqueueItem = { id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
} from '../utils/downloadLocations';
|
||||
import { isTransferLocked } from '../utils/downloadActions';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface MediaFormat {
|
||||
name: string;
|
||||
@@ -47,6 +49,7 @@ const formatBytes = (bytes: number) => {
|
||||
};
|
||||
|
||||
export const AddDownloadsModal = () => {
|
||||
const { addToast } = useToast();
|
||||
const {
|
||||
isAddModalOpen,
|
||||
pendingAddUrls,
|
||||
@@ -58,7 +61,7 @@ export const AddDownloadsModal = () => {
|
||||
addDownload,
|
||||
queues
|
||||
} = useDownloadStore();
|
||||
const { baseDownloadFolder } = useSettingsStore();
|
||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
||||
|
||||
const [urls, setUrls] = useState('');
|
||||
const [metadataRefreshNonce, setMetadataRefreshNonce] = useState(0);
|
||||
@@ -77,7 +80,7 @@ export const AddDownloadsModal = () => {
|
||||
// Right Form
|
||||
const [saveLocation, setSaveLocation] = useState(baseDownloadFolder);
|
||||
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
|
||||
const [connections, setConnections] = useState(16);
|
||||
const [connections, setConnections] = useState(perServerConnections);
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimit, setSpeedLimit] = useState('1024');
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
@@ -102,6 +105,7 @@ export const AddDownloadsModal = () => {
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setPendingUseSharedDestination(false);
|
||||
setConnections(perServerConnections);
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
@@ -126,7 +130,8 @@ export const AddDownloadsModal = () => {
|
||||
pendingAddReferer,
|
||||
pendingAddHeaders,
|
||||
pendingAddCookies,
|
||||
baseDownloadFolder
|
||||
baseDownloadFolder,
|
||||
perServerConnections
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -141,6 +146,21 @@ export const AddDownloadsModal = () => {
|
||||
return () => window.removeEventListener('pointerdown', closeMenu);
|
||||
}, [isActionMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActionMenuOpen && !showingDuplicates) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (showingDuplicates) {
|
||||
setShowingDuplicates(false);
|
||||
} else {
|
||||
setIsActionMenuOpen(false);
|
||||
setIsQueueMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [isActionMenuOpen, showingDuplicates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveLocation) return;
|
||||
invoke('get_free_space', { path: saveLocation })
|
||||
@@ -445,9 +465,12 @@ export const AddDownloadsModal = () => {
|
||||
exists = storeHas || diskHas;
|
||||
count++;
|
||||
}
|
||||
if (exists) {
|
||||
throw new Error(`Could not find an available name for ${finalFile}.`);
|
||||
}
|
||||
|
||||
itemsToAdd[idx] = { ...item, file: newName };
|
||||
} else if (res.resolution === 'replace') {
|
||||
} else if (res.resolution === 'replace') {
|
||||
if (conflict?.reason.type !== 'file') {
|
||||
itemsToAdd[idx] = null;
|
||||
continue;
|
||||
@@ -479,16 +502,21 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
await store.removeDownload(existingItem.id);
|
||||
if (existingItem && isTransferLocked(existingItem.status)) {
|
||||
throw new Error(`Pause ${existingItem.fileName} before replacing it.`);
|
||||
}
|
||||
|
||||
await invoke('delete_file', { path: fullPath });
|
||||
if (existingItem) {
|
||||
await store.removeDownload(existingItem.id);
|
||||
}
|
||||
|
||||
try { await invoke('delete_file', { path: fullPath }); } catch(e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
|
||||
let addedCount = 0;
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const item of resolvedItems) {
|
||||
try {
|
||||
@@ -527,11 +555,25 @@ export const AddDownloadsModal = () => {
|
||||
mediaFormatSelector: formatSelector,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
|
||||
}, action);
|
||||
addedCount += 1;
|
||||
} catch (e) {
|
||||
console.error("Invalid URL or failed to add:", e);
|
||||
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
toggleAddModal(false);
|
||||
if (failures.length > 0) {
|
||||
addToast({
|
||||
message: `${addedCount} added, ${failures.length} failed. ${failures[0]}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
} else if (addedCount > 0) {
|
||||
addToast({
|
||||
message: `${addedCount} download${addedCount === 1 ? '' : 's'} added`,
|
||||
variant: 'success'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const SummaryBox = ({ title, value, icon: Icon, color }: {
|
||||
@@ -578,7 +620,14 @@ export const AddDownloadsModal = () => {
|
||||
conflicts={conflicts}
|
||||
onConfirm={(resolutions) => {
|
||||
setShowingDuplicates(false);
|
||||
executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions);
|
||||
void executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions)
|
||||
.catch(error => {
|
||||
addToast({
|
||||
message: `Could not resolve duplicate downloads: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowingDuplicates(false)}
|
||||
/>
|
||||
|
||||
@@ -13,33 +13,36 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
closeDeleteModal();
|
||||
};
|
||||
|
||||
const handleRemoveFromList = async () => {
|
||||
if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) {
|
||||
setIsRemoving(true);
|
||||
const removeMany = async (deleteFile: boolean) => {
|
||||
const ids = deleteModalState.downloadIds ?? [];
|
||||
if (ids.length === 0) {
|
||||
closeDeleteModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRemoving(true);
|
||||
setErrorMessage('');
|
||||
let succeeded = 0;
|
||||
const failures: string[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, false)));
|
||||
await removeDownload(id, deleteFile);
|
||||
succeeded += 1;
|
||||
} catch (error) {
|
||||
setErrorMessage(`Remove failed: ${String(error)}`);
|
||||
setIsRemoving(false);
|
||||
return;
|
||||
failures.push(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
setErrorMessage(`${succeeded} removed, ${failures.length} failed: ${failures[0]}`);
|
||||
setIsRemoving(false);
|
||||
return;
|
||||
}
|
||||
closeDeleteModal();
|
||||
};
|
||||
|
||||
const handleDeleteFile = async () => {
|
||||
if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, true)));
|
||||
} catch (error) {
|
||||
setErrorMessage(`Delete failed: ${String(error)}`);
|
||||
setIsRemoving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
closeDeleteModal();
|
||||
};
|
||||
const handleRemoveFromList = () => removeMany(false);
|
||||
const handleDeleteFile = () => removeMany(true);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invokeCommand as invoke } from '../ipc';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { FileDown, Trash2, Terminal, Filter } from 'lucide-react';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface LogEntry {
|
||||
level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
|
||||
@@ -22,6 +23,7 @@ const getLevelStr = (level: number): LogEntry['level'] => {
|
||||
};
|
||||
|
||||
export default function DiagnosticsView() {
|
||||
const { addToast } = useToast();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -59,8 +61,10 @@ export default function DiagnosticsView() {
|
||||
});
|
||||
if (!path) return;
|
||||
await invoke('export_logs', { destPath: path });
|
||||
addToast({ message: 'Diagnostics exported', variant: 'success' });
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
addToast({ message: `Could not export diagnostics: ${String(e)}`, variant: 'error', isActionable: true });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
||||
|
||||
interface DownloadItemProps {
|
||||
downloadId: string;
|
||||
@@ -200,13 +201,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(download.status === 'downloading' || download.status === 'processing' || download.status === 'retrying') && (
|
||||
{canPauseDownload(download.status) && (
|
||||
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
|
||||
<Pause size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{(download.status === 'ready' || download.status === 'paused') && (
|
||||
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'ready' ? 'Start' : 'Resume'}>
|
||||
{canStartDownload(download.status) && (
|
||||
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={startActionLabel(download.status)}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -10,13 +10,22 @@ import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
} from '../utils/downloadLocations';
|
||||
import {
|
||||
canPauseDownload,
|
||||
canRedownload,
|
||||
canStartDownload,
|
||||
startActionLabel
|
||||
} from '../utils/downloadActions';
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
|
||||
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { downloads, queues, updateDownload, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { downloads, queues, assignToQueue, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -25,7 +34,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
try {
|
||||
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
|
||||
return Array.isArray(stored) &&
|
||||
stored.length === DEFAULT_COLUMN_WIDTHS.length &&
|
||||
stored.every(value => typeof value === 'number' && Number.isFinite(value))
|
||||
? stored
|
||||
: DEFAULT_COLUMN_WIDTHS;
|
||||
} catch {
|
||||
return DEFAULT_COLUMN_WIDTHS;
|
||||
}
|
||||
});
|
||||
const columnMinimums = [0, 58, 92, 58, 48, 112];
|
||||
const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' ');
|
||||
|
||||
@@ -53,10 +73,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setContextMenu(null);
|
||||
};
|
||||
window.addEventListener('click', handleCloseMenu);
|
||||
return () => window.removeEventListener('click', handleCloseMenu);
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', handleCloseMenu);
|
||||
window.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths));
|
||||
}, [columnWidths]);
|
||||
|
||||
|
||||
const showInteractionError = (message: string, error: unknown) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
@@ -97,13 +128,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
|
||||
const revealDownloadFile = async (item: DownloadItem) => {
|
||||
let pathToReveal: string | null = null;
|
||||
if (item.status === 'completed') {
|
||||
pathToReveal = await getDownloadPath(item);
|
||||
} else {
|
||||
const settings = useSettingsStore.getState();
|
||||
pathToReveal = item.destination || await resolveCategoryDestination(settings, item.category);
|
||||
}
|
||||
const pathToReveal = await getDownloadPath(item);
|
||||
|
||||
if (!pathToReveal) {
|
||||
openProperties(item.id);
|
||||
@@ -198,6 +223,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
await invoke('pause_download', { id });
|
||||
} catch (e) {
|
||||
console.error("Failed to pause:", e);
|
||||
showInteractionError('Could not pause download', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,7 +275,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
disabled={filteredDownloads.length === 0}
|
||||
onClick={() => {
|
||||
filteredDownloads
|
||||
.filter(d => d.status === 'ready' || d.status === 'paused')
|
||||
.filter(d => canStartDownload(d.status))
|
||||
.forEach(d => handleResume(d));
|
||||
}}
|
||||
title="Resume All"
|
||||
@@ -261,7 +287,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
className="main-control-button"
|
||||
disabled={filteredDownloads.length === 0}
|
||||
onClick={() => {
|
||||
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
|
||||
filteredDownloads.filter(d => canPauseDownload(d.status)).forEach(d => handlePause(d.id));
|
||||
}}
|
||||
title="Pause All"
|
||||
>
|
||||
@@ -348,6 +374,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{/* Floating Context Menu */}
|
||||
{contextMenu && contextItem && (
|
||||
<div
|
||||
role="menu"
|
||||
className="app-modal fixed z-50 min-w-[180px] overflow-hidden py-1.5 text-[12px] font-medium text-text-primary"
|
||||
style={{
|
||||
top: Math.min(contextMenu.y, window.innerHeight - 300),
|
||||
@@ -363,7 +390,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
setContextMenu(null);
|
||||
Array.from(selectedIds).forEach(id => {
|
||||
const item = downloads.find(d => d.id === id);
|
||||
if (item && (item.status === 'ready' || item.status === 'paused' || item.status === 'failed' || item.status === 'retrying')) {
|
||||
if (item && canStartDownload(item.status)) {
|
||||
handleResume(item);
|
||||
}
|
||||
});
|
||||
@@ -384,12 +411,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
Array.from(selectedIds).forEach(id => {
|
||||
const item = downloads.find(d => d.id === id);
|
||||
if (item && item.status !== 'completed') {
|
||||
updateDownload(id, { queueId: q.id });
|
||||
}
|
||||
});
|
||||
assignToQueue(Array.from(selectedIds), q.id);
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
@@ -454,7 +476,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
{(contextItem.status === 'downloading' || contextItem.status === 'queued' || contextItem.status === 'retrying') && (
|
||||
{canPauseDownload(contextItem.status) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
@@ -466,7 +488,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && (
|
||||
{canStartDownload(contextItem.status) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
@@ -474,11 +496,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
{contextItem.status === 'ready' ? 'Start' : 'Resume'}
|
||||
{startActionLabel(contextItem.status)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
|
||||
{canRedownload(contextItem.status) && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
@@ -504,7 +526,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
updateDownload(contextItem.id, { queueId: q.id });
|
||||
assignToQueue([contextItem.id], q.id);
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
import {
|
||||
isIdentityLocked as getIdentityLocked,
|
||||
isTransferLocked as getTransferLocked
|
||||
} from '../utils/downloadActions';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
@@ -98,7 +102,7 @@ export const PropertiesModal = () => {
|
||||
if (!selectedPropertiesDownloadId || !item) return null;
|
||||
|
||||
const handleBrowse = async () => {
|
||||
if (isLocked) return;
|
||||
if (identityLocked) return;
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
@@ -146,8 +150,8 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isLocked = ['downloading', 'processing', 'completed', 'retrying'].includes(item.status);
|
||||
const isTransferLocked = item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying';
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
@@ -196,7 +200,7 @@ export const PropertiesModal = () => {
|
||||
{/* Scrollable Form Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-main-bg/30 p-5 space-y-7">
|
||||
|
||||
{isLocked && (
|
||||
{identityLocked && (
|
||||
<div className="flex gap-2.5 items-center text-xs text-text-secondary bg-border-color/30 p-3 rounded-md border border-border-modal">
|
||||
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
|
||||
<span>
|
||||
@@ -212,34 +216,34 @@ export const PropertiesModal = () => {
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">Download</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||
<label className="text-xs text-text-muted text-right">URL</label>
|
||||
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">File name</label>
|
||||
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Save location</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={saveLocation} readOnly disabled={isLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<button onClick={handleBrowse} disabled={isLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
|
||||
<input type="text" value={saveLocation} readOnly disabled={identityLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<button onClick={handleBrowse} disabled={identityLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
|
||||
<FolderPlus size={14} /> Select
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Connections</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={isTransferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<span className="text-xs text-text-muted">per file</span>
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Speed</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={isTransferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
|
||||
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
|
||||
Limit
|
||||
</label>
|
||||
{speedLimitEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} disabled={isTransferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} disabled={transferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<span className="text-xs text-text-muted">KiB/s</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -257,8 +261,8 @@ export const PropertiesModal = () => {
|
||||
{(['matching', 'custom', 'none'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => !isTransferLocked && setLoginMode(mode)}
|
||||
disabled={isTransferLocked}
|
||||
onClick={() => !transferLocked && setLoginMode(mode)}
|
||||
disabled={transferLocked}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
|
||||
>
|
||||
{mode === 'matching' ? 'Matching site login' : mode === 'custom' ? 'Custom credentials' : 'No login'}
|
||||
@@ -275,10 +279,10 @@ export const PropertiesModal = () => {
|
||||
{loginMode === 'custom' && (
|
||||
<>
|
||||
<label className="text-xs text-text-muted text-right">Username</label>
|
||||
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={isTransferLocked} placeholder="Username" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder="Username" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Password</label>
|
||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={isTransferLocked} placeholder="Password" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder="Password" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,14 +302,14 @@ export const PropertiesModal = () => {
|
||||
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
|
||||
<label className="text-xs text-text-muted text-right">Checksum</label>
|
||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={isTransferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
|
||||
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
|
||||
Verify
|
||||
</label>
|
||||
|
||||
{checksumEnabled && (
|
||||
<>
|
||||
<label className="text-xs text-text-muted text-right">Algorithm</label>
|
||||
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={isTransferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
|
||||
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA-1">SHA-1</option>
|
||||
<option value="SHA-256">SHA-256</option>
|
||||
@@ -313,21 +317,21 @@ export const PropertiesModal = () => {
|
||||
</select>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Digest</label>
|
||||
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={isTransferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Cookies</label>
|
||||
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={isTransferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<div className="col-span-2 mt-2">
|
||||
<label className="block text-xs text-text-muted mb-1.5">Headers</label>
|
||||
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-text-muted mb-1.5">Mirrors</label>
|
||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -351,8 +355,8 @@ export const PropertiesModal = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isTransferLocked}
|
||||
className={`app-button app-button-primary px-4 text-xs ${isTransferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={transferLocked}
|
||||
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Save
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { categoryForFileName } from '../utils/downloads';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return 'Unknown size';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const QualityModal = React.memo(() => {
|
||||
const { activeMetadata, activeMetadataUrl, isParsing, parsingError, clearMetadata, addDownload } = useDownloadStore();
|
||||
const [selectedFormatId, setSelectedFormatId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (activeMetadata && activeMetadata.formats.length > 0 && !selectedFormatId) {
|
||||
setSelectedFormatId(activeMetadata.formats[0].format_id);
|
||||
}
|
||||
}, [activeMetadata]);
|
||||
|
||||
if (!isParsing && !activeMetadata && !parsingError) return null;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!activeMetadata || !activeMetadataUrl || !selectedFormatId) return;
|
||||
|
||||
const format = activeMetadata.formats.find(f => f.format_id === selectedFormatId);
|
||||
if (!format) return;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const id = crypto.randomUUID();
|
||||
const filename = `${activeMetadata.title}.${format.ext}`.replace(/[\/\\?%*:|"<>]/g, '-');
|
||||
const estimatedBytes = format.filesize || format.filesize_approx || 0;
|
||||
|
||||
const category = categoryForFileName(filename);
|
||||
const destination = await resolveCategoryDestination(settings, category);
|
||||
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: activeMetadataUrl,
|
||||
fileName: filename,
|
||||
destination,
|
||||
fraction: 0,
|
||||
size: estimatedBytes
|
||||
? `${format.filesize ? '' : '~'}${formatBytes(estimatedBytes)}`
|
||||
: 'Unknown',
|
||||
speed: '-',
|
||||
eta: '-',
|
||||
category,
|
||||
dateAdded: new Date().toISOString(),
|
||||
isMedia: true,
|
||||
mediaFormatSelector: format.format_id
|
||||
};
|
||||
|
||||
await addDownload(downloadItem, { type: 'start-now' });
|
||||
clearMetadata();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-[#1a1b1e] rounded-xl p-6 max-w-lg w-full shadow-2xl border border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">Media Quality Selection</h2>
|
||||
|
||||
{isParsing && (
|
||||
<div className="py-8 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mb-4"></div>
|
||||
<p>Parsing media metadata...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsingError && (
|
||||
<div className="py-4 text-red-500 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
||||
<p className="font-semibold mb-1">Parsing Failed</p>
|
||||
<p className="text-sm">{parsingError}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={clearMetadata} className="px-4 py-2 border border-red-200 dark:border-red-800 rounded hover:bg-red-100 dark:hover:bg-red-900/40">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeMetadata && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate">{activeMetadata.title}</p>
|
||||
{activeMetadata.duration && <p className="text-sm text-gray-500">Duration: {Math.floor(activeMetadata.duration / 60)}:{String(activeMetadata.duration % 60).padStart(2, '0')}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Select Format</label>
|
||||
<select
|
||||
className="w-full border rounded-lg p-3 bg-gray-50 dark:bg-[#25262b] border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 outline-none transition-shadow"
|
||||
value={selectedFormatId}
|
||||
onChange={(e) => setSelectedFormatId(e.target.value)}
|
||||
>
|
||||
{activeMetadata.formats.map(f => (
|
||||
<option key={f.format_id} value={f.format_id}>
|
||||
{f.resolution || 'Video'} • {f.format_label || f.ext.toUpperCase()} • {f.filesize || f.filesize_approx ? `${f.filesize ? '' : '~'}${formatBytes(f.filesize || f.filesize_approx || 0)}` : 'Unknown size'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-8">
|
||||
<button
|
||||
onClick={clearMetadata}
|
||||
className="px-5 py-2.5 text-sm font-medium border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedFormatId}
|
||||
className="px-5 py-2.5 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Confirm Download
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
|
||||
import { useToast, ToastVariant } from '../contexts/ToastContext';
|
||||
@@ -126,34 +127,27 @@ const CategoryFolderInput = ({
|
||||
type="text"
|
||||
value={value}
|
||||
onFocus={() => setLocalValue(displayPath)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setLocalValue(val);
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = localValue ?? displayPath;
|
||||
const basePrefix = base + '/';
|
||||
|
||||
|
||||
if (!val.trim()) {
|
||||
settings.setCategoryDirectoryOverride(category, undefined);
|
||||
settings.setCategorySubfolder(category, '');
|
||||
} else if (val.startsWith(basePrefix)) {
|
||||
settings.setCategoryDirectoryOverride(category, undefined);
|
||||
settings.setCategorySubfolder(category, val.substring(basePrefix.length));
|
||||
} else {
|
||||
settings.setCategoryDirectoryOverride(category, val);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setLocalValue(null);
|
||||
// Normalize subfolder if not an override
|
||||
const currentOverride = settings.categoryDirectoryOverrides[category];
|
||||
if (!currentOverride) {
|
||||
settings.setCategorySubfolder(
|
||||
category,
|
||||
normalizeCategorySubfolder(
|
||||
settings.categorySubfolders[category] || '',
|
||||
val.substring(basePrefix.length),
|
||||
DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS]
|
||||
)
|
||||
);
|
||||
} else {
|
||||
settings.setCategoryDirectoryOverride(category, val.trim());
|
||||
}
|
||||
setLocalValue(null);
|
||||
}}
|
||||
className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay"
|
||||
aria-label={`${category} subfolder`}
|
||||
@@ -189,6 +183,7 @@ const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
|
||||
const engineRunId = useRef(0);
|
||||
const [appVersion, setAppVersion] = useState('0.7.3');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
|
||||
// Local state for adding site login
|
||||
const [loginPattern, setLoginPattern] = useState('');
|
||||
@@ -204,6 +199,27 @@ useEffect(() => {
|
||||
getVersion().then(setAppVersion).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.activeView !== 'settings' || activeTab !== 'integrations') return;
|
||||
|
||||
let active = true;
|
||||
const refresh = () => {
|
||||
invoke('get_extension_server_port')
|
||||
.then(port => {
|
||||
if (active) setExtensionServerPort(port);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setExtensionServerPort(null);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [settings.activeView, activeTab]);
|
||||
|
||||
const runEngineChecks = useCallback((force = false) => {
|
||||
const runId = ++engineRunId.current;
|
||||
const cached = engineChecks
|
||||
@@ -309,7 +325,24 @@ runEngineChecks(false);
|
||||
if (result.type === 'UpToDate') {
|
||||
showToast(`Firelink ${result.latest_version} is up to date`, 'success');
|
||||
} else if (result.type === 'UpdateAvailable') {
|
||||
showToast(`Firelink ${result.update.version} is available`, 'info');
|
||||
addToast({
|
||||
variant: 'info',
|
||||
isActionable: true,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Firelink {result.update.version} is available.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={() => {
|
||||
void openUrl(result.update.release_url);
|
||||
}}
|
||||
>
|
||||
View release
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
} else {
|
||||
showToast('The update check returned an unexpected response', 'warning');
|
||||
}
|
||||
@@ -363,6 +396,8 @@ runEngineChecks(false);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to create directories on disk:", e);
|
||||
showToast(`Base folder saved, but category folders could not be created: ${String(e)}`, 'warning');
|
||||
return;
|
||||
}
|
||||
showToast("Base download folder updated", 'success');
|
||||
}
|
||||
@@ -400,9 +435,13 @@ runEngineChecks(false);
|
||||
showToast("Added site credential", 'success');
|
||||
};
|
||||
|
||||
const copyToken = () => {
|
||||
navigator.clipboard.writeText(settings.extensionPairingToken);
|
||||
showToast("Token copied to clipboard!", 'success');
|
||||
const copyToken = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(settings.extensionPairingToken);
|
||||
showToast("Token copied to clipboard!", 'success');
|
||||
} catch (error) {
|
||||
showToast(`Could not copy token: ${String(error)}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads';
|
||||
@@ -486,18 +525,15 @@ runEngineChecks(false);
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Global speed limit:</span>
|
||||
<small>0 = unlimited speed</small>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.globalSpeedLimit}
|
||||
onChange={(e) => settings.setGlobalSpeedLimit(e.target.value)}
|
||||
placeholder="0"
|
||||
className="app-control w-24 text-center font-mono pr-9"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted pointer-events-none">KiB/s</span>
|
||||
<small>{settings.globalSpeedLimit || 'Unlimited'}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => settings.setActiveView('speedLimiter')}
|
||||
className="app-button px-3 text-xs"
|
||||
>
|
||||
Configure…
|
||||
</button>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
@@ -759,7 +795,7 @@ runEngineChecks(false);
|
||||
|
||||
<div className="mac-settings-group">
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<span className="text-[13px] text-text-primary">Ask where to save each file</span>
|
||||
<span className="text-[13px] text-text-primary">Ask where to save when adding downloads</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.askWhereToSaveEachFile}
|
||||
@@ -826,11 +862,11 @@ runEngineChecks(false);
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke('delete_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not delete password from keychain:", e);
|
||||
settings.removeSiteLogin(login.id);
|
||||
showToast("Deleted credential", 'success');
|
||||
} catch (error) {
|
||||
showToast(`Could not delete credential: ${String(error)}`, 'error');
|
||||
}
|
||||
settings.removeSiteLogin(login.id);
|
||||
showToast("Deleted credential", 'success');
|
||||
}}
|
||||
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
|
||||
title="Delete credential"
|
||||
@@ -1026,15 +1062,19 @@ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bg-modal hover:bg
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={copyToken}
|
||||
onClick={() => void copyToken()}
|
||||
className="w-full bg-accent hover:bg-accent text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
|
||||
>
|
||||
<Copy size={11} /> Copy Token
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
settings.regeneratePairingToken();
|
||||
showToast("Pairing token regenerated", 'success');
|
||||
onClick={async () => {
|
||||
try {
|
||||
await settings.regeneratePairingToken();
|
||||
showToast("Pairing token regenerated", 'success');
|
||||
} catch (error) {
|
||||
showToast(`Could not regenerate pairing token: ${String(error)}`, 'error');
|
||||
}
|
||||
}}
|
||||
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
@@ -1085,8 +1125,10 @@ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bg-modal hover:bg
|
||||
{/* Status Info */}
|
||||
<div className="border border-border-modal/70 rounded-lg p-3 bg-item-hover/10 flex justify-between items-center text-[12px]">
|
||||
<span className="text-text-secondary font-medium">Extension Server Status:</span>
|
||||
<span className="text-green-500 font-semibold flex items-center gap-1">
|
||||
● Listening on 127.0.0.1:6412-6422 (Active)
|
||||
<span className={`${extensionServerPort ? 'text-green-500' : 'text-orange-400'} font-semibold flex items-center gap-1`}>
|
||||
{extensionServerPort
|
||||
? `● Listening on 127.0.0.1:${extensionServerPort}`
|
||||
: '● Server unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,15 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setContextMenu(null);
|
||||
};
|
||||
window.addEventListener('click', handleCloseMenu);
|
||||
return () => window.removeEventListener('click', handleCloseMenu);
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', handleCloseMenu);
|
||||
window.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -247,6 +254,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
{contextMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-50 w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
|
||||
style={{
|
||||
top: Math.min(contextMenu.y, window.innerHeight - 200),
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function SpeedLimiterView() {
|
||||
<Gauge size={18} className="text-accent" /> Global Speed Limit
|
||||
</div>
|
||||
<p className="max-w-xl text-[12px] leading-relaxed text-text-muted">
|
||||
This cap is shared across the configured concurrent download slots. A lower per-download limit still takes precedence.
|
||||
Saving a new limit gracefully restarts active jobs so the change takes effect immediately.
|
||||
This cap is shared by transfers running through the core downloader. A lower per-download limit still takes precedence.
|
||||
Saving updates the core downloader immediately; media extraction keeps its existing per-download options.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
|
||||
@@ -1491,6 +1491,18 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
html[data-list-density="compact"] .download-row,
|
||||
html[data-list-density="compact"] .download-ghost-row {
|
||||
height: 26px;
|
||||
margin-block: 1px;
|
||||
}
|
||||
|
||||
html[data-list-density="relaxed"] .download-row,
|
||||
html[data-list-density="relaxed"] .download-ghost-row {
|
||||
height: 40px;
|
||||
margin-block: 3px;
|
||||
}
|
||||
|
||||
.download-row > div {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
+6
-44
@@ -5,48 +5,13 @@ import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { MediaCookieSource } from './bindings/MediaCookieSource';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
import type { MetadataResponse } from './bindings/MetadataResponse';
|
||||
import type { EngineStatusItem } from './bindings/EngineStatusItem';
|
||||
import type { EngineStatusResult } from './bindings/EngineStatusResult';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
||||
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
||||
|
||||
type StartDownloadArgs = {
|
||||
id: string;
|
||||
url: string;
|
||||
destination: string;
|
||||
filename: string;
|
||||
connections: number | null;
|
||||
speedLimit: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
headers: string | null;
|
||||
checksum: string | null;
|
||||
cookies: string | null;
|
||||
mirrors: string | null;
|
||||
userAgent: string | null;
|
||||
maxTries: number | null;
|
||||
proxy: string | null;
|
||||
};
|
||||
|
||||
type StartMediaDownloadArgs = {
|
||||
id: string;
|
||||
url: string;
|
||||
destination: string;
|
||||
filename: string;
|
||||
formatSelector: string | null;
|
||||
cookieSource: Exclude<MediaCookieSource, 'none'> | null;
|
||||
speedLimit: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
headers: string | null;
|
||||
proxy: string | null;
|
||||
userAgent: string | null;
|
||||
maxTries: number | null;
|
||||
};
|
||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -57,18 +22,13 @@ type CommandMap = {
|
||||
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null };
|
||||
result: MediaMetadata;
|
||||
};
|
||||
get_engine_status: { args: undefined; result: EngineStatusResult };
|
||||
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_deno_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
open_file: { args: { path: string }; result: void };
|
||||
show_in_folder: { args: { path: string }; result: void };
|
||||
reveal_in_file_manager: { args: { path: string }; result: void };
|
||||
open_downloaded_file: { args: { path: string }; result: void };
|
||||
trash_download_assets: { args: { path: string; partialPaths: string[] }; result: void };
|
||||
start_download: { args: StartDownloadArgs; result: void };
|
||||
start_media_download: { args: StartMediaDownloadArgs; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
remove_download: { args: { id: string; filepath: string | null }; result: void };
|
||||
@@ -88,13 +48,14 @@ type CommandMap = {
|
||||
delete_file: { args: { path: string }; result: void };
|
||||
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||
get_extension_server_port: { args: undefined; result: number | null };
|
||||
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||
get_system_proxy: { args: undefined; result: string | null };
|
||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
||||
is_supported_media: { args: { url: string }; result: boolean };
|
||||
get_supported_media_domains: { args: undefined; result: string[] };
|
||||
db_save_settings: { args: { data: string }; result: void };
|
||||
db_load_settings: { args: undefined; result: string | null };
|
||||
db_get_all_downloads: { args: undefined; result: string[] };
|
||||
@@ -107,8 +68,8 @@ type CommandMap = {
|
||||
};
|
||||
export_logs: { args: { destPath: string }; result: string };
|
||||
get_pending_order: { args: undefined; result: string[] };
|
||||
enqueue_download: { args: { item: any }; result: string };
|
||||
enqueue_many: { args: { items: any[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||
enqueue_download: { args: { item: EnqueueItem }; result: string };
|
||||
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||
move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] };
|
||||
remove_from_queue: { args: { id: string }; result: boolean };
|
||||
};
|
||||
@@ -135,6 +96,7 @@ type EventMap = {
|
||||
'download-failed': string;
|
||||
'extension-add-download': ExtensionDownload;
|
||||
'deep-link-add-download': string;
|
||||
'tray-action': 'pause-all' | 'resume-all';
|
||||
};
|
||||
|
||||
export function listenEvent<K extends keyof EventMap>(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from '../bindings/DownloadStateEvent';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import { listenEvent as listen } from '../ipc';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
@@ -28,7 +28,7 @@ let unlistenTray: UnlistenFn | null = null;
|
||||
|
||||
export async function initDownloadListener() {
|
||||
if (unlistenProgress) return;
|
||||
unlistenProgress = await listen<DownloadProgressEvent>('download-progress', (event) => {
|
||||
unlistenProgress = await listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function initDownloadListener() {
|
||||
});
|
||||
|
||||
if (!unlistenState) {
|
||||
unlistenState = await listen<DownloadStateEvent>('download-state', (event) => {
|
||||
unlistenState = await listen('download-state', (event) => {
|
||||
const payload = event.payload;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
@@ -77,18 +77,12 @@ export async function initDownloadListener() {
|
||||
}
|
||||
|
||||
if (!unlistenTray) {
|
||||
unlistenTray = await listen<string>('tray-action', (event) => {
|
||||
unlistenTray = await listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
if (event.payload === 'pause-all') {
|
||||
const uniqueQueues = Array.from(new Set(
|
||||
mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id))
|
||||
));
|
||||
uniqueQueues.forEach(qid => mainStore.pauseQueue(qid));
|
||||
void mainStore.pauseAll();
|
||||
} else if (event.payload === 'resume-all') {
|
||||
const uniqueQueues = Array.from(new Set(
|
||||
mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id))
|
||||
));
|
||||
uniqueQueues.forEach(qid => mainStore.startQueue(qid));
|
||||
void mainStore.startAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ describe('useDownloadStore', () => {
|
||||
it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: '1', url: 'http://test1', fileName: 'f1', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
{ id: '2', url: 'http://test2', fileName: 'f2', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
{ id: '2', url: 'http://test2', fileName: 'f2', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['1']), // 1 is already registered, so it skips dispatch
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe('useDownloadStore', () => {
|
||||
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: '1', url: 'http://test1', fileName: 'f1', status: 'paused', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['1']),
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
|
||||
});
|
||||
|
||||
it('adds to the list without assigning a queue or dispatching', async () => {
|
||||
it('adds to the list in the main queue without dispatching', async () => {
|
||||
await useDownloadStore.getState().addDownload({
|
||||
id: 'list-1',
|
||||
url: 'https://example.com/list.bin',
|
||||
@@ -113,7 +113,7 @@ describe('useDownloadStore', () => {
|
||||
|
||||
const item = useDownloadStore.getState().downloads[0];
|
||||
expect(item.status).toBe('ready');
|
||||
expect(item.queueId).toBeUndefined();
|
||||
expect(item.queueId).toBe('00000000-0000-0000-0000-000000000001');
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('useDownloadStore', () => {
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
});
|
||||
|
||||
it('starts immediately without assigning a user queue', async () => {
|
||||
it('starts immediately in the main queue', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_pending_order') return ['start-1'];
|
||||
return undefined;
|
||||
@@ -148,7 +148,7 @@ describe('useDownloadStore', () => {
|
||||
}, { type: 'start-now' });
|
||||
|
||||
const item = useDownloadStore.getState().downloads[0];
|
||||
expect(item.queueId).toBeUndefined();
|
||||
expect(item.queueId).toBe('00000000-0000-0000-0000-000000000001');
|
||||
expect(item.hasBeenDispatched).toBe(true);
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
@@ -158,6 +158,83 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'ready', url: 'http://ready', fileName: 'ready', status: 'ready', category: 'Other', dateAdded: '' },
|
||||
{ id: 'active', url: 'http://active', fileName: 'active', status: 'processing', category: 'Other', dateAdded: '' },
|
||||
] as any[],
|
||||
});
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_pending_order') return ['ready'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(await useDownloadStore.getState().startAll()).toBe(1);
|
||||
expect(await useDownloadStore.getState().pauseAll()).toBe(2);
|
||||
|
||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||
expect(calls.some(call => call[0] === 'enqueue_download')).toBe(true);
|
||||
expect(calls.some(call => call[0] === 'pause_download' && (call[1] as any).id === 'active')).toBe(true);
|
||||
});
|
||||
|
||||
it('migrates legacy downloads without queue ids into the main queue', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'legacy',
|
||||
url: 'https://example.com/legacy.bin',
|
||||
fileName: 'legacy.bin',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
})];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].queueId)
|
||||
.toBe('00000000-0000-0000-0000-000000000001');
|
||||
});
|
||||
|
||||
it('pauses queued, downloading, processing, and retrying queue items', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: ['queued', 'downloading', 'processing', 'retrying'].map((status, index) => ({
|
||||
id: `${index}`,
|
||||
url: `https://example.com/${index}`,
|
||||
fileName: `${index}.bin`,
|
||||
status,
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: 'queue-a'
|
||||
})) as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
expect(await useDownloadStore.getState().pauseQueue('queue-a')).toBe(4);
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(call => call[0] === 'pause_download')
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('assigns selected unfinished downloads to a queue without moving completed items', () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'ready', status: 'ready', queueId: 'old' },
|
||||
{ id: 'done', status: 'completed', queueId: 'old' }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new');
|
||||
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'ready')?.queueId).toBe('new');
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
|
||||
});
|
||||
|
||||
it('preserves extension request headers and cookies for the Add modal', () => {
|
||||
useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://example.com/file.bin'],
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { info } from '@tauri-apps/plugin-log';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import type { MediaMetadata } from '../bindings/MediaMetadata';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
expandTilde,
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
} from '../utils/downloadLocations';
|
||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
@@ -31,6 +30,8 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
if (state.backendRegisteredIds.has(id)) return true;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const destination = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
@@ -42,7 +43,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
const enqueueItem = {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: item.destination,
|
||||
destination,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
||||
@@ -126,13 +127,7 @@ const syncSystemIntegrations = () => {
|
||||
};
|
||||
|
||||
const resolveDownloadPath = async (destination: string, fileName: string) => {
|
||||
let resolvedDestination = destination;
|
||||
if (destination.startsWith('~/')) {
|
||||
resolvedDestination = await resolveDownloadFilePath(await homeDir(), destination.slice(2));
|
||||
} else if (destination === '~') {
|
||||
resolvedDestination = await homeDir();
|
||||
}
|
||||
return resolveDownloadFilePath(resolvedDestination, fileName);
|
||||
return resolveDownloadFilePath(await expandTilde(destination), fileName);
|
||||
};
|
||||
|
||||
const effectiveDestinationForItem = async (
|
||||
@@ -165,8 +160,6 @@ interface DownloadState {
|
||||
backendRegisteredIds: Set<string>;
|
||||
registerBackendIds: (ids: string[]) => void;
|
||||
unregisterBackendIds: (ids: string[]) => void;
|
||||
activeDownloadId: string | null;
|
||||
setActiveDownloadId: (id: string | null) => void;
|
||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||
moveInQueue: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||
removeFromQueue: (id: string) => Promise<void>;
|
||||
@@ -197,17 +190,14 @@ interface DownloadState {
|
||||
resumeDownload: (id: string) => Promise<void>;
|
||||
startQueue: (queueId: string) => Promise<number>;
|
||||
pauseQueue: (queueId: string) => Promise<number>;
|
||||
startAll: () => Promise<number>;
|
||||
pauseAll: () => Promise<number>;
|
||||
assignToQueue: (ids: string[], queueId: string) => void;
|
||||
addQueue: (name: string) => void;
|
||||
renameQueue: (id: string, name: string) => void;
|
||||
removeQueue: (id: string) => void;
|
||||
initDB: () => Promise<void>;
|
||||
|
||||
isParsing: boolean;
|
||||
activeMetadata: MediaMetadata | null;
|
||||
activeMetadataUrl: string | null;
|
||||
parsingError: string | null;
|
||||
fetchMetadataAction: (url: string) => Promise<void>;
|
||||
clearMetadata: () => void;
|
||||
}
|
||||
|
||||
export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
@@ -215,8 +205,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
pendingOrder: [],
|
||||
setPendingOrder: (order) => set({ pendingOrder: order }),
|
||||
activeDownloadId: null,
|
||||
setActiveDownloadId: (id) => set({ activeDownloadId: id }),
|
||||
moveInQueue: async (id, direction) => {
|
||||
try {
|
||||
const order = await invoke('move_in_queue', { id, direction });
|
||||
@@ -253,10 +241,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
selectedPropertiesDownloadId: null,
|
||||
isParsing: false,
|
||||
activeMetadata: null,
|
||||
activeMetadataUrl: null,
|
||||
parsingError: null,
|
||||
deleteModalState: { isOpen: false },
|
||||
openDeleteModal: (downloadIds) => set({
|
||||
deleteModalState: {
|
||||
@@ -298,24 +282,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
);
|
||||
},
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
clearMetadata: () => set({ isParsing: false, activeMetadata: null, activeMetadataUrl: null, parsingError: null }),
|
||||
fetchMetadataAction: async (url) => {
|
||||
set({ isParsing: true, parsingError: null, activeMetadata: null, activeMetadataUrl: url });
|
||||
try {
|
||||
const settings = useSettingsStore.getState();
|
||||
const metadata = await fetchMediaMetadataDeduped({
|
||||
url,
|
||||
cookieBrowser: settings.mediaCookieSource === 'none' ? null : settings.mediaCookieSource,
|
||||
username: null,
|
||||
password: null
|
||||
});
|
||||
set({ isParsing: false, activeMetadata: metadata });
|
||||
info(`Media metadata parsed for ${url}: found ${metadata.formats.length} formats`);
|
||||
} catch (e) {
|
||||
set({ isParsing: false, parsingError: String(e) });
|
||||
info(`Media metadata parsing failed for ${url}: ${e}`);
|
||||
}
|
||||
},
|
||||
addDownload: async (item, action) => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const destPath = await effectiveDestinationForItem(item, settings);
|
||||
@@ -323,7 +289,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
...item,
|
||||
destination: destPath,
|
||||
status: action.type === 'add-to-queue' ? 'queued' : 'ready',
|
||||
queueId: action.type === 'add-to-queue' ? action.queueId : undefined,
|
||||
queueId: action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID,
|
||||
hasBeenDispatched: false
|
||||
};
|
||||
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
|
||||
@@ -427,7 +393,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.filter(d => d.id !== id),
|
||||
pendingOrder: state.pendingOrder.filter(x => x !== id)
|
||||
pendingOrder: state.pendingOrder.filter(x => x !== id),
|
||||
backendRegisteredIds: new Set(
|
||||
Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id)
|
||||
)
|
||||
}));
|
||||
info(`Download ${id} removed`);
|
||||
syncSystemIntegrations();
|
||||
@@ -483,7 +452,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
destination: destPath,
|
||||
isMedia: targetItem.isMedia,
|
||||
mediaFormatSelector,
|
||||
queueId: targetItem.queueId
|
||||
queueId: targetItem.queueId || MAIN_QUEUE_ID,
|
||||
hasBeenDispatched: false
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
@@ -533,13 +503,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
const runnable = get().downloads
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed'));
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)));
|
||||
|
||||
if (runnable.length === 0) return 0;
|
||||
|
||||
let dispatchedCount = 0;
|
||||
const promises = runnable.map(async (item) => {
|
||||
if (item.status === 'failed' || !item.hasBeenDispatched) {
|
||||
if (item.status === 'ready' || item.status === 'failed' || !item.hasBeenDispatched) {
|
||||
if (await dispatchItem(item.id)) {
|
||||
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' });
|
||||
dispatchedCount++;
|
||||
@@ -561,7 +531,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
pauseQueue: async (queueId) => {
|
||||
const activeIds = get().downloads
|
||||
.filter(item => item.queueId === queueId && item.status === 'downloading')
|
||||
.filter(item => item.queueId === queueId && canPauseDownload(item.status))
|
||||
.map(item => item.id);
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
@@ -578,6 +548,43 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
syncSystemIntegrations();
|
||||
return pausedCount;
|
||||
},
|
||||
startAll: async () => {
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
item.queueId ? item : { ...item, queueId: MAIN_QUEUE_ID }
|
||||
)
|
||||
}));
|
||||
const queueIds = new Set(
|
||||
get().downloads
|
||||
.filter(item => item.status === 'queued' || canStartDownload(item.status))
|
||||
.map(item => item.queueId || MAIN_QUEUE_ID)
|
||||
);
|
||||
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId)));
|
||||
return results.reduce((total, count) => total + count, 0);
|
||||
},
|
||||
pauseAll: async () => {
|
||||
const activeIds = get().downloads
|
||||
.filter(item => canPauseDownload(item.status))
|
||||
.map(item => item.id);
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
activeIds.map(id => invoke('pause_download', { id }))
|
||||
);
|
||||
const pausedCount = results.filter(result => result.status === 'fulfilled').length;
|
||||
syncSystemIntegrations();
|
||||
return pausedCount;
|
||||
},
|
||||
assignToQueue: (ids, queueId) => {
|
||||
const selectedIds = new Set(ids);
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
selectedIds.has(item.id) && item.status !== 'completed'
|
||||
? { ...item, queueId }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
},
|
||||
addQueue: (name) => {
|
||||
const id = crypto.randomUUID();
|
||||
const q = { id, name, isMain: false };
|
||||
@@ -615,7 +622,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
set(state => ({
|
||||
queues: queues.length > 0 ? queues : state.queues,
|
||||
downloads: downloads.length > 0 ? downloads : state.downloads
|
||||
downloads: downloads.length > 0
|
||||
? downloads.map(download => ({
|
||||
...download,
|
||||
queueId: download.queueId || MAIN_QUEUE_ID
|
||||
}))
|
||||
: state.downloads
|
||||
}));
|
||||
|
||||
// Reset interrupted active downloads to queued.
|
||||
@@ -666,9 +678,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
is_media: item.isMedia || false
|
||||
});
|
||||
}
|
||||
await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
const registeredIds = results.filter(result => result.success).map(result => result.id);
|
||||
const failedIds = new Set(results.filter(result => !result.success).map(result => result.id));
|
||||
const order = await invoke('get_pending_order');
|
||||
set({ pendingOrder: order });
|
||||
set(state => ({
|
||||
pendingOrder: order,
|
||||
backendRegisteredIds: new Set([
|
||||
...state.backendRegisteredIds,
|
||||
...registeredIds
|
||||
]),
|
||||
downloads: state.downloads.map(download =>
|
||||
failedIds.has(download.id)
|
||||
? { ...download, status: 'failed' as const }
|
||||
: registeredIds.includes(download.id)
|
||||
? { ...download, hasBeenDispatched: true }
|
||||
: download
|
||||
)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("Failed to auto-resume active downloads:", e);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export interface SettingsState {
|
||||
resetCategoryLocations: () => void;
|
||||
addSiteLogin: (login: SiteLogin) => void;
|
||||
removeSiteLogin: (id: string) => void;
|
||||
regeneratePairingToken: () => void;
|
||||
regeneratePairingToken: () => Promise<void>;
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||
hydratePairingToken: () => Promise<boolean>;
|
||||
}
|
||||
@@ -286,12 +286,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
removeSiteLogin: (id) => set((state) => ({
|
||||
siteLogins: state.siteLogins.filter((login) => login.id !== id)
|
||||
})),
|
||||
regeneratePairingToken: () => {
|
||||
regeneratePairingToken: async () => {
|
||||
const token = generateSecureToken();
|
||||
await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token });
|
||||
set({ extensionPairingToken: token });
|
||||
invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token }).catch(e => {
|
||||
console.error('Failed to persist regenerated extension pairing token to keychain:', e);
|
||||
});
|
||||
},
|
||||
hydratePairingToken: async () => {
|
||||
const result = await invoke('hydrate_extension_pairing_token');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canPauseDownload,
|
||||
canRedownload,
|
||||
canStartDownload,
|
||||
isIdentityLocked,
|
||||
isTransferLocked,
|
||||
startActionLabel,
|
||||
} from './downloadActions';
|
||||
|
||||
describe('download action policy', () => {
|
||||
it('keeps start and pause actions mutually exclusive', () => {
|
||||
for (const status of ['ready', 'paused', 'failed'] as const) {
|
||||
expect(canStartDownload(status)).toBe(true);
|
||||
expect(canPauseDownload(status)).toBe(false);
|
||||
}
|
||||
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) {
|
||||
expect(canPauseDownload(status)).toBe(true);
|
||||
expect(canStartDownload(status)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('limits redownload to terminal or paused states', () => {
|
||||
expect(canRedownload('completed')).toBe(true);
|
||||
expect(canRedownload('failed')).toBe(true);
|
||||
expect(canRedownload('paused')).toBe(true);
|
||||
expect(canRedownload('downloading')).toBe(false);
|
||||
});
|
||||
|
||||
it('provides consistent labels and edit locks', () => {
|
||||
expect(startActionLabel('ready')).toBe('Start');
|
||||
expect(startActionLabel('failed')).toBe('Start');
|
||||
expect(startActionLabel('paused')).toBe('Resume');
|
||||
expect(isTransferLocked('processing')).toBe(true);
|
||||
expect(isIdentityLocked('completed')).toBe(true);
|
||||
expect(isTransferLocked('completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
|
||||
const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'ready',
|
||||
'paused',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'queued',
|
||||
'downloading',
|
||||
'processing',
|
||||
'retrying',
|
||||
]);
|
||||
|
||||
const REDOWNLOADABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'completed',
|
||||
'failed',
|
||||
'paused',
|
||||
]);
|
||||
|
||||
export const canStartDownload = (status: DownloadStatus): boolean =>
|
||||
STARTABLE_STATUSES.has(status);
|
||||
|
||||
export const canPauseDownload = (status: DownloadStatus): boolean =>
|
||||
PAUSABLE_STATUSES.has(status);
|
||||
|
||||
export const canRedownload = (status: DownloadStatus): boolean =>
|
||||
REDOWNLOADABLE_STATUSES.has(status);
|
||||
|
||||
export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
|
||||
status === 'ready' || status === 'failed' ? 'Start' : 'Resume';
|
||||
|
||||
export const isTransferLocked = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
export const isIdentityLocked = (status: DownloadStatus): boolean =>
|
||||
isTransferLocked(status) || status === 'completed';
|
||||
@@ -3,7 +3,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
export type { DownloadCategory } from '../bindings/DownloadCategory';
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
|
||||
let MEDIA_DOMAINS = [
|
||||
'youtube.com',
|
||||
@@ -47,7 +47,7 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n
|
||||
|
||||
export const initMediaDomains = async () => {
|
||||
try {
|
||||
const domains = await invoke<string[]>('get_supported_media_domains');
|
||||
const domains = await invoke('get_supported_media_domains');
|
||||
if (domains && domains.length > 0) {
|
||||
MEDIA_DOMAINS = domains;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user