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; }