import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, DuplicatePolicy, PackageEntry, StartConflictEntry, UiSnapshot, UpdateCheckResult } from "../shared/types"; type Tab = "collector" | "downloads" | "settings"; interface CollectorTab { id: string; name: string; text: string; } interface StartConflictPromptState { entry: StartConflictEntry; applyToAll: boolean; } interface ConfirmPromptState { title: string; message: string; confirmLabel: string; danger?: boolean; } const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, totalFiles: 0, totalPackages: 0, sessionStartedAt: 0 }); const emptySnapshot = (): UiSnapshot => ({ settings: { token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true, cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", bandwidthSchedules: [] }, session: { version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "", paused: false, running: false, updatedAt: Date.now() }, summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --", canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0 }); const cleanupLabels: Record = { never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist" }; const AUTO_RENDER_PACKAGE_LIMIT = 260; const providerLabels: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid" }; function formatSpeedMbps(speedBps: number): string { const mbps = Math.max(0, speedBps) / (1024 * 1024); return `${mbps.toFixed(2)} MB/s`; } function humanSize(bytes: number): string { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } let nextCollectorId = 1; function createScheduleId(): string { return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { const fromIndex = order.indexOf(draggedPackageId); const toIndex = order.indexOf(targetPackageId); if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { return order; } const next = [...order]; const [dragged] = next.splice(fromIndex, 1); const insertIndex = Math.max(0, Math.min(next.length, toIndex)); next.splice(insertIndex, 0, dragged); return next; } export function sortPackageOrderByName(order: string[], packages: Record, descending: boolean): string[] { const sorted = [...order]; sorted.sort((a, b) => { const nameA = (packages[a]?.name ?? "").toLowerCase(); const nameB = (packages[b]?.name ?? "").toLowerCase(); const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" }); return descending ? -cmp : cmp; }); return sorted; } export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); 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 settingsDraftRevisionRef = useRef(0); const latestStateRef = useRef(null); const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); const [dragOver, setDragOver] = useState(false); const [editingPackageId, setEditingPackageId] = useState(null); const [editingName, setEditingName] = useState(""); const [collectorTabs, setCollectorTabs] = useState([ { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } ]); const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); const collectorTabsRef = useRef(collectorTabs); const activeCollectorTabRef = useRef(activeCollectorTab); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); const actionBusyRef = useRef(false); const actionUnlockTimerRef = useRef | null>(null); const mountedRef = useRef(true); const dragOverRef = useRef(false); const dragDepthRef = useRef(0); const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); const [confirmPrompt, setConfirmPrompt] = useState(null); const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; useEffect(() => { activeCollectorTabRef.current = activeCollectorTab; }, [activeCollectorTab]); useEffect(() => { collectorTabsRef.current = collectorTabs; }, [collectorTabs]); useEffect(() => { activeTabRef.current = tab; }, [tab]); useEffect(() => { packageOrderRef.current = snapshot.session.packageOrder; }, [snapshot.session.packageOrder]); const showToast = useCallback((message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } toastTimerRef.current = setTimeout(() => { setStatusToast(""); toastTimerRef.current = null; }, timeoutMs); }, []); useEffect(() => { let unsubscribe: (() => void) | null = null; let unsubClipboard: (() => void) | null = null; void window.rd.getSnapshot().then((state) => { setSnapshot(state); setSettingsDraft(state.settings); settingsDirtyRef.current = false; setSettingsDirty(false); applyTheme(state.settings.theme); if (state.settings.autoUpdateCheck) { void window.rd.checkUpdates().then((result) => { void handleUpdateResult(result, "startup"); }).catch(() => undefined); } }).catch((error) => { showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800); }); unsubscribe = window.rd.onStateUpdate((state) => { latestStateRef.current = state; if (stateFlushTimerRef.current) { return; } const itemCount = Object.keys(state.session.items).length; let flushDelay = itemCount >= 1500 ? 850 : itemCount >= 700 ? 620 : itemCount >= 250 ? 420 : 180; if (!state.session.running) { flushDelay = Math.min(flushDelay, 260); } if (activeTabRef.current !== "downloads") { flushDelay = Math.max(flushDelay, 320); } stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = null; if (latestStateRef.current) { const next = latestStateRef.current; setSnapshot(next); if (!settingsDirtyRef.current) { setSettingsDraft(next.settings); } latestStateRef.current = null; } }, flushDelay); }); unsubClipboard = window.rd.onClipboardDetected((links) => { showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); setCollectorTabs((prev) => { const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0]; if (!active) { return prev; } const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n"); return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t); }); }); return () => { mountedRef.current = false; if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } if (startConflictResolverRef.current) { const resolver = startConflictResolverRef.current; startConflictResolverRef.current = null; resolver(null); } if (confirmResolverRef.current) { const resolver = confirmResolverRef.current; confirmResolverRef.current = null; resolver(false); } if (unsubscribe) { unsubscribe(); } if (unsubClipboard) { unsubClipboard(); } }; }, []); const downloadsTabActive = tab === "downloads"; const deferredDownloadSearch = useDeferredValue(downloadSearch); const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase(); const downloadSearchActive = downloadSearchQuery.length > 0; const totalPackageCount = snapshot.session.packageOrder.length; const shouldLimitPackageRendering = downloadsTabActive && snapshot.session.running && !downloadSearchActive && totalPackageCount > AUTO_RENDER_PACKAGE_LIMIT && !showAllPackages; const packageIdsForView = useMemo(() => { if (!downloadsTabActive) { return [] as string[]; } if (downloadSearchActive) { return snapshot.session.packageOrder; } if (shouldLimitPackageRendering) { return snapshot.session.packageOrder.slice(0, AUTO_RENDER_PACKAGE_LIMIT); } return snapshot.session.packageOrder; }, [downloadsTabActive, downloadSearchActive, shouldLimitPackageRendering, snapshot.session.packageOrder]); const packageOrderKey = useMemo(() => { if (!downloadsTabActive) { return ""; } return packageIdsForView.join("|"); }, [downloadsTabActive, packageIdsForView]); const packages = useMemo(() => { if (!downloadsTabActive) { return [] as PackageEntry[]; } if (downloadSearchActive) { return snapshot.session.packageOrder .map((id: string) => snapshot.session.packages[id]) .filter((pkg): pkg is PackageEntry => Boolean(pkg) && pkg.name.toLowerCase().includes(downloadSearchQuery)); } return packageIdsForView .map((id) => snapshot.session.packages[id]) .filter((pkg): pkg is PackageEntry => Boolean(pkg)); }, [downloadsTabActive, downloadSearchActive, downloadSearchQuery, packageIdsForView, snapshot.session.packageOrder, snapshot.session.packages]); const packagePosition = useMemo(() => { if (!downloadsTabActive) { return new Map(); } const map = new Map(); snapshot.session.packageOrder.forEach((id, index) => { map.set(id, index); }); return map; }, [downloadsTabActive, snapshot.session.packageOrder]); const itemsByPackage = useMemo(() => { if (!downloadsTabActive) { return new Map(); } const map = new Map(); for (const pkg of packages) { const items = pkg.itemIds .map((id) => snapshot.session.items[id]) .filter(Boolean) as DownloadItem[]; map.set(pkg.id, items); } return map; }, [downloadsTabActive, packageOrderKey, packages, snapshot.session.items]); useEffect(() => { if (!downloadsTabActive) { return; } setCollapsedPackages((prev) => { let changed = false; const next: Record = { ...prev }; const defaultCollapsed = totalPackageCount >= 24; for (const packageId of snapshot.session.packageOrder) { if (!(packageId in prev)) { next[packageId] = defaultCollapsed; changed = true; } } for (const packageId of Object.keys(next)) { if (!snapshot.session.packages[packageId]) { delete next[packageId]; changed = true; } } return changed ? next : prev; }); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); const hiddenPackageCount = shouldLimitPackageRendering ? Math.max(0, totalPackageCount - packages.length) : 0; const visiblePackages = packages; useEffect(() => { if (!snapshot.session.running) { setShowAllPackages(false); } }, [snapshot.session.running]); const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) ), [packages, collapsedPackages]); const configuredProviders = useMemo(() => { const list: DebridProvider[] = []; if (settingsDraft.token.trim()) { list.push("realdebrid"); } if (settingsDraft.megaLogin.trim() && settingsDraft.megaPassword.trim()) { list.push("megadebrid"); } if (settingsDraft.bestToken.trim()) { list.push("bestdebrid"); } if (settingsDraft.allDebridToken.trim()) { list.push("alldebrid"); } return list; }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]); const primaryProviderValue: DebridProvider = useMemo(() => { if (configuredProviders.includes(settingsDraft.providerPrimary)) { return settingsDraft.providerPrimary; } return configuredProviders[0] ?? "realdebrid"; }, [configuredProviders, settingsDraft.providerPrimary]); const secondaryProviderChoices = useMemo(() => ( configuredProviders.filter((provider) => provider !== primaryProviderValue) ), [configuredProviders, primaryProviderValue]); const secondaryProviderValue: DebridFallbackProvider = useMemo(() => { if (secondaryProviderChoices.includes(settingsDraft.providerSecondary as DebridProvider)) { return settingsDraft.providerSecondary; } return "none"; }, [secondaryProviderChoices, settingsDraft.providerSecondary]); const tertiaryProviderChoices = useMemo(() => { const blocked = new Set([primaryProviderValue]); if (secondaryProviderValue !== "none") { blocked.add(secondaryProviderValue); } return configuredProviders.filter((provider) => !blocked.has(provider)); }, [configuredProviders, primaryProviderValue, secondaryProviderValue]); const tertiaryProviderValue: DebridFallbackProvider = useMemo(() => { if (tertiaryProviderChoices.includes(settingsDraft.providerTertiary as DebridProvider)) { return settingsDraft.providerTertiary; } return "none"; }, [tertiaryProviderChoices, settingsDraft.providerTertiary]); const normalizedSettingsDraft: AppSettings = useMemo(() => ({ ...settingsDraft, providerPrimary: primaryProviderValue, providerSecondary: configuredProviders.length >= 2 ? secondaryProviderValue : "none", providerTertiary: configuredProviders.length >= 3 ? tertiaryProviderValue : "none" }), [ settingsDraft, primaryProviderValue, configuredProviders.length, secondaryProviderValue, tertiaryProviderValue ]); const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise => { if (!mountedRef.current) { return; } if (result.error) { if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); } return; } if (!result.updateAvailable) { if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } return; } const approved = await askConfirmPrompt({ title: "Update verfügbar", message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`, confirmLabel: "Jetzt installieren" }); if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } const install = await window.rd.installUpdate(); if (!mountedRef.current) { return; } if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; } showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200); }; const onSaveSettings = async (): Promise => { await performQuickAction(async () => { const result = await persistDraftSettings(); applyTheme(result.theme); showToast("Einstellungen gespeichert", 1800); }, (error) => { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); }); }; const onCheckUpdates = async (): Promise => { await performQuickAction(async () => { const result = await window.rd.checkUpdates(); await handleUpdateResult(result, "manual"); }, (error) => { showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); }); }; const persistDraftSettings = async (): Promise => { const revisionAtStart = settingsDraftRevisionRef.current; const result = await window.rd.updateSettings(normalizedSettingsDraft); if (settingsDraftRevisionRef.current === revisionAtStart) { setSettingsDraft(result); settingsDirtyRef.current = false; setSettingsDirty(false); } return result; }; const closeStartConflictPrompt = (result: { policy: Extract; applyToAll: boolean } | null): void => { const resolver = startConflictResolverRef.current; startConflictResolverRef.current = null; setStartConflictPrompt(null); if (resolver) { resolver(result); } }; const askStartConflictDecision = (entry: StartConflictEntry): Promise<{ policy: Extract; applyToAll: boolean } | null> => { return new Promise((resolve) => { startConflictResolverRef.current = resolve; setStartConflictPrompt({ entry, applyToAll: false }); }); }; const closeConfirmPrompt = (confirmed: boolean): void => { const resolver = confirmResolverRef.current; confirmResolverRef.current = null; setConfirmPrompt(null); if (resolver) { resolver(confirmed); } }; const askConfirmPrompt = (prompt: ConfirmPromptState): Promise => { return new Promise((resolve) => { confirmResolverRef.current = resolve; setConfirmPrompt(prompt); }); }; const onStartDownloads = async (): Promise => { await performQuickAction(async () => { if (configuredProviders.length === 0) { setTab("settings"); showToast("Bitte zuerst mindestens einen Hoster-Account eintragen", 3000); return; } await persistDraftSettings(); const conflicts = await window.rd.getStartConflicts(); let skipped = 0; let overwritten = 0; let rememberedPolicy: Extract | null = null; for (const conflict of conflicts) { let decisionPolicy = rememberedPolicy; if (!decisionPolicy) { const decision = await askStartConflictDecision(conflict); if (!decision) { showToast("Start abgebrochen", 1800); return; } decisionPolicy = decision.policy; if (decision.applyToAll) { rememberedPolicy = decision.policy; } } const result = await window.rd.resolveStartConflict(conflict.packageId, decisionPolicy); if (result.skipped) { skipped += 1; } if (result.overwritten) { overwritten += 1; } } if (conflicts.length > 0) { showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800); } await window.rd.start(); }); }; const onStartPauseClick = async (): Promise => { if (snapshot.session.running) { await performQuickAction(() => window.rd.togglePause()); return; } await onStartDownloads(); }; const onAddLinks = async (): Promise => { await performQuickAction(async () => { const activeId = activeCollectorTabRef.current; const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0]; const rawText = active?.text ?? ""; const persisted = await persistDraftSettings(); const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName }); if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t)); } else { showToast("Keine gültigen Links gefunden"); } }, (error) => { showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); }); }; const onImportDlc = async (): Promise => { await performQuickAction(async () => { const files = await window.rd.pickContainers(); if (files.length === 0) { return; } await persistDraftSettings(); const result = await window.rd.addContainers(files); showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); }, (error) => { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }); }; 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[]; const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v); const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; if (dlc.length > 0) { await performQuickAction(async () => { await persistDraftSettings(); const result = await window.rd.addContainers(dlc); showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); }, (error) => { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }); } else if (droppedText.trim()) { setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t)); setTab("collector"); showToast("Links per Drag-and-Drop eingefügt"); } }; const onExportQueue = async (): Promise => { await performQuickAction(async () => { const json = await window.rd.exportQueue(); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "rd-queue-export.json"; a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60_000); showToast("Queue exportiert"); }, (error) => { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); }); }; const onImportQueue = async (): Promise => { if (actionBusyRef.current) { return; } setActionBusy(true); const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; const releasePickerBusy = (): void => { setActionBusy(actionBusyRef.current); }; const onWindowFocus = (): void => { window.removeEventListener("focus", onWindowFocus); if (!input.files || input.files.length === 0) { releasePickerBusy(); } }; input.onchange = async () => { window.removeEventListener("focus", onWindowFocus); const file = input.files?.[0]; if (!file) { releasePickerBusy(); return; } releasePickerBusy(); await performQuickAction(async () => { const text = await file.text(); const result = await window.rd.importQueue(text); showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); }, (error) => { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }); }; window.addEventListener("focus", onWindowFocus, { once: true }); input.click(); }; const setBool = (key: keyof AppSettings, value: boolean): void => { settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setText = (key: keyof AppSettings, value: string): void => { settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setNum = (key: keyof AppSettings, value: number): void => { settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setSpeedLimitMbps = (value: number): void => { const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); }; const performQuickAction = async ( action: () => Promise, onError?: (error: unknown) => void ): Promise => { if (actionBusyRef.current) { return; } actionBusyRef.current = true; setActionBusy(true); try { await action(); } catch (error) { if (onError) { onError(error); } else { showToast(`Fehler: ${String(error)}`, 2600); } } finally { if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } actionUnlockTimerRef.current = setTimeout(() => { if (!mountedRef.current) { actionUnlockTimerRef.current = null; return; } actionBusyRef.current = false; setActionBusy(false); actionUnlockTimerRef.current = null; }, 80); } }; const movePackage = useCallback((packageId: string, direction: "up" | "down") => { const currentOrder = packageOrderRef.current; const order = [...currentOrder]; const idx = order.indexOf(packageId); if (idx < 0) { return; } const target = direction === "up" ? idx - 1 : idx + 1; if (target < 0 || target >= order.length) { return; } [order[idx], order[target]] = [order[target], order[idx]]; setDownloadsSortDescending(false); packageOrderRef.current = order; void window.rd.reorderPackages(order).catch((error) => { showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { const currentOrder = packageOrderRef.current; const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId); const unchanged = nextOrder.length === currentOrder.length && nextOrder.every((id, index) => id === currentOrder[index]); if (unchanged) { return; } setDownloadsSortDescending(false); packageOrderRef.current = nextOrder; void window.rd.reorderPackages(nextOrder).catch((error) => { showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const addCollectorTab = (): void => { const id = `tab-${nextCollectorId++}`; setCollectorTabs((prev) => { const name = `Tab ${prev.length + 1}`; return [...prev, { id, name, text: "" }]; }); setActiveCollectorTab(id); }; const removeCollectorTab = (id: string): void => { let fallbackId = ""; setCollectorTabs((prev) => { if (prev.length <= 1) { return prev; } const index = prev.findIndex((tabEntry) => tabEntry.id === id); if (index < 0) { return prev; } const next = prev.filter((tabEntry) => tabEntry.id !== id); if (activeCollectorTabRef.current === id) { fallbackId = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? ""; } return next; }); if (fallbackId) { setActiveCollectorTab(fallbackId); } }; const onPackageDragStart = useCallback((packageId: string) => { draggedPackageIdRef.current = packageId; }, []); const onPackageDrop = useCallback((targetPackageId: string) => { const draggedPackageId = draggedPackageIdRef.current; draggedPackageIdRef.current = null; if (!draggedPackageId || draggedPackageId === targetPackageId) { return; } reorderPackagesByDrop(draggedPackageId, targetPackageId); }, [reorderPackagesByDrop]); const onPackageDragEnd = useCallback(() => { draggedPackageIdRef.current = null; }, []); const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => { setEditingPackageId(packageId); setEditingName(packageName); }, []); const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => { setEditingPackageId(null); const normalized = nextName.trim(); if (normalized && normalized !== currentName.trim()) { void window.rd.renamePackage(packageId, normalized).catch((error) => { showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400); }); } }, [showToast]); const onPackageToggleCollapse = useCallback((packageId: string): void => { setCollapsedPackages((prev) => ({ ...prev, [packageId]: !(prev[packageId] ?? false) })); }, []); const onPackageCancel = useCallback((packageId: string): void => { void window.rd.cancelPackage(packageId).catch((error) => { showToast(`Paket-Löschung fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const onPackageMoveUp = useCallback((packageId: string): void => { movePackage(packageId, "up"); }, [movePackage]); const onPackageMoveDown = useCallback((packageId: string): void => { movePackage(packageId, "down"); }, [movePackage]); const onPackageToggle = useCallback((packageId: string): void => { void window.rd.togglePackage(packageId).catch((error) => { showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const onPackageRemoveItem = useCallback((itemId: string): void => { void window.rd.removeItem(itemId).catch((error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const schedules = settingsDraft.bandwidthSchedules ?? []; const addSchedule = (): void => { settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { id: createScheduleId(), startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }] })); }; const removeSchedule = (idx: number): void => { settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx) })); }; const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => { settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s) })); }; const applyTheme = (theme: AppTheme): void => { document.documentElement.setAttribute("data-theme", theme); }; const packageSpeedMap = useMemo(() => { const map = new Map(); for (const item of Object.values(snapshot.session.items)) { if (item.speedBps > 0) { map.set(item.packageId, (map.get(item.packageId) ?? 0) + item.speedBps); } } return map; }, [snapshot.session.items]); return (
{ event.preventDefault(); if (draggedPackageIdRef.current) { return; } dragDepthRef.current += 1; if (!dragOverRef.current) { dragOverRef.current = true; setDragOver(true); } }} onDragOver={(e) => { e.preventDefault(); }} onDragLeave={() => { if (draggedPackageIdRef.current) { return; } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0 && dragOverRef.current) { dragOverRef.current = false; setDragOver(false); } }} onDrop={onDrop} >

Multi Debrid Downloader

{snapshot.speedText}
{snapshot.etaText}
{snapshot.reconnectSeconds > 0 && (
Reconnect: {snapshot.reconnectSeconds}s
)}
{tab === "collector" && (

Linksammler

{collectorTabs.map((ct) => (
{collectorTabs.length > 1 && }
))}