From 36611647ab3d328fc68666b8a53bd6b3d2004851 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 4 Sep 2026 22:13:06 +0200 Subject: [PATCH] Disable row and height animations when removing downloads --- CHANGELOG.md | 1 + PROJECT_MEMORY.md | 4 ++++ src/renderer/App.tsx | 1 + src/renderer/views/downloads/DownloadsView.tsx | 1 + .../downloads/VirtualizedDownloadsBody.tsx | 14 +++++++++----- .../downloads/download-order-transition.ts | 4 ++++ tests/downloads-view.test.tsx | 11 ++++++++++- tests/visual/main.tsx | 1 + tests/visual/removal-motion-probe.ts | 18 ++++++++++++++++++ 9 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/visual/removal-motion-probe.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e76f5f..edad81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 5d0318d..dcdb83b 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -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. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d4f6777..7d673b4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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: { diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index d57f748..7f59232 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -53,6 +53,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore { sortColumn?: DownloadSortColumn; sortDirection?: "asc" | "desc"; packageOrderSortRevision?: number; + queueItems?: Readonly>; disclosureRevision: number; animationsEnabled: boolean; status: DownloadsStatusModel; diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx index 42665cd..f10efd1 100644 --- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -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(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]); + const renderedRows = useMemo(() => 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); diff --git a/src/renderer/views/downloads/download-order-transition.ts b/src/renderer/views/downloads/download-order-transition.ts index 189a548..00162b6 100644 --- a/src/renderer/views/downloads/download-order-transition.ts +++ b/src/renderer/views/downloads/download-order-transition.ts @@ -24,6 +24,10 @@ export function shouldAnimateDownloadOrderChange(input: { animationsEnabled: boo return input.animationsEnabled && input.sortRevision === input.appliedSortRevision; } +export function hasRemovedDownloadItems(previous: Readonly> | undefined, current: Readonly> | 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[]; diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 6bd47d1..541a18d 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -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); diff --git a/tests/visual/main.tsx b/tests/visual/main.tsx index fd9d671..e1cbd34 100644 --- a/tests/visual/main.tsx +++ b/tests/visual/main.tsx @@ -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(); } diff --git a/tests/visual/removal-motion-probe.ts b/tests/visual/removal-motion-probe.ts new file mode 100644 index 0000000..9674f2a --- /dev/null +++ b/tests/visual/removal-motion-probe.ts @@ -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(".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(".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 {};