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.
This commit is contained in:
Sucukdeluxe
2026-08-14 22:24:00 +02:00
parent 3974853634
commit 9cf89b8493
10 changed files with 433 additions and 95 deletions
+9 -2
View File
@@ -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<string[] | null>(null);
const pendingPackageOrderAtRef = useRef(0);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0);
const [downloadSearch, setDownloadSearch] = useState("");
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("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<DownloadsViewModel>(() => ({
...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) {
+1 -1
View File
@@ -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"],
@@ -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<typeof formatHosterLabel>;
@@ -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<string>;
sessionRunning: boolean;
}
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, id, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
const [renderItems, setRenderItems] = useState(!collapsed);
const animationRef = useRef<Animation | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(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 (
<div
aria-hidden={collapsed}
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
id={id}
ref={containerRef}
>
<div className="downloads-package-items-inner" ref={innerRef}>
{items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
</div>
</div>
);
}
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 (
<span className="downloads-cell downloads-name-cell">
<button aria-controls={`downloads-package-items-${entry.id}`} aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
<button aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
{editing
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
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) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button"></button></span>
</div>
{renderItems ? <PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} id={`downloads-package-items-${entry.id}`} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} /> : null}
</article>
);
}
@@ -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) {
@@ -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 <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
@@ -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<HTMLDivElement>): 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<HTMLDivElement>(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<readonly DownloadLogicalRow[]>(desiredRows);
const transitionRowsRef = useRef<DownloadDisclosureRow[] | null>(null);
const [transitionRows, setTransitionRows] = useState<DownloadDisclosureRow[] | null>(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 ? (
<div className="downloads-virtual-spacer" style={spacerStyle}>
{virtualWindow.rows.map((entry) => (
<div className="downloads-virtual-row" data-download-virtual-index={entry.index} key={`${entry.id}:${entry.index}`} style={rowStyle(entry.top, entry.height)}>
<div className={`downloads-virtual-row${disclosureClassName(entry.source)}`} data-download-virtual-index={entry.index} key={`${entry.source.type}:${entry.id}`} style={rowStyle(entry.top, entry.height, disclosureOpacity(entry.source))}>
{renderVirtualRow(entry.source, model, actions)}
</div>
))}
@@ -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<string, DownloadDisclosureSourceRow[]> {
const grouped = new Map<string, DownloadDisclosureSourceRow[]>();
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<string, DownloadLogicalRow[]>();
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<string, DownloadDisclosureRow[]>();
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 };
});
}
@@ -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<DownloadItem>): numbe
return total;
}
function isPendingDownloadItem(item: DownloadItem): boolean {
return item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed";
}
export function getPendingDownloadItemCount(items: Iterable<DownloadItem>): 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<DownloadItem>): { 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<string, number>): number {
let total = 0;
for (const speed of Object.values(packageSpeeds)) {
@@ -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 {