perf(downloads): scale playlist rows efficiently

This commit is contained in:
NimBold
2026-07-16 17:23:43 +03:30
parent feb5d8e87d
commit d195a132b3
2 changed files with 92 additions and 65 deletions
+16 -34
View File
@@ -1,11 +1,8 @@
import React from 'react'; import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useDownloadStore } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react'; import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem'; import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions'; import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
import { isActiveDownloadStatus } from '../utils/downloads';
import { import {
downloadProgressColorClass, downloadProgressColorClass,
formatDownloadTotal, formatDownloadTotal,
@@ -13,47 +10,35 @@ import {
} from '../utils/downloadProgress'; } from '../utils/downloadProgress';
interface DownloadItemProps { interface DownloadItemProps {
downloadId: string; download: DownloadItemType;
index: number; index: number;
queueIndex: number;
queueLength: number;
tableGridTemplate: string; tableGridTemplate: string;
setContextMenu: (menu: { x: number; y: number; id: string }) => void; setContextMenu: (menu: { x: number; y: number; id: string }) => void;
handlePause: (id: string, skipConfirm?: boolean) => void; handlePause: (id: string, skipConfirm?: boolean) => void;
handleResume: (item: DownloadItemType) => void; handleResume: (item: DownloadItemType) => void;
getCategoryIcon: (category: string) => React.ReactNode; getCategoryIcon: (category: string) => React.ReactNode;
selectedIds: Set<string>; isSelected: boolean;
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
onClick: (e: React.MouseEvent, item: DownloadItemType) => void; onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
} }
export const DownloadItem = React.memo<DownloadItemProps>(({ export const DownloadItem = React.memo<DownloadItemProps>(({
downloadId, download,
index, index,
queueIndex,
queueLength,
tableGridTemplate, tableGridTemplate,
setContextMenu, setContextMenu,
handlePause, handlePause,
handleResume, handleResume,
getCategoryIcon, getCategoryIcon,
selectedIds, isSelected,
onMoveInQueue,
onClick, onClick,
}) => { }) => {
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId)); const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const queueItems = useDownloadStore(useShallow(state => {
const item = state.downloads.find(candidate => candidate.id === downloadId);
if (!item) return [];
const queueId = item.queueId;
return state.downloads
.filter(candidate =>
candidate.queueId === queueId &&
candidate.status !== 'completed' &&
!(isActiveDownloadStatus(candidate.status) && candidate.status !== 'queued')
)
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(candidate => candidate.id);
}));
const moveInQueue = useDownloadStore(state => state.moveInQueue);
const liveProgress = useDownloadProgressStore(state => state.progressMap[downloadId]);
const queueIndex = queueItems.indexOf(downloadId);
if (!download) return null;
const displayFraction = download.status === 'downloading' const displayFraction = download.status === 'downloading'
? liveProgress?.fraction ?? download.fraction ?? 0 ? liveProgress?.fraction ?? download.fraction ?? 0
@@ -83,7 +68,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
return ( return (
<div <div
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${selectedIds.has(downloadId) ? 'is-selected' : ''}`} className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate }} style={{ gridTemplateColumns: tableGridTemplate }}
onClick={(e) => onClick(e, download)} onClick={(e) => onClick(e, download)}
onContextMenu={(e) => { onContextMenu={(e) => {
@@ -138,8 +123,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
style={{ width: `${displayFraction * 100}%` }} style={{ width: `${displayFraction * 100}%` }}
/> />
</div> </div>
<span <span
key={`status-${download.status}`}
title={ title={
download.lastError && (download.status === 'failed' || download.status === 'retrying') download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError ? download.lastError
@@ -181,7 +165,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div className="download-cell-truncate"> <div className="download-cell-truncate">
<span <span
key={`speed-${download.status}`}
className="tabular-nums" className="tabular-nums"
title={displaySpeed} title={displaySpeed}
> >
@@ -191,7 +174,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div className="download-cell-truncate"> <div className="download-cell-truncate">
<span <span
key={`eta-${download.status}`}
className="tabular-nums" className="tabular-nums"
title={displayEta} title={displayEta}
> >
@@ -215,7 +197,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && ( {(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<> <>
<button <button
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'up')} onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0} disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40" className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Up" title="Move Up"
@@ -223,8 +205,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<ArrowUp size={14} /> <ArrowUp size={14} />
</button> </button>
<button <button
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'down')} onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueItems.length - 1} disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40" className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down" title="Move Down"
> >
+76 -31
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
@@ -33,7 +33,7 @@ const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => { export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, queues, assignToQueue, openDeleteModal, redownload } = useDownloadStore(); const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
const { addToast } = useToast(); const { addToast } = useToast();
const isMac = navigator.userAgent.includes('Mac'); const isMac = navigator.userAgent.includes('Mac');
const [isReadingClipboard, setIsReadingClipboard] = useState(false); const [isReadingClipboard, setIsReadingClipboard] = useState(false);
@@ -54,8 +54,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' }); const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null); const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
const selectedIdsRef = useRef(selectedIds); const selectedIdsRef = useRef(selectedIds);
const lastSelectedIdRef = useRef(lastSelectedId);
const sortedDownloadsRef = useRef<DownloadItem[]>([]); const sortedDownloadsRef = useRef<DownloadItem[]>([]);
selectedIdsRef.current = selectedIds; selectedIdsRef.current = selectedIds;
lastSelectedIdRef.current = lastSelectedId;
const [columnWidths, setColumnWidths] = useState(() => { const [columnWidths, setColumnWidths] = useState(() => {
try { try {
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null'); const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
@@ -138,25 +140,25 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}, []); }, []);
const showInteractionError = (message: string, error: unknown) => { const showInteractionError = useCallback((message: string, error: unknown) => {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true }); addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
}; }, [addToast]);
const getDownloadPath = async (item: DownloadItem) => { const getDownloadPath = useCallback(async (item: DownloadItem) => {
const fileName = item.fileName?.trim(); const fileName = item.fileName?.trim();
if (!fileName) return null; if (!fileName) return null;
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
const destination = item.destination || const destination = item.destination ||
await resolveCategoryDestination(settings, item.category); await resolveCategoryDestination(settings, item.category);
return resolveDownloadFilePath(destination, fileName); return resolveDownloadFilePath(destination, fileName);
}; }, []);
const openProperties = (id: string) => { const openProperties = useCallback((id: string) => {
useDownloadStore.getState().setSelectedPropertiesDownloadId(id); useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
}; }, []);
const openDownloadFile = async (item: DownloadItem) => { const openDownloadFile = useCallback(async (item: DownloadItem) => {
if (item.status !== 'completed') { if (item.status !== 'completed') {
openProperties(item.id); openProperties(item.id);
return; return;
@@ -174,7 +176,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
console.error("Failed to open file:", error); console.error("Failed to open file:", error);
showInteractionError('Could not open downloaded file', error); showInteractionError('Could not open downloaded file', error);
} }
}; }, [getDownloadPath, openProperties, showInteractionError]);
const revealDownloadFile = async (item: DownloadItem) => { const revealDownloadFile = async (item: DownloadItem) => {
const pathToReveal = await getDownloadPath(item); const pathToReveal = await getDownloadPath(item);
@@ -192,14 +194,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
} }
}; };
const handleDownloadDoubleClick = (item: DownloadItem) => { const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
if (item.status === 'completed') { if (item.status === 'completed') {
void openDownloadFile(item); void openDownloadFile(item);
return; return;
} }
openProperties(item.id); openProperties(item.id);
}; }, [openDownloadFile, openProperties]);
const isQueueFilter = filter.startsWith('queue:'); const isQueueFilter = filter.startsWith('queue:');
const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => { const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => {
@@ -232,6 +234,36 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}) })
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig), : sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]); [filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
// Each row used to derive this by filtering and sorting the complete store
// independently. That made a 1000-entry playlist perform O(n^2 log n) work
// on every download update. Compute the same queue membership once and pass
// the resulting position to rows instead.
const queuePositionsByDownloadId = useMemo(() => {
const grouped = new Map<string | undefined, DownloadItem[]>();
for (const download of downloads) {
if (
download.status === 'completed'
|| (isActiveDownloadStatus(download.status) && download.status !== 'queued')
) {
continue;
}
const queueItems = grouped.get(download.queueId) || [];
queueItems.push(download);
grouped.set(download.queueId, queueItems);
}
const positions = new Map<string, { index: number; length: number }>();
for (const queueItems of grouped.values()) {
queueItems.sort((left, right) =>
(left.queuePosition ?? 0) - (right.queuePosition ?? 0)
);
queueItems.forEach((download, index) => {
positions.set(download.id, { index, length: queueItems.length });
});
}
return positions;
}, [downloads]);
sortedDownloadsRef.current = sortedDownloads; sortedDownloadsRef.current = sortedDownloads;
useEffect(() => { useEffect(() => {
@@ -246,27 +278,30 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
useEffect(() => { useEffect(() => {
setQueueSortConfig(null); setQueueSortConfig(null);
}, [filter, isQueueFilter]); }, [filter, isQueueFilter]);
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => { const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
if (e.detail === 2) { if (e.detail === 2) {
handleDownloadDoubleClick(item); handleDownloadDoubleClick(item);
return; return;
} }
if (e.shiftKey && lastSelectedId) { const currentSortedDownloads = sortedDownloadsRef.current;
const currentIndex = sortedDownloads.findIndex(d => d.id === item.id); const currentSelectedIds = selectedIdsRef.current;
const lastIndex = sortedDownloads.findIndex(d => d.id === lastSelectedId); const currentLastSelectedId = lastSelectedIdRef.current;
if (e.shiftKey && currentLastSelectedId) {
const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id);
const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId);
if (currentIndex !== -1 && lastIndex !== -1) { if (currentIndex !== -1 && lastIndex !== -1) {
const start = Math.min(currentIndex, lastIndex); const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex);
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(selectedIds) : new Set<string>(); const newSelected = (e.metaKey || e.ctrlKey) ? new Set(currentSelectedIds) : new Set<string>();
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
newSelected.add(sortedDownloads[i].id); newSelected.add(currentSortedDownloads[i].id);
} }
setSelectedIds(newSelected); setSelectedIds(newSelected);
} }
} else if (e.metaKey || e.ctrlKey) { } else if (e.metaKey || e.ctrlKey) {
const newSelected = new Set(selectedIds); const newSelected = new Set(currentSelectedIds);
if (newSelected.has(item.id)) { if (newSelected.has(item.id)) {
newSelected.delete(item.id); newSelected.delete(item.id);
} else { } else {
@@ -278,15 +313,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
setSelectedIds(new Set([item.id])); setSelectedIds(new Set([item.id]));
setLastSelectedId(item.id); setLastSelectedId(item.id);
} }
}; }, [handleDownloadDoubleClick]);
const handleContextMenu = (menu: { x: number; y: number; id: string }) => { const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
if (!selectedIds.has(menu.id)) { if (!selectedIdsRef.current.has(menu.id)) {
setSelectedIds(new Set([menu.id])); setSelectedIds(new Set([menu.id]));
setLastSelectedId(menu.id); setLastSelectedId(menu.id);
} }
setContextMenu(menu); setContextMenu(menu);
}; }, []);
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
const ids = selectedIdsRef.current.has(id)
? Array.from(selectedIdsRef.current)
: id;
void moveInQueue(ids, direction);
}, [moveInQueue]);
const handleSort = (column: DownloadSortColumn) => { const handleSort = (column: DownloadSortColumn) => {
const update = (current: DownloadSortConfig | null): DownloadSortConfig => const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
@@ -317,7 +359,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
} }
}; };
const handlePause = async (id: string, skipConfirm = false) => { const handlePause = useCallback(async (id: string, skipConfirm = false) => {
const download = useDownloadStore.getState().downloads.find(d => d.id === id); const download = useDownloadStore.getState().downloads.find(d => d.id === id);
if (!skipConfirm && download && download.resumable === false) { if (!skipConfirm && download && download.resumable === false) {
const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?"); const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?");
@@ -332,9 +374,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
console.error("Failed to pause:", e); console.error("Failed to pause:", e);
showInteractionError('Could not pause download', e); showInteractionError('Could not pause download', e);
} }
}; }, [showInteractionError]);
const handleResume = async (item: DownloadItem) => { const handleResume = useCallback(async (item: DownloadItem) => {
try { try {
const resumed = await useDownloadStore.getState().resumeDownload(item.id); const resumed = await useDownloadStore.getState().resumeDownload(item.id);
if (!resumed) { if (!resumed) {
@@ -344,7 +386,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
console.error("Failed to resume:", error); console.error("Failed to resume:", error);
showInteractionError(`Could not resume ${item.fileName}`, error); showInteractionError(`Could not resume ${item.fileName}`, error);
} }
}; }, [showInteractionError]);
const resumeItemsSequentially = async (items: DownloadItem[]) => { const resumeItemsSequentially = async (items: DownloadItem[]) => {
for (const item of items) { for (const item of items) {
@@ -408,7 +450,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}; };
const getCategoryIcon = (category: string) => { const getCategoryIcon = useCallback((category: string) => {
switch(category) { switch(category) {
case 'Musics': return <Music size={16} className="text-pink-400" />; case 'Musics': return <Music size={16} className="text-pink-400" />;
case 'Movies': return <Film size={16} className="text-red-400" />; case 'Movies': return <Film size={16} className="text-red-400" />;
@@ -419,7 +461,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
case 'Other': return <FileQuestion size={16} className="text-gray-400" />; case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
default: return <FileQuestion size={16} className="text-gray-400" />; default: return <FileQuestion size={16} className="text-gray-400" />;
} }
} }, []);
return ( return (
<div className="downloads-view flex-1 flex flex-col h-full min-w-0"> <div className="downloads-view flex-1 flex flex-col h-full min-w-0">
@@ -544,14 +586,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{sortedDownloads.map((d, index) => ( {sortedDownloads.map((d, index) => (
<DownloadItemComponent <DownloadItemComponent
key={d.id} key={d.id}
downloadId={d.id} download={d}
index={index} index={index}
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
tableGridTemplate={tableGridTemplate} tableGridTemplate={tableGridTemplate}
setContextMenu={handleContextMenu} setContextMenu={handleContextMenu}
handlePause={handlePause} handlePause={handlePause}
handleResume={handleResume} handleResume={handleResume}
getCategoryIcon={getCategoryIcon} getCategoryIcon={getCategoryIcon}
selectedIds={selectedIds} isSelected={selectedIds.has(d.id)}
onMoveInQueue={handleMoveInQueue}
onClick={handleItemClick} onClick={handleItemClick}
/> />
))} ))}