import React from 'react'; import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react'; import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem'; import { canPauseDownload, canStartDownload, formatDownloadActionCount, type DownloadActionCounts, } from '../utils/downloadActions'; import { useTranslation } from 'react-i18next'; import { useSettingsStore } from '../store/useSettingsStore'; import { isAllocationPhaseVisible } from '../utils/downloads'; import { formatDateTime } from '../utils/dateTime'; import { downloadProgressColorClass, formatTorrentDuration, formatDownloadTotal, resolveDownloadSizeDisplay, resolveDownloadFraction } from '../utils/downloadProgress'; import { isTorrentWaitingForPeers } from '../utils/torrentPresentation'; import { COLUMN_ALIGNMENT_JUSTIFY, getDownloadActionPosition, getColumnGridColumn, type DownloadColumnAlignment, type DownloadTableColumnKey } from '../utils/downloadTableColumns'; interface DownloadItemProps { download: DownloadItemType; allocationPending: boolean; queueIndex: number; columnOrder: DownloadTableColumnKey[]; columnAlignments: Record; tableGridTemplate: string; tableMinWidth: number | string; setContextMenu: (menu: { x: number; y: number; id: string }) => void; handlePause: (id: string, skipConfirm?: boolean) => void; handleResume: (item: DownloadItemType) => void; handlePauseSelected: () => void; handleResumeSelected: () => void; getCategoryIcon: (category: string) => React.ReactNode; isSelected: boolean; selectedDownloadCount: number; selectedActionCounts: DownloadActionCounts; isQueueReorderable: boolean; isQueueDragSource: boolean; onMoveInQueue: (id: string, direction: 'up' | 'down') => void; onQueueDragStart: (id: string, event: React.PointerEvent) => void; onClick: (e: React.MouseEvent, item: DownloadItemType) => void; } export const DownloadItem = React.memo(({ download, allocationPending, queueIndex, columnOrder, columnAlignments, tableGridTemplate, tableMinWidth, setContextMenu, handlePause, handleResume, handlePauseSelected, handleResumeSelected, getCategoryIcon, isSelected, selectedDownloadCount, selectedActionCounts, isQueueReorderable, isQueueDragSource, onMoveInQueue, onQueueDragStart, onClick, }) => { const { t, i18n } = useTranslation(); const calendarPreference = useSettingsStore(state => state.calendarPreference); const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]); const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]); const rowRef = React.useRef(null); const [isRowHovered, setIsRowHovered] = React.useState(false); const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false); const [isActionHovered, setIsActionHovered] = React.useState(false); const [isActionFocused, setIsActionFocused] = React.useState(false); const [actionPosition, setActionPosition] = React.useState(); const waitingForPeers = isTorrentWaitingForPeers({ isTorrent: download.isTorrent, status: download.status, downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes, fraction: liveProgress?.fraction ?? download.fraction, connectedPeers: liveProgress?.active_connections, connectedSeeders: liveProgress?.num_seeders, }); const allocationVisible = download.isTorrent !== true && isAllocationPhaseVisible(allocationPending, download.status); const hasRowActions = download.status !== 'completed'; const isBulkSelection = isSelected && selectedDownloadCount > 1; const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0 ? selectedActionCounts.pause : null; const resumeSelectionCount = isBulkSelection && selectedActionCounts.resume > 0 ? selectedActionCounts.resume : null; const canResumeAction = isBulkSelection ? selectedActionCounts.resume > 0 : canStartDownload(download.status); const canPauseAction = isBulkSelection ? selectedActionCounts.pause > 0 : canPauseDownload(download.status); const selectedCountLabel = (count: number | null) => count === null ? null : t($ => $.downloadTable.summary.selected, { count }); const isActionVisible = hasRowActions && ( isRowHovered || isActionHovered || isRowKeyboardFocused || isActionFocused ); const mediaQualityLabel = (() => { if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined; const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim(); return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined; })(); const dateAddedLabel = download.dateAdded ? formatDateTime(download.dateAdded, { locale: i18n.language, calendar: calendarPreference }) : '-'; const updateActionPosition = React.useCallback(() => { const row = rowRef.current; const view = row?.closest('.downloads-view'); if (!row || !view) return; const horizontalViewport = row.closest('.download-table-scroll') ?? view; const verticalViewport = row.closest('.download-table-list') ?? view; const rowRect = row.getBoundingClientRect(); const horizontalViewportRect = horizontalViewport.getBoundingClientRect(); const verticalViewportRect = verticalViewport.getBoundingClientRect(); const rowPadding = Number.parseFloat(getComputedStyle(row).getPropertyValue('--download-row-padding-x')); const nextPosition = getDownloadActionPosition( rowRect, horizontalViewportRect, verticalViewportRect, window.innerWidth, Number.isFinite(rowPadding) ? rowPadding : undefined ); setActionPosition(previous => ( previous?.top === nextPosition.top && previous?.right === nextPosition.right && previous?.height === nextPosition.height && previous?.overflow === nextPosition.overflow && previous?.visibility === nextPosition.visibility ? previous : nextPosition )); }, []); React.useLayoutEffect(() => { if (!isActionVisible) return; let frame: number | null = null; const schedulePositionUpdate = () => { if (frame !== null) window.cancelAnimationFrame(frame); frame = window.requestAnimationFrame(() => { frame = null; updateActionPosition(); }); }; const row = rowRef.current; const view = row?.closest('.downloads-view'); const horizontalViewport = row?.closest('.download-table-scroll'); const verticalViewport = row?.closest('.download-table-list'); const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedulePositionUpdate); updateActionPosition(); window.addEventListener('resize', schedulePositionUpdate); window.addEventListener('scroll', schedulePositionUpdate, true); [row, view, horizontalViewport, verticalViewport].forEach(element => { if (element) resizeObserver?.observe(element); }); return () => { window.removeEventListener('resize', schedulePositionUpdate); window.removeEventListener('scroll', schedulePositionUpdate, true); resizeObserver?.disconnect(); if (frame !== null) window.cancelAnimationFrame(frame); }; }, [isActionVisible, updateActionPosition]); const progressFraction = download.status === 'moving' ? moveProgress ?? download.fraction : download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding' ? liveProgress?.fraction ?? download.fraction : download.fraction; const displayFraction = download.status === 'moving' && moveProgress !== undefined ? Math.max(0, Math.min(1, moveProgress)) : download.status === 'moving' ? resolveDownloadFraction({ fraction: progressFraction, status: download.status }) : resolveDownloadFraction({ fraction: progressFraction, downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes, totalBytes: liveProgress?.total_bytes ?? download.totalBytes, totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate, isMedia: download.isMedia, size: download.size, status: download.status, }); const displayPercent = `${(displayFraction * 100).toFixed(0)}%`; const displaySpeed = allocationVisible ? '-' : download.status === 'seeding' ? liveProgress?.upload_speed ?? '-' : download.status === 'downloading' || download.status === 'verifying' ? liveProgress?.speed ?? download.speed : download.status === 'processing' ? t($ => $.downloads.values.processing) : '-'; const displayEta = allocationVisible ? '-' : download.status === 'seeding' ? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0 ? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language) : '-' : download.status === 'downloading' || download.status === 'verifying' ? liveProgress?.eta ?? download.eta : download.status === 'processing' ? t($ => $.downloads.values.muxing) : '-'; const sizeDisplay = resolveDownloadSizeDisplay({ downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes, totalBytes: liveProgress?.total_bytes ?? download.totalBytes, totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate, fallbackSize: download.size }); const hasDownloadedAmount = download.status !== 'completed' && Boolean(sizeDisplay.downloaded && sizeDisplay.total); const completedSizeLabel = (() => { const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback; return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value; })(); const downloadStatusLabel = allocationVisible ? t($ => $.downloads.status.allocatingFiles) : waitingForPeers ? t($ => $.downloads.status.waitingForPeers) : t($ => $.downloads.status[download.status]); const visibleErrorStatusLabel = download.credentialsRequired === true ? t($ => $.properties.credentialsRequired) : download.lastErrorKind === 'nameResolution' ? download.status === 'retrying' && download.lastResolverFallback === true ? t($ => $.downloads.errors.nameResolutionRetrying) : download.status === 'failed' ? t($ => $.downloads.errors.nameResolutionFailed) : downloadStatusLabel : downloadStatusLabel; const downloadedSizeLabel = sizeDisplay.totalIsEstimate ? t($ => $.downloads.size.downloadedOfApproximate, { downloaded: sizeDisplay.downloaded ?? '', total: sizeDisplay.total ?? '', unit: sizeDisplay.unit ?? '', }) : t($ => $.downloads.size.downloadedOf, { downloaded: sizeDisplay.downloaded ?? '', total: sizeDisplay.total ?? '', unit: sizeDisplay.unit ?? '', }); const columnStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({ '--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]], gridColumn: getColumnGridColumn(key, columnOrder), } as React.CSSProperties); const cells: Record = { 'File Name': (
{getCategoryIcon(download.category)} {download.fileName} {mediaQualityLabel ? ( $.addDownloads.quality)}> {mediaQualityLabel} ) : null} {download.isTorrent ? ( $.addDownloads.torrent)}> {t($ => $.addDownloads.torrent)} ) : null}
), Size: (
{hasDownloadedAmount ? ( {sizeDisplay.downloaded} / ) : null} {hasDownloadedAmount ? `${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}` : completedSizeLabel}
), Status: (
{download.status === 'completed' ? (
{downloadStatusLabel}
) : (
{allocationVisible ? ( <>
)}
), Speed: (
{displaySpeed}
), ETA: (
{displayEta}
), 'Date Added': (
{dateAddedLabel}
), }; const rowActions = hasRowActions ? ( ) : null; return (
{ setIsRowHovered(true); if (hasRowActions) updateActionPosition(); }} onMouseLeave={event => { const nextTarget = event.relatedTarget; if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { setIsRowHovered(false); } }} onFocus={() => { const target = document.activeElement; const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible'); setIsRowKeyboardFocused(keyboardFocused); if (hasRowActions && keyboardFocused) updateActionPosition(); }} onBlur={event => { const nextTarget = event.relatedTarget; if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { setIsRowKeyboardFocused(false); } }} onPointerDown={event => { // Modifier clicks belong to selection. Starting a row drag first can // capture the pointer and suppress the click that applies Cmd/Ctrl or // Shift selection. if ( isQueueReorderable && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey ) { onQueueDragStart(download.id, event); } }} onClick={(e) => onClick(e, download)} onKeyDown={event => { if ( isQueueReorderable && event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown') ) { event.preventDefault(); event.stopPropagation(); onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down'); } }} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, id: download.id }); }} >
{columnOrder.map(columnKey => ( {cells[columnKey]} ))}
{rowActions}
); });