diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 2fa849d..ef07281 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -70,11 +70,17 @@ export class AppController { } public updateSettings(partial: Partial): AppSettings { - this.settings = normalizeSettings({ + const nextSettings = normalizeSettings({ ...defaultSettings(), ...this.settings, ...partial }); + + if (JSON.stringify(nextSettings) === JSON.stringify(this.settings)) { + return this.settings; + } + + this.settings = nextSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); return this.settings; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a85013a..bc613b4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { DragEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types"; type Tab = "collector" | "downloads" | "settings"; @@ -66,6 +66,8 @@ export function App(): ReactElement { const [tab, setTab] = useState("collector"); const [statusToast, setStatusToast] = useState(""); const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); + const [settingsDirty, setSettingsDirty] = useState(false); + const settingsDirtyRef = useRef(false); const latestStateRef = useRef(null); const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); @@ -81,7 +83,9 @@ export function App(): ReactElement { const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); const [actionBusy, setActionBusy] = useState(false); + const actionBusyRef = useRef(false); const dragOverRef = useRef(false); + const dragDepthRef = useRef(0); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -89,6 +93,10 @@ export function App(): ReactElement { activeCollectorTabRef.current = activeCollectorTab; }, [activeCollectorTab]); + useEffect(() => { + settingsDirtyRef.current = settingsDirty; + }, [settingsDirty]); + const showToast = (message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } @@ -104,6 +112,7 @@ export function App(): ReactElement { void window.rd.getSnapshot().then((state) => { setSnapshot(state); setSettingsDraft(state.settings); + setSettingsDirty(false); applyTheme(state.settings.theme); if (state.settings.autoUpdateCheck) { void window.rd.checkUpdates().then((result) => { @@ -119,7 +128,11 @@ export function App(): ReactElement { stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = null; if (latestStateRef.current) { - setSnapshot(latestStateRef.current); + const next = latestStateRef.current; + setSnapshot(next); + if (!settingsDirtyRef.current) { + setSettingsDraft(next.settings); + } latestStateRef.current = null; } }, 220); @@ -145,23 +158,28 @@ export function App(): ReactElement { .map((id: string) => snapshot.session.packages[id]) .filter(Boolean), [snapshot]); + const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]); + useEffect(() => { setCollapsedPackages((prev) => { const next: Record = {}; - for (const pkg of packages) { - next[pkg.id] = prev[pkg.id] ?? false; + const defaultCollapsed = snapshot.session.packageOrder.length >= 24; + for (const packageId of snapshot.session.packageOrder) { + next[packageId] = prev[packageId] ?? defaultCollapsed; } return next; }); - }, [packages]); + }, [packageOrderKey, snapshot.session.packageOrder.length]); + + const deferredDownloadSearch = useDeferredValue(downloadSearch); const filteredPackages = useMemo(() => { - const query = downloadSearch.trim().toLowerCase(); + const query = deferredDownloadSearch.trim().toLowerCase(); if (!query) { return packages; } return packages.filter((pkg) => pkg.name.toLowerCase().includes(query)); - }, [packages, downloadSearch]); + }, [packages, deferredDownloadSearch]); const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) @@ -250,6 +268,7 @@ export function App(): ReactElement { try { const result = await window.rd.updateSettings(normalizedSettingsDraft); setSettingsDraft(result); + setSettingsDirty(false); applyTheme(result.theme); showToast("Einstellungen gespeichert", 1800); } catch (error) { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); } @@ -285,6 +304,7 @@ export function App(): ReactElement { const onDrop = async (event: DragEvent): Promise => { event.preventDefault(); + dragDepthRef.current = 0; dragOverRef.current = false; setDragOver(false); const files = Array.from(event.dataTransfer.files ?? []) as File[]; @@ -338,18 +358,29 @@ export function App(): ReactElement { } catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); } }; - const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; + const setBool = (key: keyof AppSettings, value: boolean): void => { + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, [key]: value })); + }; + const setText = (key: keyof AppSettings, value: string): void => { + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, [key]: value })); + }; + const setNum = (key: keyof AppSettings, value: number): void => { + setSettingsDirty(true); + setSettingsDraft((prev) => ({ ...prev, [key]: value })); + }; const setSpeedLimitMbps = (value: number): void => { const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; + setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); }; const performQuickAction = async (action: () => Promise): Promise => { - if (actionBusy) { + if (actionBusyRef.current) { return; } + actionBusyRef.current = true; setActionBusy(true); try { await action(); @@ -357,8 +388,9 @@ export function App(): ReactElement { showToast(`Fehler: ${String(error)}`, 2600); } finally { setTimeout(() => { + actionBusyRef.current = false; setActionBusy(false); - }, 100); + }, 80); } }; @@ -464,15 +496,20 @@ export function App(): ReactElement { return (
{ - e.preventDefault(); + onDragEnter={(event) => { + event.preventDefault(); + dragDepthRef.current += 1; if (!dragOverRef.current) { dragOverRef.current = true; setDragOver(true); } }} + onDragOver={(e) => { + e.preventDefault(); + }} onDragLeave={() => { - if (dragOverRef.current) { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0 && dragOverRef.current) { dragOverRef.current = false; setDragOver(false); } @@ -648,6 +685,7 @@ export function App(): ReactElement {