diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e0d9246..fa0c438 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -94,7 +94,7 @@ import { StatisticsSidebarStatus, type StatisticsViewActions } from "./views/statistics/StatisticsView"; -import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; +import { buildDownloadsViewModel, formatRemainingDownloadBytes, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag"; import { @@ -1562,6 +1562,7 @@ export function App(): ReactElement { const pendingPackageOrderRef = useRef(null); const pendingPackageOrderAtRef = useRef(0); const [collapsedPackages, setCollapsedPackages] = useState>({}); + const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0); const [downloadSearch, setDownloadSearch] = useState(""); const [downloadDisplayMode, setDownloadDisplayMode] = useState("packages"); const [downloadFilter, setDownloadFilter] = useState("all"); @@ -3673,6 +3674,7 @@ export function App(): ReactElement { }, [showToast]); const onPackageToggleCollapse = useCallback((packageId: string): void => { + setDownloadDisclosureRevision((current) => current + 1); setCollapsedPackages((prev) => { const nextCollapsed = !(prev[packageId] ?? false); return { ...prev, [packageId]: nextCollapsed }; @@ -4571,6 +4573,7 @@ export function App(): ReactElement { speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps); }, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]); const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]); + const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]); const downloadsViewModel = useMemo(() => ({ ...downloadsViewCore, running: snapshot.session.running, @@ -4593,6 +4596,7 @@ export function App(): ReactElement { gridTemplate, sortColumn: downloadsSortColumn, sortDirection: downloadsSortDescending ? "desc" : "asc", + disclosureRevision: downloadDisclosureRevision, status: { packages: snapshot.stats.totalPackages, links: getPendingDownloadItemCount(Object.values(snapshot.session.items)), @@ -4600,11 +4604,13 @@ export function App(): ReactElement { sessionBytes: snapshot.stats.totalDownloaded, total: humanSize(downloadQueueTotalBytes), totalBytes: downloadQueueTotalBytes, + remaining: formatRemainingDownloadBytes(downloadRemaining), + remainingBytes: downloadRemaining.bytes, hosters: providerStats.length, speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", eta: snapshot.etaText } - }), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); + }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); const downloadsActions: DownloadsViewActions = { onDisplayModeChange: setDownloadDisplayMode, @@ -4647,6 +4653,7 @@ export function App(): ReactElement { onClearAll: clearDownloadQueue, onToggleAllPackages: () => { const targetState = !allPackagesCollapsed; + setDownloadDisclosureRevision((current) => current + 1); setCollapsedPackages((current) => { const next = { ...current }; for (const entry of packages) { diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 6dcbca9..52f7b4a 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -27,7 +27,7 @@ const pairs = [ ["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"], ["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"], ["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"], - ["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"], + ["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"], ["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."], ["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"], ["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"], diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 8c6853d..4918074 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react"; +import { memo, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react"; import type { DownloadItem } from "../../../shared/types"; import { compactProviderLabels, @@ -19,7 +19,6 @@ export type DownloadSortColumn = "name" | "size" | "hoster" | "progress"; const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px"; const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px"; const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter"; -const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; type HosterLabel = ReturnType; @@ -314,73 +313,6 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps) export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual); -interface PackageItemsTransitionProps { - actions: DownloadsTableActions; - collapsed: boolean; - columnOrder: readonly string[]; - gridTemplate: string; - id: string; - items: DownloadItem[]; - selectedIds: ReadonlySet; - sessionRunning: boolean; -} - -function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, id, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null { - const [renderItems, setRenderItems] = useState(!collapsed); - const animationRef = useRef(null); - const containerRef = useRef(null); - const innerRef = useRef(null); - const initialRenderRef = useRef(true); - - useRendererLayoutEffect(() => { - if (initialRenderRef.current) { - initialRenderRef.current = false; - return; - } - if (!renderItems) { - if (!collapsed) setRenderItems(true); - return; - } - const container = containerRef.current; - const inner = innerRef.current; - if (!container || !inner) return; - animationRef.current?.cancel(); - const targetHeight = inner.scrollHeight; - const animation = collapsed - ? container.animate([{ height: `${targetHeight}px`, opacity: 1 }, { height: "0px", opacity: 0 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" }) - : container.animate([{ height: "0px", opacity: 0 }, { height: `${targetHeight}px`, opacity: 1 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" }); - animationRef.current = animation; - animation.onfinish = () => { - if (animationRef.current !== animation) return; - animationRef.current = null; - if (collapsed) { - setRenderItems(false); - } else { - animation.cancel(); - } - }; - return () => { - animation.onfinish = null; - animation.cancel(); - if (animationRef.current === animation) animationRef.current = null; - }; - }, [collapsed, renderItems]); - - if (!renderItems) return null; - return ( -
-
- {items.map((item) => )} -
-
- ); -} - export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } { let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)); let failed = 0; @@ -429,7 +361,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n if (column === "name") { return ( - + {editing ? finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent) => { if (event.key === "Enter") { @@ -502,11 +434,10 @@ export interface PackageCardProps { sessionRunning?: boolean; columnOrder: readonly string[]; gridTemplate: string; - renderItems?: boolean; actions: DownloadsTableActions; } -export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, renderItems = true, actions }: PackageCardProps): ReactElement { +export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions }: PackageCardProps): ReactElement { const entry = row.package; let renameFinished = false; const finishRename = (value: string): void => { @@ -547,7 +478,6 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac {columnOrder.map((column) => {packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)})} - {renderItems ? : null} ); } @@ -556,7 +486,7 @@ export function arePackageCardPropsEqual(previous: PackageCardProps, next: Packa const a = previous.row.package; const b = next.row.package; if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false; - if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.renderItems !== next.renderItems || previous.actions !== next.actions) return false; + if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions) return false; if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) { if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false; for (const itemId of b.itemIds) { diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 4c7068e..930856a 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -19,6 +19,8 @@ export interface DownloadsStatusModel { sessionBytes: number; total: string; totalBytes: number; + remaining: string; + remainingBytes: number; hosters: number; speed: string; eta: string; @@ -43,9 +45,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore { editingName: string; columnOrder: readonly string[]; gridTemplate: string; - sortColumn?: DownloadSortColumn; - sortDirection?: "asc" | "desc"; - status: DownloadsStatusModel; + sortColumn?: DownloadSortColumn; + sortDirection?: "asc" | "desc"; + disclosureRevision: number; + status: DownloadsStatusModel; } export interface DownloadsViewActions extends DownloadsTableActions { @@ -106,6 +109,7 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }) { label: "Links", metric: "links", numericValue: model.status.links, value: integerFormatter.format(model.status.links) }, { label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session }, { label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total }, + { label: "Verbleibend", metric: "remaining", numericValue: model.status.remainingBytes, value: model.status.remaining }, { label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: integerFormatter.format(model.status.hosters) } ]; return
{entries.map((entry) =>
{entry.label}
)}
Geschwindigkeit{speed}
ETA{eta}
; diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx index 7048d40..747441e 100644 --- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -1,9 +1,18 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement } from "react"; import { buildDownloadLogicalRows, type DownloadLogicalRow } from "./downloads-model"; +import { + activateDownloadDisclosureTransition, + DOWNLOAD_DISCLOSURE_DURATION_MS, + mergeDownloadDisclosureRows, + prepareDownloadDisclosureTransition, + type DownloadDisclosureRow +} from "./download-disclosure-transition"; import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView"; import { ItemRow, PackageCard } from "./DownloadsTable"; import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, DOWNLOAD_VIRTUAL_OVERSCAN_ROWS } from "./download-virtualizer"; +const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + interface DownloadViewportState { scrollTop: number; viewportHeight: number; @@ -42,13 +51,22 @@ function useDownloadViewport(bodyRef: React.RefObject): Download return viewport; } -function rowStyle(top: number, height: number): CSSProperties { +function rowStyle(top: number, height: number, opacity: number): CSSProperties { return { "--downloads-virtual-row-top": `${top}px`, - "--downloads-virtual-row-height": `${height}px` + "--downloads-virtual-row-height": `${height}px`, + opacity } as CSSProperties; } +function disclosureClassName(row: DownloadLogicalRow | DownloadDisclosureRow): string { + return "disclosurePhase" in row ? ` is-${row.disclosurePhase}` : ""; +} + +function disclosureOpacity(row: DownloadLogicalRow | DownloadDisclosureRow): number { + return "disclosureOpacity" in row && typeof row.disclosureOpacity === "number" ? row.disclosureOpacity : 1; +} + function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement { if (row.type === "item") { return ( @@ -63,7 +81,6 @@ function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, ac editingName={model.editingName} gridTemplate={model.gridTemplate} packageSpeedBps={model.packageSpeedBps[row.packageId] ?? 0} - renderItems={false} row={row.packageRow} selectedIds={model.selectedIds} selectedVersion={model.actionableSelectedIds.length} @@ -75,13 +92,51 @@ function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, ac export function VirtualizedDownloadsBody({ actions, model, state }: { actions: DownloadsViewActions; model: DownloadsViewModel; state: ReactElement | null }): ReactElement { const bodyRef = useRef(null); const viewport = useDownloadViewport(bodyRef); - const logicalRows = useMemo(() => buildDownloadLogicalRows(model), [model]); - const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(logicalRows, { + const desiredRows = useMemo(() => buildDownloadLogicalRows(model), [model]); + const desiredRowsRef = useRef(desiredRows); + desiredRowsRef.current = desiredRows; + const previousRowsRef = useRef(desiredRows); + const transitionRowsRef = useRef(null); + const [transitionRows, setTransitionRows] = useState(null); + + useEffect(() => { + if (!transitionRowsRef.current) previousRowsRef.current = desiredRows; + }, [desiredRows]); + + useRendererLayoutEffect(() => { + const prepared = prepareDownloadDisclosureTransition(transitionRowsRef.current ?? previousRowsRef.current, desiredRowsRef.current); + if (!prepared.animated) { + transitionRowsRef.current = null; + previousRowsRef.current = desiredRowsRef.current; + setTransitionRows(null); + return; + } + transitionRowsRef.current = prepared.rows; + setTransitionRows(prepared.rows); + let settleTimer = 0; + const animationFrame = window.requestAnimationFrame(() => { + const active = activateDownloadDisclosureTransition(transitionRowsRef.current ?? prepared.rows); + transitionRowsRef.current = active; + setTransitionRows(active); + settleTimer = window.setTimeout(() => { + previousRowsRef.current = desiredRowsRef.current; + transitionRowsRef.current = null; + setTransitionRows(null); + }, DOWNLOAD_DISCLOSURE_DURATION_MS); + }); + return () => { + window.cancelAnimationFrame(animationFrame); + if (settleTimer) window.clearTimeout(settleTimer); + }; + }, [model.disclosureRevision, model.displayMode]); + + const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : desiredRows, [desiredRows, transitionRows]); + const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, { scrollTop: viewport.scrollTop, viewportHeight: viewport.viewportHeight, overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS, pinnedIds: [model.editingPackageId] - }), [logicalRows, model.editingPackageId, viewport.scrollTop, viewport.viewportHeight]); + }), [model.editingPackageId, renderedRows, viewport.scrollTop, viewport.viewportHeight]); const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties; return ( @@ -90,7 +145,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D {!state ? (
{virtualWindow.rows.map((entry) => ( -
+
{renderVirtualRow(entry.source, model, actions)}
))} diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts new file mode 100644 index 0000000..dfc107f --- /dev/null +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -0,0 +1,135 @@ +import type { DownloadLogicalRow } from "./downloads-model"; +import { DOWNLOAD_FILE_ROW_HEIGHT } from "./download-virtualizer"; + +export const DOWNLOAD_DISCLOSURE_DURATION_MS = 300; +export const DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS = 64; + +export type DownloadDisclosurePhase = "stable" | "entering" | "leaving"; + +export type DownloadDisclosureRow = DownloadLogicalRow & { + disclosureOpacity: number; + disclosurePhase: DownloadDisclosurePhase; +}; + +function stableRow(row: DownloadLogicalRow): DownloadDisclosureRow { + return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" }; +} + +type DownloadDisclosureSourceRow = DownloadLogicalRow | DownloadDisclosureRow; + +function disclosureOpacity(row: DownloadDisclosureSourceRow): number { + return "disclosureOpacity" in row ? row.disclosureOpacity : 1; +} + +export function stableDownloadDisclosureRows(rows: readonly DownloadLogicalRow[]): DownloadDisclosureRow[] { + return rows.map(stableRow); +} + +function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map { + const grouped = new Map(); + for (const row of rows) { + if (row.type !== "item") continue; + const entries = grouped.get(row.packageId) ?? []; + entries.push(row); + grouped.set(row.packageId, entries); + } + return grouped; +} + +export function prepareDownloadDisclosureTransition( + current: readonly DownloadDisclosureSourceRow[], + desired: readonly DownloadLogicalRow[] +): { animated: boolean; rows: DownloadDisclosureRow[] } { + if (!desired.some((row) => row.type === "package")) { + return { animated: false, rows: stableDownloadDisclosureRows(desired) }; + } + + const currentItems = itemRowsByPackage(current); + const currentPackages = new Map(current.filter((row) => row.type === "package").map((row) => [row.packageId, row])); + const desiredItems = new Map(); + for (const row of desired) { + if (row.type !== "item") continue; + const entries = desiredItems.get(row.packageId) ?? []; + entries.push(row); + desiredItems.set(row.packageId, entries); + } + + let animatedItemCount = 0; + for (const row of desired) { + if (row.type !== "package") continue; + const previousPackage = currentPackages.get(row.packageId); + if (previousPackage?.type !== "package" || previousPackage.packageRow.collapsed === row.packageRow.collapsed) continue; + animatedItemCount += Math.max(currentItems.get(row.packageId)?.length ?? 0, desiredItems.get(row.packageId)?.length ?? 0); + if (animatedItemCount > DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS) { + return { animated: false, rows: stableDownloadDisclosureRows(desired) }; + } + } + + const rows: DownloadDisclosureRow[] = []; + let animated = false; + for (const row of desired) { + if (row.type !== "package") continue; + rows.push(stableRow(row)); + const before = currentItems.get(row.packageId) ?? []; + const after = desiredItems.get(row.packageId) ?? []; + const previousPackage = currentPackages.get(row.packageId); + const disclosureChanged = previousPackage?.type === "package" && previousPackage.packageRow.collapsed !== row.packageRow.collapsed; + if (!disclosureChanged) { + rows.push(...after.map(stableRow)); + continue; + } + if (row.packageRow.collapsed && before.length > 0) { + animated = true; + rows.push(...before.map((entry) => ({ ...entry, disclosureOpacity: disclosureOpacity(entry), disclosurePhase: "leaving" as const }))); + continue; + } + const beforeById = new Map(before.map((entry) => [entry.id, entry])); + for (const entry of after) { + const previous = beforeById.get(entry.id); + if (!previous) { + animated = true; + rows.push({ ...entry, height: 0, disclosureOpacity: 0, disclosurePhase: "entering" }); + } else if (("disclosurePhase" in previous && previous.disclosurePhase === "leaving") || previous.height === 0) { + animated = true; + rows.push({ ...entry, height: previous.height, disclosureOpacity: disclosureOpacity(previous), disclosurePhase: "entering" }); + } else { + rows.push(stableRow(entry)); + } + } + } + return { animated, rows }; +} + +export function mergeDownloadDisclosureRows( + transition: readonly DownloadDisclosureRow[], + desired: readonly DownloadLogicalRow[] +): DownloadDisclosureRow[] { + const transitionById = new Map(transition.map((row) => [`${row.type}:${row.id}`, row])); + const desiredIds = new Set(desired.map((row) => `${row.type}:${row.id}`)); + const leavingByPackage = new Map(); + for (const row of transition) { + if (row.type !== "item" || row.disclosurePhase !== "leaving" || desiredIds.has(`item:${row.id}`)) continue; + const entries = leavingByPackage.get(row.packageId) ?? []; + entries.push(row); + leavingByPackage.set(row.packageId, entries); + } + + const rows: DownloadDisclosureRow[] = []; + for (const row of desired) { + const animated = transitionById.get(`${row.type}:${row.id}`); + rows.push(animated + ? { ...row, height: animated.height, disclosureOpacity: animated.disclosureOpacity, disclosurePhase: animated.disclosurePhase } + : stableRow(row)); + if (row.type === "package") rows.push(...(leavingByPackage.get(row.packageId) ?? [])); + } + return rows; +} + +export function activateDownloadDisclosureTransition(rows: readonly DownloadDisclosureRow[]): DownloadDisclosureRow[] { + return rows.map((row) => { + if (row.type !== "item" || row.disclosurePhase === "stable") return row; + return row.disclosurePhase === "entering" + ? { ...row, height: DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 } + : { ...row, height: 0, disclosureOpacity: 0 }; + }); +} diff --git a/src/renderer/views/downloads/downloads-model.ts b/src/renderer/views/downloads/downloads-model.ts index e4fe264..79ce77a 100644 --- a/src/renderer/views/downloads/downloads-model.ts +++ b/src/renderer/views/downloads/downloads-model.ts @@ -1,4 +1,5 @@ import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types"; +import { humanSize } from "../../download-format"; import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer"; export type DownloadDisplayMode = "packages" | "files"; @@ -89,14 +90,37 @@ export function getDownloadQueueTotalBytes(items: Iterable): numbe return total; } +function isPendingDownloadItem(item: DownloadItem): boolean { + return item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed"; +} + export function getPendingDownloadItemCount(items: Iterable): number { let count = 0; for (const item of items) { - if (item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed") count += 1; + if (isPendingDownloadItem(item)) count += 1; } return count; } +export function getRemainingDownloadBytes(items: Iterable): { bytes: number; unknownItems: number } { + let bytes = 0; + let unknownItems = 0; + for (const item of items) { + if (!isPendingDownloadItem(item)) continue; + if (!item.totalBytes || item.totalBytes <= 0) { + unknownItems += 1; + continue; + } + bytes += Math.max(0, item.totalBytes - Math.max(0, item.downloadedBytes)); + } + return { bytes, unknownItems }; +} + +export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string { + if (summary.unknownItems <= 0) return humanSize(summary.bytes); + return summary.bytes > 0 ? `≥ ${humanSize(summary.bytes)}` : "Unbekannt"; +} + export function getDownloadSpeedBps(packageSpeeds: Record): number { let total = 0; for (const speed of Object.values(packageSpeeds)) { diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 690dd8e..749f066 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -290,6 +290,7 @@ position: relative; height: var(--downloads-virtual-total-height); min-height: 0; + transition: height 300ms cubic-bezier(0.22, 1, 0.36, 1); } .downloads-virtual-row { @@ -297,6 +298,8 @@ inset: 0 0 auto 0; height: var(--downloads-virtual-row-height); transform: translateY(var(--downloads-virtual-row-top)); + overflow: hidden; + transition: height 300ms cubic-bezier(0.22, 1, 0.36, 1), transform 300ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms cubic-bezier(0.22, 1, 0.36, 1); } .downloads-virtual-row > .downloads-package-card { diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 240e797..8cdbbd7 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -9,13 +9,22 @@ import { buildDownloadSidebarCounts, buildDownloadsViewModel, classifyDownloadStatus, + formatRemainingDownloadBytes, getDownloadQueueTotalBytes, + getRemainingDownloadBytes, getPendingDownloadItemCount, getDownloadSpeedBps, buildDownloadLogicalRows, type DownloadSidebarFilter, type DownloadsModelInput } from "../src/renderer/views/downloads/downloads-model"; +import { + DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS, + activateDownloadDisclosureTransition, + mergeDownloadDisclosureRows, + prepareDownloadDisclosureTransition, + stableDownloadDisclosureRows +} from "../src/renderer/views/downloads/download-disclosure-transition"; import { DownloadsContent, DownloadsFooter, @@ -98,9 +107,10 @@ describe("rollende Downloadkennzahlen", () => { expect(getRollingMetricDirection(300, 300)).toBe("none"); }); - it("animates exactly the five stable sidebar metrics", () => { + it("animates exactly the six stable sidebar metrics including the remaining volume", () => { const html = renderToStaticMarkup(); - expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5); + expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(6); + expect(html).toContain("Verbleibend"); expect(html).toContain('data-status-metric="speed"'); expect(html).toContain('data-status-metric="eta"'); }); @@ -163,6 +173,171 @@ describe("Download-Gesamtgröße", () => { }); }); +describe("verbleibendes Downloadvolumen", () => { + it("summiert nur offene bekannte Restbytes und klemmt überladene Fortschritte bei null", () => { + const items = [ + item("partial", "remaining", "downloading", { downloadedBytes: 750_000_000, totalBytes: 2_000_000_000 }), + item("queued", "remaining", "queued", { downloadedBytes: 0, totalBytes: 3_000_000_000 }), + item("overshot", "remaining", "downloading", { downloadedBytes: 2_100_000_000, totalBytes: 2_000_000_000 }), + item("done", "remaining", "completed", { downloadedBytes: 9_000_000_000, totalBytes: 10_000_000_000 }), + item("failed", "remaining", "failed", { downloadedBytes: 0, totalBytes: 8_000_000_000 }), + item("cancelled", "remaining", "cancelled", { downloadedBytes: 0, totalBytes: 7_000_000_000 }) + ]; + + expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 4_250_000_000, unknownItems: 0 }); + }); + + it("meldet unbekannte Größen nur für noch offene Downloads", () => { + const items = [ + item("known", "remaining", "queued", { downloadedBytes: 250_000_000, totalBytes: 1_000_000_000 }), + item("unknown-open", "remaining", "queued", { downloadedBytes: 120_000_000, totalBytes: null }), + item("unknown-done", "remaining", "completed", { totalBytes: null }) + ]; + + expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 750_000_000, unknownItems: 1 }); + expect(formatRemainingDownloadBytes({ bytes: 750_000_000, unknownItems: 1 })).toBe("≥ 715.26 MB"); + expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 1 })).toBe("Unbekannt"); + expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 0 })).toBe("0 B"); + }); +}); + +describe("virtualisierte Paketanimation", () => { + const packageA = pkg("package-a", "A", ["a-1", "a-2"]); + const packageB = pkg("package-b", "B", ["b-1"]); + const items = { + "a-1": item("a-1", "package-a", "queued"), + "a-2": item("a-2", "package-a", "queued"), + "b-1": item("b-1", "package-b", "queued") + }; + const logicalRows = (collapsedPackageIds: string[]) => buildDownloadLogicalRows(buildDownloadsViewModel({ + packageOrder: [packageA.id, packageB.id], + packages: { [packageA.id]: packageA, [packageB.id]: packageB }, + items, + displayMode: "packages", + filter: "all", + providerFilter: "all", + query: "", + collapsedPackageIds, + selectedIds: [], + hideExtractedItems: false, + showAllPackages: true, + renderLimit: 100 + })); + + it("fährt neue Unterzeilen von null auf ihre feste Höhe aus und verschiebt Folgepakete unter stabilen IDs", () => { + const collapsed = logicalRows([packageA.id]); + const expanded = logicalRows([]); + const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(collapsed), expanded); + + expect(prepared.animated).toBe(true); + expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ + ["package-a", 40, "stable", 1], + ["a-1", 0, "entering", 0], + ["a-2", 0, "entering", 0], + ["package-b", 40, "stable", 1], + ["b-1", 38, "stable", 1] + ]); + expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([ + ["package-a", 40, 1], + ["a-1", 38, 1], + ["a-2", 38, 1], + ["package-b", 40, 1], + ["b-1", 38, 1] + ]); + }); + + it("behält ausfahrende Unterzeilen bis zum Animationsende und reduziert ihre Höhe auf null", () => { + const expanded = logicalRows([]); + const collapsed = logicalRows([packageA.id]); + const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(expanded), collapsed); + + expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ + ["package-a", 40, "stable", 1], + ["a-1", 38, "leaving", 1], + ["a-2", 38, "leaving", 1], + ["package-b", 40, "stable", 1], + ["b-1", 38, "stable", 1] + ]); + expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([ + ["package-a", 40, 1], + ["a-1", 0, 0], + ["a-2", 0, 0], + ["package-b", 40, 1], + ["b-1", 38, 1] + ]); + expect(stableDownloadDisclosureRows(collapsed).map((row) => row.id)).toEqual(["package-a", "package-b", "b-1"]); + }); + + it("überspringt die Bewegung bei riesigen Einzelpaketen und hält das DOM-Fenster begrenzt", () => { + const count = DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS + 10_000; + const hugePackage = pkg("huge-package", "Groß", Array.from({ length: count }, (_, index) => `huge-${index}`)); + const hugeItems = Object.fromEntries(hugePackage.itemIds.map((id) => [id, item(id, hugePackage.id, "queued")])); + const buildRows = (collapsed: boolean) => buildDownloadLogicalRows(buildDownloadsViewModel({ + packageOrder: [hugePackage.id], + packages: { [hugePackage.id]: hugePackage }, + items: hugeItems, + displayMode: "packages", + filter: "all", + providerFilter: "all", + query: "", + collapsedPackageIds: collapsed ? [hugePackage.id] : [], + selectedIds: [], + hideExtractedItems: false, + showAllPackages: true, + renderLimit: count + 1 + })); + const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(true)), buildRows(false)); + const window = calculateDownloadVirtualWindow(prepared.rows, { scrollTop: 0, viewportHeight: 720, overscan: 8 }); + + expect(prepared.animated).toBe(false); + expect(window.rows.length).toBeLessThan(40); + }); + + it("begrenzt gleichzeitig animierte Unterzeilen über alle Pakete hinweg", () => { + const manyItems = Object.fromEntries(Array.from({ length: 80 }, (_, index) => { + const packageId = index < 40 ? "many-a" : "many-b"; + const id = `${packageId}-${index}`; + return [id, item(id, packageId, "queued")]; + })); + const manyPackages = { + "many-a": pkg("many-a", "Viele A", Object.keys(manyItems).filter((id) => id.startsWith("many-a"))), + "many-b": pkg("many-b", "Viele B", Object.keys(manyItems).filter((id) => id.startsWith("many-b"))) + }; + const buildRows = (collapsedPackageIds: string[]) => buildDownloadLogicalRows(buildDownloadsViewModel({ + packageOrder: ["many-a", "many-b"], + packages: manyPackages, + items: manyItems, + displayMode: "packages", + filter: "all", + providerFilter: "all", + query: "", + collapsedPackageIds, + selectedIds: [], + hideExtractedItems: false, + showAllPackages: true, + renderLimit: 100 + })); + + const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(["many-a", "many-b"])), buildRows([])); + expect(prepared.animated).toBe(false); + }); + + it("übernimmt Laufzeitupdates während der Bewegung und kehrt schnelle Gegenklicks um", () => { + const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows); + const updated = logicalRows([]).map((row) => row.type === "item" && row.id === "a-1" ? { ...row, item: { ...row.item, downloadedBytes: 987_654_321 } } : row); + const merged = mergeDownloadDisclosureRows(entering, updated); + const updatedItem = merged.find((row) => row.type === "item" && row.id === "a-1"); + expect(updatedItem?.type === "item" ? updatedItem.item.downloadedBytes : 0).toBe(987_654_321); + expect(updatedItem).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 38, disclosureOpacity: 1 })); + + const collapsed = logicalRows([packageA.id]); + const leaving = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([])), collapsed).rows); + const reversed = prepareDownloadDisclosureTransition(leaving, logicalRows([])); + expect(reversed.animated).toBe(true); + expect(reversed.rows.filter((row) => row.packageId === packageA.id && row.type === "item").every((row) => row.disclosurePhase === "entering")).toBe(true); + }); +}); + describe("laufender Queue-Linkzähler", () => { it("sinkt sofort, sobald eine Unterdatei abgeschlossen ist", () => { const items = [ @@ -360,6 +535,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record { expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s); expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*block;/s); expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*block;/s); - expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/); + expect(css).toMatch(/\.downloads-virtual-spacer\s*\{[^}]*transition:\s*height 300ms cubic-bezier\(0\.22, 1, 0\.36, 1\);/s); + expect(css).toMatch(/\.downloads-virtual-row\s*\{[^}]*transition:\s*height 300ms[^;]*transform 300ms[^;]*opacity 300ms/s); expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s); expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s); expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s); @@ -821,7 +1000,7 @@ describe("downloads view", () => { expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s); expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s); expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s); - expect(source.match(/duration:\s*300/g)).toHaveLength(2); + expect(source).not.toContain("PackageItemsTransition"); }); it("keeps the action column visible at 1366px and 1120px through the production wrapper contract", () => { @@ -1277,7 +1456,7 @@ describe("download table row contracts", () => { expect(calls).toEqual(["select", "prevent", "collapse", "collapse"]); expect(collapseButton.props["aria-expanded"]).toBe(true); - expect(collapseButton.props["aria-controls"]).toBe(`downloads-package-items-${row.package.id}`); + expect(collapseButton.props["aria-controls"]).toBeUndefined(); }); it("does not collapse a package when a row double click starts on interactive content", () => { diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 5fc1239..506d3c2 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -148,7 +148,8 @@ describe("renderer localization", () => { ["3 ausgewählt", "3 selected"], ["vor 4 Std", "4 hr ago"], ["Zeitregel 4", "Schedule rule 4"], - ["1.5 GB von 10 GB übrig", "1.5 GB of 10 GB remaining"] + ["1.5 GB von 10 GB übrig", "1.5 GB of 10 GB remaining"], + ["Verbleibend", "Remaining"] ])("translates composed renderer text %s without changing its payload", (german, english) => { expect(translateUiText(german, "en")).toBe(english); expect(translateUiText(english, "de")).toBe(german);