Disable row and height animations when removing downloads

This commit is contained in:
Sucukdeluxe
2026-09-04 22:13:06 +02:00
parent 3436fcdd24
commit 36611647ab
9 changed files with 49 additions and 6 deletions
+1
View File
@@ -10,6 +10,7 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
### Fixed
- Remove queue entries and reposition the remaining rows immediately, without row or table-height animations, for both whole-package and archive-set cleanup.
- Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions.
- Keep the chosen package order stable while live snapshots and reorder confirmations arrive, preventing a brief jump back to the previous order when reversing a sort. Ignore failures from superseded reorder requests.
+4
View File
@@ -235,6 +235,10 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
### Unveröffentlichte Offline-Paketbereinigung vom 4. September 2026
- Entfernen ohne Animation: Die Downloadansicht erkennt entfernte IDs im tatsächlichen Queue-Itembestand. CSS-Zeilen-/Höhentransitionen und laufende Reihenfolgeanimationen werden für diesen Render ausgesetzt; eventuell laufende Aufklappübergänge werden beendet. Das gilt auch für einzelne Archivsätze, deren Oberpaket erhalten bleibt. Normales Auf-/Zuklappen wird durch die bloße Sichtbarkeit einzelner Zeilen nicht als Löschung behandelt.
- Verifiziert: 189 fokussierte Tests erfolgreich und TypeScript fehlerfrei. Browserprobe mit aktivierten Animationen unter `?motion=on&offline-cleanup=multipart&check-removal-motion` meldet für Paketmodus 12 Prüfungen/0 Bewegungen, für Archivsatzmodus 10 Prüfungen/0 Bewegungen; Folge 4 bleibt beim Entfernen von Folge 3 erhalten. Änderung per Hot-Reload geladen.
- Release-Status: Sascha hat die Vorbereitung von v2.0.88 unterbrochen, um zuerst das animationslose Entfernen umzusetzen. Version bleibt 2.0.87; kein Release veröffentlicht. Release bleibt bis zur Fortsetzung pausiert.
- Aktuelle Bedienvorgabe: „Ganze Pakete“ steht im Bestätigungsdialog oben und wird bei jedem Öffnen vorausgewählt. „Nur betroffene Archivsätze“ folgt darunter. Beide Modi bleiben wählbar; der gewählte Scope wird ausdrücklich an das Backend übergeben. Browserprüfung bestätigt Reihenfolge `package`, `archive` und ausschließlich `package` als vorausgewählt; TypeScript fehlerfrei. Änderung per Hot-Reload in der Dev-App geladen.
- Erweiterung nach Saschas Rückmeldung: Die Bestätigung enthält jetzt zwei Radiooptionen. Standard bei jedem Öffnen ist „Nur betroffene Archivsätze“; alternativ „Ganze Pakete“. Im Archivsatz-Modus entfernt ein Offline-Part alle zusammengehörigen Parts derselben Folge, einschließlich bereits abgeschlossener Queue-Einträge; andere Folgen bleiben im Oberpaket. Leere Oberpakete verschwinden automatisch. Dateien bleiben in beiden Modi erhalten. Der Scope wird separat vom automatischen Offline-Überspringen übergeben und verändert dessen Einstellung nicht.
+1
View File
@@ -5426,6 +5426,7 @@ export function App(): ReactElement {
sortColumn: downloadsSortColumn,
sortDirection: downloadsSortDescending ? "desc" : "asc",
packageOrderSortRevision: downloadsSortRevision,
queueItems: snapshot.session.items,
disclosureRevision: downloadDisclosureRevision,
animationsEnabled: snapshot.settings.animatePackageDisclosure,
status: {
@@ -53,6 +53,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
packageOrderSortRevision?: number;
queueItems?: Readonly<Record<string, unknown>>;
disclosureRevision: number;
animationsEnabled: boolean;
status: DownloadsStatusModel;
@@ -17,6 +17,7 @@ import {
captureDownloadOrderRowTops,
getDownloadOrderTransitionPinnedIds,
shouldAnimateDownloadOrderChange,
hasRemovedDownloadItems,
getDownloadPackageOrder,
isDownloadPackageOrderChange
} from "./download-order-transition";
@@ -140,8 +141,10 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
const packageOrderChanged = isDownloadPackageOrderChange(previousPackageOrderRef.current, packageOrder);
const packageOrderSortRevision = model.packageOrderSortRevision ?? 0;
const appliedSortRevisionRef = useRef(packageOrderSortRevision);
const previousQueueItemsRef = useRef(model.queueItems);
const itemsRemoved = hasRemovedDownloadItems(previousQueueItemsRef.current, model.queueItems);
const orderAnimationsEnabled = shouldAnimateDownloadOrderChange({
animationsEnabled: model.animationsEnabled,
animationsEnabled: model.animationsEnabled && !itemsRemoved,
sortRevision: packageOrderSortRevision,
appliedSortRevision: appliedSortRevisionRef.current
});
@@ -161,7 +164,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
const prepared = prepareDownloadDisclosureTransition(
transitionRowsRef.current ?? previousRowsRef.current,
desiredRowsRef.current,
model.animationsEnabled
model.animationsEnabled && !itemsRemoved
);
if (!prepared.animated) {
transitionRowsRef.current = null;
@@ -193,9 +196,9 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
cancelActivation();
if (settleTimer) window.clearTimeout(settleTimer);
};
}, [model.animationsEnabled, model.disclosureRevision, model.displayMode]);
}, [itemsRemoved, model.animationsEnabled, model.disclosureRevision, model.displayMode]);
const renderedRows = useMemo<DownloadDisclosureRow[]>(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]);
const renderedRows = useMemo<DownloadDisclosureRow[]>(() => transitionRows && !itemsRemoved ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, itemsRemoved, transitionRows]);
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
scrollTop: viewport.scrollTop,
viewportHeight: viewport.viewportHeight,
@@ -207,6 +210,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
previousPackageOrderRef.current = packageOrder;
previousVisibleIdsRef.current = virtualWindow.rows.map((entry) => entry.id);
appliedSortRevisionRef.current = packageOrderSortRevision;
previousQueueItemsRef.current = model.queueItems;
const body = bodyRef.current;
if (!body) return;
if (!orderAnimationsEnabled) {
@@ -237,7 +241,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
orderTransitionTimerRef.current = 0;
setOrderTransitionRevision((revision) => revision + 1);
}, DOWNLOAD_ORDER_TRANSITION_DURATION_MS);
}, [orderAnimationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, packageOrderSortRevision, virtualWindow.rows]);
}, [model.queueItems, orderAnimationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, packageOrderSortRevision, virtualWindow.rows]);
useEffect(() => () => {
if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current);
@@ -24,6 +24,10 @@ export function shouldAnimateDownloadOrderChange(input: { animationsEnabled: boo
return input.animationsEnabled && input.sortRevision === input.appliedSortRevision;
}
export function hasRemovedDownloadItems(previous: Readonly<Record<string, unknown>> | undefined, current: Readonly<Record<string, unknown>> | undefined): boolean {
return Boolean(previous && current && previous !== current && Object.keys(previous).some((id) => !Object.hasOwn(current, id)));
}
export function getDownloadOrderTransitionPinnedIds(input: {
enabled: boolean;
previousOrder: readonly string[];
+10 -1
View File
@@ -38,7 +38,8 @@ import {
getDownloadOrderTransitionPinnedIds,
getDownloadOrderTransformKeyframes,
isDownloadPackageOrderChange,
shouldAnimateDownloadOrderChange
shouldAnimateDownloadOrderChange,
hasRemovedDownloadItems
} from "../src/renderer/views/downloads/download-order-transition";
import {
DownloadsContent,
@@ -386,6 +387,14 @@ describe("virtualisierte Paketanimation", () => {
expect(shouldAnimateDownloadOrderChange({ animationsEnabled: false, sortRevision: 3, appliedSortRevision: 3 })).toBe(false);
});
it("detects removed queue entries even when another entry is added in the same update", () => {
expect(hasRemovedDownloadItems({ first: {}, second: {} }, { second: {} })).toBe(true);
expect(hasRemovedDownloadItems({ first: {}, second: {} }, { second: {}, third: {} })).toBe(true);
expect(hasRemovedDownloadItems({ first: {} }, {})).toBe(true);
expect(hasRemovedDownloadItems({ first: {} }, { first: { status: "completed" }, second: {} })).toBe(false);
expect(hasRemovedDownloadItems(undefined, { first: {} })).toBe(false);
});
it("pins only previously visible rows for a real priority reorder", () => {
expect(DOWNLOAD_ORDER_TRANSITION_DURATION_MS).toBe(3000);
expect(isDownloadPackageOrderChange(["a", "b", "c"], ["b", "c", "a"])).toBe(true);
+1
View File
@@ -205,5 +205,6 @@ export async function startVisualHarness(
}
if (typeof window !== "undefined" && typeof document !== "undefined") {
if (new URLSearchParams(window.location.search).has("check-removal-motion")) void import("./removal-motion-probe");
startVisualHarness();
}
+18
View File
@@ -0,0 +1,18 @@
let checks = 0;
let movingChecks = 0;
const observer = new MutationObserver(() => {
const body = document.querySelector(".downloads-table-body");
if (!body) return;
checks++;
const moving = [...body.querySelectorAll<HTMLElement>(".downloads-virtual-row")].some((row) => {
const target = Number.parseFloat(row.style.getPropertyValue("--downloads-virtual-row-top"));
return Math.abs(new DOMMatrixReadOnly(getComputedStyle(row).transform).m42 - target) > 0.5;
});
const spacer = body.querySelector<HTMLElement>(".downloads-virtual-spacer");
const resizing = spacer && Math.abs(spacer.getBoundingClientRect().height - Number.parseFloat(spacer.style.getPropertyValue("--downloads-virtual-total-height"))) > 0.5;
if (moving || resizing) movingChecks++;
document.documentElement.dataset.removalMotion = JSON.stringify({ checks, movingChecks });
});
observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["style", "class"] });
export {};