diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts
index 77bb288..8a5e4ce 100644
--- a/src/main/download-manager.ts
+++ b/src/main/download-manager.ts
@@ -7407,7 +7407,9 @@ export class DownloadManager extends EventEmitter {
this.runGlobalStallWatchdog(now);
- const downloadsComplete = this.activeTasks.size === 0 && !this.hasQueuedItems() && !this.hasDelayedQueuedItems();
+ // Single-pass queue presence check (saves one full O(n) iteration per tick)
+ const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false };
+ const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed;
const postProcessComplete = this.packagePostProcessTasks.size === 0;
if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) {
this.finishRun();
@@ -7617,56 +7619,38 @@ export class DownloadManager extends EventEmitter {
return null;
}
- private hasQueuedItems(): boolean {
- const now = nowMs();
+ /** Single-pass alternative to hasQueuedItems + hasDelayedQueuedItems.
+ * Returns both flags so the scheduler termination check needs only ONE
+ * iteration over packages/items per tick instead of two separate scans. */
+ private getQueuePresence(now = nowMs()): { hasImmediate: boolean; hasDelayed: boolean } {
+ let hasImmediate = false;
+ let hasDelayed = false;
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
- if (!pkg || pkg.cancelled || !pkg.enabled) {
- continue;
- }
- if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) {
- continue;
- }
+ if (!pkg || pkg.cancelled || !pkg.enabled) continue;
+ if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue;
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
- if (!item) {
- continue;
- }
+ if (!item) continue;
+ if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
if (retryAfter > now) {
- continue;
- }
- if (item.status === "queued" || item.status === "reconnect_wait") {
- return true;
+ hasDelayed = true;
+ } else {
+ hasImmediate = true;
}
+ if (hasImmediate && hasDelayed) return { hasImmediate, hasDelayed };
}
}
- return false;
+ return { hasImmediate, hasDelayed };
+ }
+
+ private hasQueuedItems(): boolean {
+ return this.getQueuePresence().hasImmediate;
}
private hasDelayedQueuedItems(): boolean {
- const now = nowMs();
- for (const [itemId, readyAt] of this.retryAfterByItem.entries()) {
- if (readyAt <= now) {
- continue;
- }
- const item = this.session.items[itemId];
- if (!item) {
- continue;
- }
- if (item.status !== "queued" && item.status !== "reconnect_wait") {
- continue;
- }
- const pkg = this.session.packages[item.packageId];
- if (!pkg || pkg.cancelled || !pkg.enabled) {
- continue;
- }
- if (this.runPackageIds.size > 0 && !this.runPackageIds.has(item.packageId)) {
- continue;
- }
- return true;
- }
- return false;
+ return this.getQueuePresence().hasDelayed;
}
private countQueuedItems(): number {
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 9a33990..b59f672 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -6117,6 +6117,153 @@ export function App(): ReactElement {
);
}
+/** Computes the user-facing status text for an item, applying business rules
+ * about which states are visible while the session is stopped. */
+function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string {
+ const statusText = String(item.fullStatus || "").trim();
+ if (statusText === "Wartet") return "";
+ if (sessionRunning) return statusText;
+ if (item.status !== "queued" && item.status !== "reconnect_wait") return statusText;
+ if (statusText === "Paket gestoppt") return statusText;
+ if (/^Entpacken\b/i.test(statusText) || /^Entpackt\b/i.test(statusText) || /^Entpack-Fehler\b/i.test(statusText) || /^Fertig\b/i.test(statusText)) {
+ return statusText;
+ }
+ return "";
+}
+
+interface ItemRowProps {
+ item: DownloadItem;
+ packageId: string;
+ isSelected: boolean;
+ sessionRunning: boolean;
+ columnOrder: string[];
+ gridTemplate: string;
+ onSelect: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
+ onSelectMouseDown: (id: string, e: React.MouseEvent) => void;
+ onSelectMouseEnter: (id: string) => void;
+ onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
+}
+
+/** Per-item row, memoized so a status update on one item doesn't re-render
+ * every other item in the same package (the bottleneck on packages with
+ * many episodes). Custom equality only checks the fields actually rendered. */
+const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement {
+ const handleClick = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelect(item.id, e.ctrlKey, e.shiftKey);
+ }, [item.id, onSelect]);
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation();
+ onSelectMouseDown(item.id, e);
+ }, [item.id, onSelectMouseDown]);
+ const handleMouseEnter = useCallback(() => {
+ onSelectMouseEnter(item.id);
+ }, [item.id, onSelectMouseEnter]);
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onContextMenu(packageId, item.id, e.clientX, e.clientY);
+ }, [packageId, item.id, onContextMenu]);
+
+ return (
+
+ {columnOrder.map((col) => {
+ switch (col) {
+ case "name": return (
+
+ {item.onlineStatus && }
+ {item.fileName}
+
+ );
+ case "size": {
+ const total = item.totalBytes || item.downloadedBytes || 0;
+ const dl = item.downloadedBytes || 0;
+ const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0;
+ const label = `${humanSize(dl)} / ${humanSize(total)}`;
+ return (
+
+ {total > 0 ? (
+
+
+ {label}
+ {label}
+
+ ) : ""}
+
+ );
+ }
+ case "progress": return (
+
+ {(item.totalBytes || 0) > 0 ? (
+
+
+ {item.progressPercent}%
+ {item.progressPercent}%
+
+ ) : ""}
+
+ );
+ case "hoster": { const h = extractHoster(item.url) || ""; return {h}; }
+ case "account": return {item.providerLabel || (item.provider ? providerLabels[item.provider] : "")};
+ case "prio": return ;
+ case "status": {
+ const displayStatus = computeDisplayedItemStatus(item, sessionRunning);
+ const title = !displayStatus ? "" : (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus);
+ return (
+
+ {displayStatus}
+
+ );
+ }
+ case "speed": return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""};
+ case "added": return {formatDateTime(item.createdAt)};
+ default: return null;
+ }
+ })}
+
+ );
+}, (prev, next) => {
+ // Skip re-render unless something visible actually changed for THIS item.
+ if (prev.item !== next.item) {
+ const a = prev.item;
+ const b = next.item;
+ if (a.id !== b.id
+ || a.updatedAt !== b.updatedAt
+ || a.status !== b.status
+ || a.fileName !== b.fileName
+ || a.url !== b.url
+ || a.provider !== b.provider
+ || a.providerLabel !== b.providerLabel
+ || a.fullStatus !== b.fullStatus
+ || a.onlineStatus !== b.onlineStatus
+ || a.progressPercent !== b.progressPercent
+ || a.speedBps !== b.speedBps
+ || a.downloadedBytes !== b.downloadedBytes
+ || a.totalBytes !== b.totalBytes
+ || a.retries !== b.retries
+ || a.createdAt !== b.createdAt) {
+ return false;
+ }
+ }
+ if (prev.packageId !== next.packageId) return false;
+ if (prev.isSelected !== next.isSelected) return false;
+ if (prev.sessionRunning !== next.sessionRunning) return false;
+ if (prev.columnOrder !== next.columnOrder) return false;
+ if (prev.gridTemplate !== next.gridTemplate) return false;
+ if (prev.onSelect !== next.onSelect) return false;
+ if (prev.onSelectMouseDown !== next.onSelectMouseDown) return false;
+ if (prev.onSelectMouseEnter !== next.onSelectMouseEnter) return false;
+ if (prev.onContextMenu !== next.onContextMenu) return false;
+ return true;
+});
+
interface PackageCardProps {
pkg: PackageEntry;
items: DownloadItem[];
@@ -6151,61 +6298,48 @@ interface PackageCardProps {
}
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
- const done = items.filter((item) => item.status === "completed").length;
- const failed = items.filter((item) => item.status === "failed").length;
- const cancelled = items.filter((item) => item.status === "cancelled").length;
- const extracted = items.filter((item) => item.fullStatus?.startsWith("Entpackt")).length;
- const extracting = items.some((item) => item.fullStatus?.startsWith("Entpacken"));
- const total = Math.max(1, items.length);
- // Use 50/50 split when extraction is active OR package is in extracting state
- // (prevents bar jumping from 100% to 50% when extraction starts)
- const allDownloaded = done + failed + cancelled >= total;
- const allExtracted = extracted >= total;
- const useExtractSplit = extracting || pkg.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
- // Include fractional progress from active downloads so the bar moves continuously
- const activeProgress = items.reduce((sum, item) => {
- if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
- return sum + (item.progressPercent || 0) / 100;
+ // Single-pass aggregation: replaces 5 separate filter()/some() + 2 reduce() calls.
+ // For a package with N items this is O(N) instead of O(7N) per render.
+ const stats = useMemo(() => {
+ let done = 0;
+ let failed = 0;
+ let cancelled = 0;
+ let extracted = 0;
+ let extracting = false;
+ let activeProgress = 0;
+ let extractingProgress = 0;
+ for (const item of items) {
+ if (item.status === "completed") done += 1;
+ else if (item.status === "failed") failed += 1;
+ else if (item.status === "cancelled") cancelled += 1;
+ const fs = item.fullStatus || "";
+ if (fs.startsWith("Entpackt")) {
+ extracted += 1;
+ } else if (fs.startsWith("Entpacken")) {
+ extracting = true;
+ const m = fs.match(/^Entpacken\s+(\d+)%/);
+ if (m) extractingProgress += Number(m[1]) / 100;
+ }
+ if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
+ activeProgress += (item.progressPercent || 0) / 100;
+ }
}
- return sum;
- }, 0);
- const dlProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
- // Include fractional progress from items currently being extracted
- const extractingProgress = items.reduce((sum, item) => {
- const fs = item.fullStatus || "";
- if (fs.startsWith("Entpackt")) return sum;
- const m = fs.match(/^Entpacken\s+(\d+)%/);
- if (m) return sum + Number(m[1]) / 100;
- return sum;
- }, 0);
- const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
- const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress);
+ const total = Math.max(1, items.length);
+ const allDownloaded = done + failed + cancelled >= total;
+ const allExtracted = extracted >= total;
+ const useExtractSplit = extracting || pkg.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
+ const dlProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
+ const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
+ const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress);
+ return { done, failed, cancelled, extracted, extracting, total, useExtractSplit, dlProgress, exProgress, combinedProgress };
+ }, [items, pkg.status]);
+ const { done, failed, cancelled, extracted, extracting, total, useExtractSplit, dlProgress, exProgress, combinedProgress } = stats;
const onKeyDown = (e: ReactKeyboardEvent): void => {
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); }
};
- const getDisplayedItemStatus = (item: DownloadItem): string => {
- const statusText = String(item.fullStatus || "").trim();
- if (statusText === "Wartet") {
- return "";
- }
- if (sessionRunning) {
- return statusText;
- }
- if (item.status !== "queued" && item.status !== "reconnect_wait") {
- return statusText;
- }
- if (statusText === "Paket gestoppt") {
- return statusText;
- }
- if (/^Entpacken\b/i.test(statusText) || /^Entpackt\b/i.test(statusText) || /^Entpack-Fehler\b/i.test(statusText) || /^Fertig\b/i.test(statusText)) {
- return statusText;
- }
- return "";
- };
-
return (
}
{!collapsed && items.filter((item) => !hideExtractedItems || !item.fullStatus?.startsWith("Entpackt")).map((item) => (
- { e.stopPropagation(); onSelect(item.id, e.ctrlKey, e.shiftKey); }} onMouseDown={(e) => { e.stopPropagation(); onSelectMouseDown(item.id, e); }} onMouseEnter={() => onSelectMouseEnter(item.id)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, item.id, e.clientX, e.clientY); }}>
- {columnOrder.map((col) => {
- switch (col) {
- case "name": return (
-
- {item.onlineStatus && }
- {item.fileName}
-
- );
- case "size": return (
- {(() => {
- const total = item.totalBytes || item.downloadedBytes || 0;
- const dl = item.downloadedBytes || 0;
- const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0;
- const label = `${humanSize(dl)} / ${humanSize(total)}`;
- return total > 0 ? (
-
-
- {label}
- {label}
-
- ) : "";
- })()}
- );
- case "progress": return (
-
- {(item.totalBytes || 0) > 0 ? (
-
-
- {item.progressPercent}%
- {item.progressPercent}%
-
- ) : ""}
-
- );
- case "hoster": { const h = extractHoster(item.url) || ""; return {h}; }
- case "account": return {item.providerLabel || (item.provider ? providerLabels[item.provider] : "")};
- case "prio": return ;
- case "status": return (
- {
- const displayStatus = getDisplayedItemStatus(item);
- if (!displayStatus) {
- return "";
- }
- return item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus;
- })()}>
- {getDisplayedItemStatus(item)}
-
- );
- case "speed": return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""};
- case "added": return {formatDateTime(item.createdAt)};
- default: return null;
- }
- })}
-
+
))}
);
@@ -6369,11 +6461,21 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|| prev.isEditing !== next.isEditing
|| prev.collapsed !== next.collapsed
|| prev.hideExtractedItems !== next.hideExtractedItems
- || prev.selectedIds !== next.selectedIds
|| prev.columnOrder !== next.columnOrder
|| prev.gridTemplate !== next.gridTemplate) {
return false;
}
+ // selectedIds is a Set that gets a new reference on every selection change
+ // anywhere in the app. Only re-render this card if the selection state
+ // changed for an item that ACTUALLY belongs to this package — that way
+ // selecting an item in a different package doesn't re-render all 200+ cards.
+ if (prev.selectedIds !== next.selectedIds) {
+ for (const itemId of next.pkg.itemIds) {
+ if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {
+ return false;
+ }
+ }
+ }
if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) {
return false;
}