From 9cf89b8493b55325f172b7e19bbf0baba8cfc87f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 14 Aug 2026 22:24:00 +0200 Subject: [PATCH] feat(downloads): restore package disclosure motion and remaining size Restore smooth 300 ms expand and collapse transitions without removing row virtualization. Limit animated disclosure rows globally, preserve live item updates during motion, support rapid reversal, and keep large queues bounded. Add a Remaining sidebar metric that sums only unfinished known bytes while clearly marking unknown open sizes. Cover multi-package limits, live transition merges, honest remaining-size semantics, translations, and accessibility. --- src/renderer/App.tsx | 11 +- src/renderer/i18n.ts | 2 +- .../views/downloads/DownloadsTable.tsx | 78 +------- .../views/downloads/DownloadsView.tsx | 10 +- .../downloads/VirtualizedDownloadsBody.tsx | 71 ++++++- .../download-disclosure-transition.ts | 135 +++++++++++++ .../views/downloads/downloads-model.ts | 26 ++- src/renderer/views/downloads/downloads.css | 3 + tests/downloads-view.test.tsx | 189 +++++++++++++++++- tests/i18n.test.ts | 3 +- 10 files changed, 433 insertions(+), 95 deletions(-) create mode 100644 src/renderer/views/downloads/download-disclosure-transition.ts 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);