import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, DuplicatePolicy, HistoryEntry, PackageEntry, StartConflictEntry, UiSnapshot, UpdateCheckResult, UpdateInstallProgress } from "../shared/types"; import { reorderPackageOrderByDrop, sortPackageOrderByName } from "./package-order"; type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; interface CollectorTab { id: string; name: string; text: string; } interface StartConflictPromptState { entry: StartConflictEntry; applyToAll: boolean; } interface ConfirmPromptState { title: string; message: string; confirmLabel: string; danger?: boolean; } interface ContextMenuState { x: number; y: number; packageId: string; itemId?: string; } interface LinkPopupState { title: string; links: { name: string; url: string }[]; isPackage: boolean; } const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, totalDownloadedAllTime: 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, collectMkvToLibrary: false, mkvLibraryDir: "", cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", collapseNewPackages: true, autoSkipExtracted: false, confirmDeleteSelection: true, bandwidthSchedules: [], totalDownloadedAllTime: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], autoExtractWhenStopped: true }, 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, packageSpeedBps: {} }); 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", ddownload: "DDownload" }; function formatDateTime(ts: number): string { if (!ts) return ""; const d = new Date(ts); const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yyyy = d.getFullYear(); const hh = String(d.getHours()).padStart(2, "0"); const min = String(d.getMinutes()).padStart(2, "0"); return `${dd}.${mm}.${yyyy} - ${hh}:${min}`; } function extractHoster(url: string): string { try { const host = new URL(url).hostname.replace(/^www\./, ""); const parts = host.split("."); return parts.length >= 2 ? parts[parts.length - 2] : host; } catch { return ""; } } function formatHoster(item: DownloadItem): string { const hoster = extractHoster(item.url); const label = hoster || "-"; if (item.provider) { return `${label} via ${providerLabels[item.provider]}`; } return label; } const settingsSubTabs: { key: SettingsSubTab; label: string }[] = [ { key: "allgemein", label: "Allgemein" }, { key: "accounts", label: "Accounts" }, { key: "entpacken", label: "Entpacken" }, { key: "geschwindigkeit", label: "Geschwindigkeit" }, { key: "bereinigung", label: "Bereinigung" }, { key: "updates", label: "Updates" }, ]; function formatSpeedMbps(speedBps: number): string { const mbps = Math.max(0, speedBps || 0) / (1024 * 1024); return `${mbps.toFixed(2)} MB/s`; } function humanSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) { return "0 B"; } 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`; } if (bytes < 1024 * 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`; } interface BandwidthChartProps { items: Record; running: boolean; paused: boolean; speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>; } const BandwidthChart = memo(function BandwidthChart({ items, running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement { const canvasRef = useRef(null); const containerRef = useRef(null); const lastUpdateRef = useRef(0); const animationFrameRef = useRef(0); const drawChart = useCallback(() => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const width = container.clientWidth; const height = container.clientHeight; if (width <= 0 || height <= 0) return; canvas.width = width * dpr; canvas.height = height * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); const isDark = document.documentElement.getAttribute("data-theme") !== "light"; const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)"; const textColor = isDark ? "#90a4bf" : "#4e6482"; const accentColor = isDark ? "#38bdf8" : "#1168d9"; const fillColor = isDark ? "rgba(56, 189, 248, 0.15)" : "rgba(17, 104, 217, 0.15)"; const history = speedHistoryRef.current; const now = Date.now(); const maxTime = now; const minTime = now - 60000; let maxSpeed = 0; for (const point of history) { if (point.speed > maxSpeed) maxSpeed = point.speed; } maxSpeed = Math.max(maxSpeed, 1024 * 1024); const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed))); // Measure widest label to set dynamic left padding ctx.font = "11px 'Manrope', sans-serif"; let maxLabelWidth = 0; for (let i = 0; i <= 5; i += 1) { const speedVal = niceMax * (1 - i / 5); const w = ctx.measureText(formatSpeedMbps(speedVal)).width; if (w > maxLabelWidth) maxLabelWidth = w; } const padding = { top: 20, right: 20, bottom: 30, left: Math.ceil(maxLabelWidth) + 16 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; ctx.strokeStyle = gridColor; ctx.lineWidth = 1; for (let i = 0; i <= 5; i += 1) { const y = padding.top + (chartHeight / 5) * i; ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(width - padding.right, y); ctx.stroke(); } ctx.fillStyle = textColor; ctx.font = "11px 'Manrope', sans-serif"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (let i = 0; i <= 5; i += 1) { const y = padding.top + (chartHeight / 5) * i; const speedVal = niceMax * (1 - i / 5); ctx.fillText(formatSpeedMbps(speedVal), padding.left - 8, y); } ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillText("60s", padding.left, height - padding.bottom + 8); ctx.fillText("30s", padding.left + chartWidth / 2, height - padding.bottom + 8); ctx.fillText("0s", width - padding.right, height - padding.bottom + 8); if (history.length < 2) { ctx.fillStyle = textColor; ctx.font = "13px 'Manrope', sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten für Statistiken", width / 2, height / 2); return; } const points: { x: number; y: number }[] = []; for (const point of history) { const x = padding.left + ((point.time - minTime) / 60000) * chartWidth; const y = padding.top + chartHeight - (point.speed / niceMax) * chartHeight; points.push({ x, y }); } ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i += 1) { ctx.lineTo(points[i].x, points[i].y); } ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight); ctx.lineTo(points[0].x, padding.top + chartHeight); ctx.closePath(); ctx.fillStyle = fillColor; ctx.fill(); ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i += 1) { ctx.lineTo(points[i].x, points[i].y); } ctx.strokeStyle = accentColor; ctx.lineWidth = 2; ctx.stroke(); const lastPoint = points[points.length - 1]; ctx.beginPath(); ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2); ctx.fillStyle = accentColor; ctx.fill(); }, [running, paused]); useEffect(() => { const interval = setInterval(() => { drawChart(); }, 250); return () => clearInterval(interval); }, [drawChart]); useEffect(() => { // Only record samples while the session is running and not paused if (!running || paused) return; const now = Date.now(); const activeItems = Object.values(items).filter((item) => item.status === "downloading"); if (activeItems.length === 0) return; const totalSpeed = activeItems.reduce((sum, item) => sum + (item.speedBps || 0), 0); const history = speedHistoryRef.current; history.push({ time: now, speed: totalSpeed }); const cutoff = now - 60000; let trimIndex = 0; while (trimIndex < history.length && history[trimIndex].time < cutoff) { trimIndex += 1; } if (trimIndex > 0) { speedHistoryRef.current = history.slice(trimIndex); } lastUpdateRef.current = now; }, [items, paused, running]); useEffect(() => { const handleResize = () => { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = requestAnimationFrame(drawChart); }; window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [drawChart]); useEffect(() => { drawChart(); }, [drawChart, items, paused]); return (
); }); let nextCollectorId = 1; function createScheduleId(): string { return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function sortPackageOrderBySize(order: string[], packages: Record, items: Record, descending: boolean): string[] { const sorted = [...order]; sorted.sort((a, b) => { const sizeA = (packages[a]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0); const sizeB = (packages[b]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0); const cmp = sizeA - sizeB; return descending ? -cmp : cmp; }); return sorted; } function sortPackageOrderByHoster(order: string[], packages: Record, items: Record, descending: boolean): string[] { const sorted = [...order]; sorted.sort((a, b) => { const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase(); const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase(); const cmp = hosterA.localeCompare(hosterB); return descending ? -cmp : cmp; }); return sorted; } function sortPackageOrderByProgress(order: string[], packages: Record, items: Record, descending: boolean): string[] { const sorted = [...order]; sorted.sort((a, b) => { const progressA = computePackageProgress(packages[a], items); const progressB = computePackageProgress(packages[b], items); const cmp = progressA - progressB; return descending ? -cmp : cmp; }); return sorted; } function computePackageProgress(pkg: PackageEntry | undefined, items: Record): number { if (!pkg) return 0; const ids = pkg.itemIds ?? []; if (ids.length === 0) return 0; let totalDown = 0; let totalSize = 0; for (const id of ids) { const item = items[id]; if (!item) continue; totalDown += item.downloadedBytes || 0; totalSize += item.totalBytes || item.downloadedBytes || 0; } return totalSize > 0 ? totalDown / totalSize : 0; } type PkgSortColumn = "name" | "size" | "hoster" | "progress"; const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"]; const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"]; const COLUMN_DEFS: Record = { name: { label: "Name", width: "1fr", sortable: "name" }, size: { label: "Geladen / Größe", width: "160px", sortable: "size" }, progress: { label: "Fortschritt", width: "80px", sortable: "progress" }, hoster: { label: "Hoster", width: "110px", sortable: "hoster" }, account: { label: "Service", width: "110px" }, prio: { label: "Priorität", width: "70px" }, status: { label: "Status", width: "160px" }, speed: { label: "Geschwindigkeit", width: "90px" }, added: { label: "Hinzugefügt am", width: "155px" }, }; function sameStringArray(a: string[], b: string[]): boolean { if (a.length !== b.length) { return false; } for (let index = 0; index < a.length; index += 1) { if (a[index] !== b[index]) { return false; } } return true; } function formatMbpsInputFromKbps(kbps: number): string { const mbps = Math.max(0, Number(kbps) || 0) / 1024; return String(Number(mbps.toFixed(2))); } function parseMbpsInput(value: string): number | null { const normalized = String(value || "").trim().replace(/,/g, "."); if (!normalized) { return 0; } const parsed = Number(normalized); if (!Number.isFinite(parsed) || parsed < 0) { return null; } return parsed; } function formatUpdateInstallProgress(progress: UpdateInstallProgress): string { if (progress.stage === "downloading") { if (progress.totalBytes && progress.totalBytes > 0 && progress.percent !== null) { return `Update-Download: ${progress.percent}% (${humanSize(progress.downloadedBytes)} / ${humanSize(progress.totalBytes)})`; } return `Update-Download: ${humanSize(progress.downloadedBytes)}`; } if (progress.stage === "starting") { return "Update wird vorbereitet..."; } if (progress.stage === "verifying") { return "Download fertig | Prüfe Integrität..."; } if (progress.stage === "launching") { return "Starte Installer..."; } if (progress.stage === "done") { return "Installer gestartet"; } return `Update-Fehler: ${progress.message}`; } export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [appVersion, setAppVersion] = useState(""); const [tab, setTab] = useState("downloads"); const [statusToast, setStatusToast] = useState(""); const [updateInstallProgress, setUpdateInstallProgress] = useState(null); const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); const [settingsDirty, setSettingsDirty] = useState(false); const settingsDirtyRef = useRef(false); const settingsDraftRevisionRef = useRef(0); const panelDirtyRevisionRef = useRef(0); const latestStateRef = useRef(null); const snapshotRef = useRef(snapshot); snapshotRef.current = snapshot; const tabRef = useRef(tab); const autoExpandedPkgsRef = useRef(new Set()); tabRef.current = tab; const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); const onImportDlcRef = useRef<() => Promise>(() => Promise.resolve()); 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 serverPackageOrderRef = useRef([]); const pendingPackageOrderRef = useRef(null); const pendingPackageOrderAtRef = useRef(0); const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); 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 [openMenu, setOpenMenu] = useState(null); const [settingsSubTab, setSettingsSubTab] = useState("allgemein"); const [openSubmenu, setOpenSubmenu] = useState(null); 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 confirmQueueRef = useRef void }>>([]); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); const [contextMenu, setContextMenu] = useState(null); const ctxMenuRef = useRef(null); const [linkPopup, setLinkPopup] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set; dontAsk: boolean } | null>(null); const [columnOrder, setColumnOrder] = useState(() => DEFAULT_COLUMN_ORDER); const [dragColId, setDragColId] = useState(null); const [dropTargetCol, setDropTargetCol] = useState(null); const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null); const colHeaderCtxRef = useRef(null); const colHeaderBarRef = useRef(null); const [historyEntries, setHistoryEntries] = useState([]); const historyEntriesRef = useRef([]); const [historyCollapsed, setHistoryCollapsed] = useState>({}); const [selectedHistoryIds, setSelectedHistoryIds] = useState>(new Set()); const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null); const historyCtxMenuRef = useRef(null); // Load history when tab changes to history useEffect(() => { if (tab !== "history") return; const loadHistory = async (): Promise => { try { const entries = await window.rd.getHistory(); if (mountedRef.current && entries) { setHistoryEntries(entries); } } catch (err) { console.error("Failed to load history:", err); } }; void loadHistory(); }, [tab]); useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]); // Sync column order from settings (value-based comparison to avoid reference issues) const columnOrderJson = JSON.stringify(snapshot.settings.columnOrder); useEffect(() => { const order = snapshot.settings.columnOrder; if (order && order.length > 0) { setColumnOrder(order); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [columnOrderJson]); 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(() => { const incoming = snapshot.session.packageOrder; serverPackageOrderRef.current = incoming; const pending = pendingPackageOrderRef.current; if (!pending) { packageOrderRef.current = incoming; return; } if (sameStringArray(pending, incoming)) { pendingPackageOrderRef.current = null; pendingPackageOrderAtRef.current = 0; packageOrderRef.current = incoming; return; } const maxOptimisticHoldMs = 1500; if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) { pendingPackageOrderRef.current = null; pendingPackageOrderAtRef.current = 0; packageOrderRef.current = incoming; return; } packageOrderRef.current = pending; }, [snapshot.session.packageOrder]); useEffect(() => { setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); }, [settingsDraft.speedLimitKbps]); const showToast = useCallback((message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } toastTimerRef.current = setTimeout(() => { setStatusToast(""); toastTimerRef.current = null; }, timeoutMs); }, []); const clearImportQueueFocusListener = useCallback((): void => { const handler = importQueueFocusHandlerRef.current; if (!handler) { return; } window.removeEventListener("focus", handler); importQueueFocusHandlerRef.current = null; }, []); useEffect(() => { document.title = `Multi Debrid Downloader${appVersion ? ` - v${appVersion}` : ""}`; }, [appVersion]); useEffect(() => { let unsubscribe: (() => void) | null = null; let unsubClipboard: (() => void) | null = null; let unsubUpdateInstallProgress: (() => void) | null = null; void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined); void window.rd.getSnapshot().then((state) => { if (!mountedRef.current) { return; } setSnapshot(state); if (state.settings.columnOrder?.length > 0) { setColumnOrder(state.settings.columnOrder); } setSettingsDraft(state.settings); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; setSettingsDirty(false); applyTheme(state.settings.theme); if (state.settings.autoUpdateCheck) { void window.rd.checkUpdates().then((result) => { if (!mountedRef.current) { return; } 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 ? 900 : itemCount >= 700 ? 650 : itemCount >= 250 ? 400 : 150; if (!state.session.running) { flushDelay = Math.min(flushDelay, 200); } if (activeTabRef.current !== "downloads") { flushDelay = Math.max(flushDelay, 800); } stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = null; if (latestStateRef.current) { const next = latestStateRef.current; setSnapshot(next); if (next.settings.columnOrder?.length > 0) { setColumnOrder(next.settings.columnOrder); } 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); }); }); unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => { if (!mountedRef.current) { return; } setUpdateInstallProgress(progress); }); return () => { mountedRef.current = false; if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } clearImportQueueFocusListener(); if (startConflictResolverRef.current) { const resolver = startConflictResolverRef.current; startConflictResolverRef.current = null; resolver(null); } if (confirmResolverRef.current) { const resolver = confirmResolverRef.current; confirmResolverRef.current = null; resolver(false); } while (confirmQueueRef.current.length > 0) { const request = confirmQueueRef.current.shift(); request?.resolve(false); } if (unsubscribe) { unsubscribe(); } if (unsubClipboard) { unsubClipboard(); } if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); } }; }, [clearImportQueueFocusListener]); const downloadsTabActive = tab === "downloads"; const deferredDownloadSearch = useDeferredValue(downloadSearch); const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase(); const downloadSearchActive = downloadSearchQuery.length > 0; const gridTemplate = useMemo(() => columnOrder.map((col) => COLUMN_DEFS[col]?.width ?? "100px").join(" "), [columnOrder]); 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 = useMemo(() => { if (!snapshot.session.running || packages.length <= 1) { return packages; } const activeStatuses = new Set(["downloading", "validating", "integrity_check", "extracting"]); const active: PackageEntry[] = []; const rest: PackageEntry[] = []; for (const pkg of packages) { const hasActive = pkg.itemIds.some((id) => { const item = snapshot.session.items[id]; return item && activeStatuses.has(item.status); }); if (hasActive) { active.push(pkg); } else { rest.push(pkg); } } if (active.length === 0 || active.length === packages.length) { return packages; } // Sort active packages: highest completion percentage first active.sort((a, b) => { const aItems = a.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean); const bItems = b.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean); const aPct = aItems.length > 0 ? aItems.filter((i) => i.status === "completed").length / aItems.length : 0; const bPct = bItems.length > 0 ? bItems.filter((i) => i.status === "completed").length / bItems.length : 0; if (aPct !== bPct) { return bPct - aPct; } const aBytes = aItems.reduce((s, i) => s + (i.downloadedBytes || 0), 0); const bBytes = bItems.reduce((s, i) => s + (i.downloadedBytes || 0), 0); return bBytes - aBytes; }); return [...active, ...rest]; }, [packages, snapshot.session.running, snapshot.session.items]); useEffect(() => { if (!snapshot.session.running) { setShowAllPackages(false); } }, [snapshot.session.running]); // Auto-expand packages that are currently extracting (only once per extraction cycle) useEffect(() => { const extractingPkgIds: string[] = []; const currentlyExtracting = new Set(); for (const pkg of packages) { const items = (pkg.itemIds ?? []).map((id) => snapshot.session.items[id]).filter(Boolean); const isExtracting = items.some((item) => item.fullStatus?.startsWith("Entpacken -") && !item.fullStatus?.includes("Done")); if (isExtracting) { currentlyExtracting.add(pkg.id); if (collapsedPackages[pkg.id] && !autoExpandedPkgsRef.current.has(pkg.id)) { extractingPkgIds.push(pkg.id); autoExpandedPkgsRef.current.add(pkg.id); } } } // Reset tracking for packages no longer extracting for (const id of autoExpandedPkgsRef.current) { if (!currentlyExtracting.has(id)) { autoExpandedPkgsRef.current.delete(id); } } if (extractingPkgIds.length > 0) { setCollapsedPackages((prev) => { const next = { ...prev }; for (const id of extractingPkgIds) next[id] = false; return next; }); } }, [packages, snapshot.session.items, collapsedPackages]); 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"); } if ((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()) { list.push("ddownload"); } return list; }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken, settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]); 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) { setUpdateInstallProgress(null); if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } return; } let changelogBlock = ""; if (result.releaseNotes) { const notes = result.releaseNotes.length > 500 ? `${result.releaseNotes.slice(0, 500)}…` : result.releaseNotes; changelogBlock = `\n\n--- Changelog ---\n${notes}`; } const approved = await askConfirmPrompt({ title: "Update verfügbar", message: `${result.latestTag} (aktuell v${result.currentVersion})${changelogBlock}\n\nJetzt automatisch herunterladen und installieren?`, confirmLabel: "Jetzt installieren" }); if (!mountedRef.current) { return; } if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } setUpdateInstallProgress({ stage: "starting", percent: 0, downloadedBytes: 0, totalBytes: null, message: "Update wird vorbereitet" }); const install = await window.rd.installUpdate(); if (!mountedRef.current) { return; } if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; } setUpdateInstallProgress({ stage: "error", percent: null, downloadedBytes: 0, totalBytes: null, message: install.message }); 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 => { let updateResult: UpdateCheckResult | null = null; await performQuickAction(async () => { setUpdateInstallProgress(null); updateResult = await window.rd.checkUpdates(); }, (error) => { showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); }); if (updateResult) await handleUpdateResult(updateResult, "manual"); }; const persistDraftSettings = async (): Promise => { const revisionAtStart = settingsDraftRevisionRef.current; const result = await window.rd.updateSettings(normalizedSettingsDraft); if (settingsDraftRevisionRef.current === revisionAtStart) { setSettingsDraft(result); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; 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 pumpConfirmQueue = useCallback((): void => { if (confirmResolverRef.current) { return; } const next = confirmQueueRef.current.shift(); if (!next) { return; } confirmResolverRef.current = next.resolve; setConfirmPrompt(next.prompt); }, []); const closeConfirmPrompt = useCallback((confirmed: boolean): void => { const resolver = confirmResolverRef.current; confirmResolverRef.current = null; setConfirmPrompt(null); if (resolver) { resolver(confirmed); } pumpConfirmQueue(); }, [pumpConfirmQueue]); const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise => { return new Promise((resolve) => { confirmQueueRef.current.push({ prompt, resolve }); pumpConfirmQueue(); }); }, [pumpConfirmQueue]); 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; if (settingsDraft.autoSkipExtracted && conflicts.length > 0) { rememberedPolicy = "skip"; } 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 && !settingsDraft.autoSkipExtracted) { showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800); } await window.rd.start(); }); }; const collapseNewPackages = async (existingIds: Set): Promise => { const fresh = await window.rd.getSnapshot(); const newIds = Object.keys(fresh.session.packages).filter((id) => !existingIds.has(id)); if (newIds.length > 0) { setCollapsedPackages((prev) => { const next = { ...prev }; for (const id of newIds) { next[id] = true; } return next; }); } }; 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 existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); 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)); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } 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 existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); const result = await window.rd.addContainers(files); if (result.addedLinks > 0) { showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); } }, (error) => { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }); }; onImportDlcRef.current = onImportDlc; const onDrop = async (event: DragEvent): Promise => { event.preventDefault(); dragDepthRef.current = 0; dragOverRef.current = false; setDragOver(false); const hasFiles = event.dataTransfer.types.includes("Files"); const hasUri = event.dataTransfer.types.includes("text/uri-list"); if (!hasFiles && !hasUri) { return; } 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 existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); const result = await window.rd.addContainers(dlc); if (result.addedLinks > 0) { showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); } }, (error) => { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }); } else if (droppedText.trim()) { const activeCollectorId = activeCollectorTabRef.current; setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId ? { ...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 result = await window.rd.exportQueue(); if (result.saved) { showToast("Queue exportiert"); } }, (error) => { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); }); }; const onImportQueue = async (): Promise => { if (actionBusyRef.current) { return; } actionBusyRef.current = true; setActionBusy(true); const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; const releasePickerBusy = (): void => { actionBusyRef.current = false; setActionBusy(false); }; const onWindowFocus = (): void => { clearImportQueueFocusListener(); if (!input.files || input.files.length === 0) { releasePickerBusy(); } }; input.onchange = async () => { clearImportQueueFocusListener(); 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); }); }; clearImportQueueFocusListener(); importQueueFocusHandlerRef.current = onWindowFocus; window.addEventListener("focus", onWindowFocus, { once: true }); input.click(); }; const setBool = (key: keyof AppSettings, value: boolean): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setText = (key: keyof AppSettings, value: string): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setNum = (key: keyof AppSettings, value: number): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.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; panelDirtyRevisionRef.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); pendingPackageOrderRef.current = [...order]; pendingPackageOrderAtRef.current = Date.now(); packageOrderRef.current = [...order]; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; }); void window.rd.reorderPackages(order).catch((error) => { pendingPackageOrderRef.current = null; pendingPackageOrderAtRef.current = 0; packageOrderRef.current = serverPackageOrderRef.current; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; }); 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); pendingPackageOrderRef.current = [...nextOrder]; pendingPackageOrderAtRef.current = Date.now(); packageOrderRef.current = [...nextOrder]; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } }; }); void window.rd.reorderPackages(nextOrder).catch((error) => { pendingPackageOrderRef.current = null; pendingPackageOrderAtRef.current = 0; packageOrderRef.current = serverPackageOrderRef.current; setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; }); 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 => { let shouldRename = false; setEditingPackageId((prev) => { if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key) shouldRename = true; return null; }); if (shouldRename) { 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 => { setSnapshot((prev) => { if (!prev) { return prev; } const nextPackages = { ...prev.session.packages }; const nextItems = { ...prev.session.items }; const pkg = nextPackages[packageId]; if (pkg) { for (const itemId of pkg.itemIds) { delete nextItems[itemId]; } delete nextPackages[packageId]; } return { ...prev, session: { ...prev.session, packages: nextPackages, items: nextItems, packageOrder: prev.session.packageOrder.filter((id) => id !== packageId) } }; }); 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 moveSelectedPackages = useCallback((direction: "up" | "down") => { const currentOrder = packageOrderRef.current; const selPkgs = new Set([...selectedIds].filter((id) => snapshot.session.packages[id])); if (selPkgs.size === 0) return; const order = [...currentOrder]; if (direction === "up") { for (let i = 0; i < order.length; i++) { if (selPkgs.has(order[i]) && i > 0 && !selPkgs.has(order[i - 1])) { [order[i - 1], order[i]] = [order[i], order[i - 1]]; } } } else { for (let i = order.length - 1; i >= 0; i--) { if (selPkgs.has(order[i]) && i < order.length - 1 && !selPkgs.has(order[i + 1])) { [order[i], order[i + 1]] = [order[i + 1], order[i]]; } } } const unchanged = order.length === currentOrder.length && order.every((id, idx) => id === currentOrder[idx]); if (unchanged) return; setDownloadsSortDescending(false); pendingPackageOrderRef.current = [...order]; pendingPackageOrderAtRef.current = Date.now(); packageOrderRef.current = [...order]; // Optimistic UI update — apply the new order immediately so the user // sees the change without waiting for the backend round-trip. setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; }); void window.rd.reorderPackages(order).catch((error) => { pendingPackageOrderRef.current = null; pendingPackageOrderAtRef.current = 0; packageOrderRef.current = serverPackageOrderRef.current; // Rollback: restore original order from server setSnapshot((prev) => { if (!prev) return prev; return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; }); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); }, [selectedIds, snapshot.session.packages, showToast]); 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 => { setSnapshot((prev) => { if (!prev) { return prev; } const item = prev.session.items[itemId]; if (!item) { return prev; } const nextItems = { ...prev.session.items }; delete nextItems[itemId]; const nextPackages = { ...prev.session.packages }; const pkg = nextPackages[item.packageId]; if (pkg) { const nextItemIds = pkg.itemIds.filter((id) => id !== itemId); if (nextItemIds.length === 0) { delete nextPackages[item.packageId]; return { ...prev, session: { ...prev.session, packages: nextPackages, items: nextItems, packageOrder: prev.session.packageOrder.filter((id) => id !== item.packageId) } }; } nextPackages[item.packageId] = { ...pkg, itemIds: nextItemIds }; } return { ...prev, session: { ...prev.session, packages: nextPackages, items: nextItems } }; }); void window.rd.removeItem(itemId).catch((error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); const onPackageContextMenu = useCallback((packageId: string, itemId: string | undefined, x: number, y: number): void => { const clickedId = itemId ?? packageId; setSelectedIds((prev) => { if (prev.has(clickedId)) return prev; return new Set([clickedId]); }); setContextMenu({ x, y, packageId, itemId }); }, []); const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]); const dragSelectRef = useRef(false); const dragAnchorRef = useRef(null); const dragDidMoveRef = useRef(false); const onSelectId = useCallback((id: string, ctrlKey: boolean): void => { if (dragDidMoveRef.current) return; // drag handled it, skip click setSelectedIds((prev) => { if (ctrlKey) { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; } if (prev.size === 1 && prev.has(id)) return new Set(); return new Set([id]); }); }, []); const onSelectMouseDown = useCallback((id: string, e: React.MouseEvent): void => { if (!e.ctrlKey || e.button !== 0) return; e.preventDefault(); dragSelectRef.current = true; dragAnchorRef.current = id; dragDidMoveRef.current = false; const onUp = (): void => { dragSelectRef.current = false; dragAnchorRef.current = null; dragDidMoveRef.current = false; window.removeEventListener("mouseup", onUp); }; window.addEventListener("mouseup", onUp); }, []); const onSelectMouseEnter = useCallback((id: string): void => { if (!dragSelectRef.current) return; if (!dragDidMoveRef.current) { dragDidMoveRef.current = true; // Add anchor item now that we know it's a drag const anchor = dragAnchorRef.current; if (anchor) { setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; }); } } setSelectedIds((prev) => { if (prev.has(id)) return prev; const next = new Set(prev); next.add(id); return next; }); }, []); const showLinksPopup = useCallback((packageId: string, itemId?: string): void => { const sel = selectedIds; const currentPackages = snapshotRef.current.session.packages; const currentItems = snapshotRef.current.session.items; // Multi-select: collect links from all selected packages/items if (sel.size > 1) { const allLinks: { name: string; url: string }[] = []; for (const id of sel) { const pkg = currentPackages[id]; if (pkg) { for (const iid of pkg.itemIds) { const item = currentItems[iid]; if (item) allLinks.push({ name: item.fileName, url: item.url }); } } else { const item = currentItems[id]; if (item) allLinks.push({ name: item.fileName, url: item.url }); } } setLinkPopup({ title: `${sel.size} ausgewählt`, links: allLinks, isPackage: allLinks.length > 1 }); setContextMenu(null); return; } const pkg = currentPackages[packageId]; if (!pkg) { return; } if (itemId) { const item = currentItems[itemId]; if (item) { setLinkPopup({ title: item.fileName, links: [{ name: item.fileName, url: item.url }], isPackage: false }); } } else { const links = pkg.itemIds .map((id) => currentItems[id]) .filter(Boolean) .map((item) => ({ name: item.fileName, url: item.url })); setLinkPopup({ title: pkg.name, links, isPackage: true }); } setContextMenu(null); }, [selectedIds]); const schedules = settingsDraft.bandwidthSchedules ?? []; useEffect(() => { setScheduleSpeedInputs((prev) => { const syncFromSettings = !settingsDirtyRef.current; let changed = false; const next: Record = {}; for (let index = 0; index < schedules.length; index += 1) { const schedule = schedules[index]; const key = schedule.id || `schedule-${index}`; const normalized = formatMbpsInputFromKbps(schedule.speedLimitKbps); if (syncFromSettings || !Object.prototype.hasOwnProperty.call(prev, key)) { next[key] = normalized; if (prev[key] !== normalized) { changed = true; } } else { next[key] = prev[key]; } } const prevKeys = Object.keys(prev); if (prevKeys.length !== Object.keys(next).length) { changed = true; } return changed ? next : prev; }); }, [schedules, settingsDirty]); const addSchedule = (): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.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; panelDirtyRevisionRef.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; panelDirtyRevisionRef.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 closeMenus = (): void => { setOpenMenu(null); setOpenSubmenu(null); }; useEffect(() => { if (!contextMenu) { return; } const close = (): void => setContextMenu(null); window.addEventListener("click", close); window.addEventListener("contextmenu", close); return () => { window.removeEventListener("click", close); window.removeEventListener("contextmenu", close); }; }, [contextMenu]); useLayoutEffect(() => { if (!contextMenu || !ctxMenuRef.current) return; const el = ctxMenuRef.current; const rect = el.getBoundingClientRect(); if (rect.bottom > window.innerHeight) { el.style.top = `${Math.max(0, contextMenu.y - rect.height)}px`; } if (rect.right > window.innerWidth) { el.style.left = `${Math.max(0, contextMenu.x - rect.width)}px`; } }, [contextMenu]); useEffect(() => { if (!colHeaderCtx) return; const close = (e: MouseEvent): void => { // Don't close if click is inside the menu or on the header bar (re-position instead) if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return; if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return; setColHeaderCtx(null); }; window.addEventListener("mousedown", close); return () => { window.removeEventListener("mousedown", close); }; }, [colHeaderCtx]); useLayoutEffect(() => { if (!colHeaderCtx || !colHeaderCtxRef.current) return; const el = colHeaderCtxRef.current; const rect = el.getBoundingClientRect(); if (rect.bottom > window.innerHeight) { el.style.top = `${Math.max(0, colHeaderCtx.y - rect.height)}px`; } if (rect.right > window.innerWidth) { el.style.left = `${Math.max(0, colHeaderCtx.x - rect.width)}px`; } }, [colHeaderCtx]); useEffect(() => { if (!historyCtxMenu) return; const close = (): void => setHistoryCtxMenu(null); window.addEventListener("click", close); window.addEventListener("contextmenu", close); return () => { window.removeEventListener("click", close); window.removeEventListener("contextmenu", close); }; }, [historyCtxMenu]); useLayoutEffect(() => { if (!historyCtxMenu || !historyCtxMenuRef.current) return; const el = historyCtxMenuRef.current; const rect = el.getBoundingClientRect(); if (rect.bottom > window.innerHeight) { el.style.top = `${Math.max(0, historyCtxMenu.y - rect.height)}px`; } if (rect.right > window.innerWidth) { el.style.left = `${Math.max(0, historyCtxMenu.x - rect.width)}px`; } }, [historyCtxMenu]); const executeDeleteSelection = useCallback((ids: Set): void => { const current = snapshotRef.current; for (const id of ids) { if (current.session.items[id]) void window.rd.removeItem(id); else if (current.session.packages[id]) void window.rd.cancelPackage(id); } setSelectedIds(new Set()); }, []); const requestDeleteSelection = useCallback((): void => { if (selectedIds.size === 0) return; if (!settingsDraft.confirmDeleteSelection) { executeDeleteSelection(selectedIds); return; } setDeleteConfirm({ ids: new Set(selectedIds), dontAsk: false }); }, [selectedIds, settingsDraft.confirmDeleteSelection, executeDeleteSelection]); useEffect(() => { const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { const target = e.target as HTMLElement; if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { // Don't clear selection if an overlay is open — let the overlay close first if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return; if (tabRef.current === "downloads") setSelectedIds(new Set()); else if (tabRef.current === "history") setSelectedHistoryIds(new Set()); } } if (e.key === "Delete" && tabRef.current === "downloads" && selectedIds.size > 0) { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; e.preventDefault(); requestDeleteSelection(); } }; const onDown = (e: MouseEvent): void => { const target = e.target as HTMLElement; if (target.closest(".package-card") || target.closest(".ctx-menu") || target.closest(".modal-backdrop") || target.closest(".modal-card")) return; setSelectedIds(new Set()); }; window.addEventListener("keydown", onKey); window.addEventListener("mousedown", onDown); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); }; }, [selectedIds, requestDeleteSelection]); const onExportBackup = async (): Promise => { closeMenus(); try { const result = await window.rd.exportBackup(); if (result.saved) { showToast("Sicherung exportiert"); } } catch (error) { showToast(`Sicherung fehlgeschlagen: ${String(error)}`, 2600); } }; const onImportBackup = async (): Promise => { closeMenus(); try { const result = await window.rd.importBackup(); if (result.restored) { showToast(result.message, 4000); } else if (result.message !== "Abgebrochen") { showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); } } catch (error) { showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600); } }; const onMenuRestart = (): void => { closeMenus(); void window.rd.restart(); }; const onMenuQuit = (): void => { closeMenus(); void window.rd.quit(); }; useEffect(() => { const handler = (e: globalThis.KeyboardEvent): void => { if (e.ctrlKey && !e.altKey && !e.metaKey) { const target = e.target as HTMLElement; const inInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; if (e.shiftKey && e.key.toLowerCase() === "r") { if (inInput) return; e.preventDefault(); void window.rd.restart(); return; } if (!e.shiftKey && e.key.toLowerCase() === "q") { if (inInput) return; e.preventDefault(); void window.rd.quit(); return; } if (!e.shiftKey && e.key.toLowerCase() === "l") { if (inInput) return; e.preventDefault(); setTab("collector"); setOpenMenu(null); return; } if (!e.shiftKey && e.key.toLowerCase() === "p") { if (inInput) return; e.preventDefault(); setTab("settings"); setOpenMenu(null); return; } if (!e.shiftKey && e.key.toLowerCase() === "o") { if (inInput) return; e.preventDefault(); setOpenMenu(null); void onImportDlcRef.current(); return; } if (!e.shiftKey && e.key.toLowerCase() === "a") { if (inInput) return; if (tabRef.current === "downloads") { e.preventDefault(); setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages))); } else if (tabRef.current === "history") { e.preventDefault(); setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id))); } return; } } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, []); useEffect(() => { if (!openMenu) { return; } const handler = (e: MouseEvent): void => { const target = e.target as HTMLElement; if (!target.closest(".menu-bar")) { setOpenMenu(null); setOpenSubmenu(null); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [openMenu]); const packageSpeedMap = useMemo(() => { const map = new Map(); for (const [pid, bps] of Object.entries(snapshot.packageSpeedBps)) { if (bps > 0) map.set(pid, bps); } return map; }, [snapshot.packageSpeedBps]); const itemStatusCounts = useMemo(() => { const counts = { downloading: 0, queued: 0, failed: 0 }; for (const item of Object.values(snapshot.session.items)) { if (item.status === "downloading") { counts.downloading += 1; } else if (item.status === "queued" || item.status === "reconnect_wait") { counts.queued += 1; } else if (item.status === "failed") { counts.failed += 1; } } return counts; }, [snapshot.session.items]); const providerStats = useMemo(() => { const stats: Record = {}; for (const item of Object.values(snapshot.session.items)) { const hoster = extractHoster(item.url) || "unknown"; if (!stats[hoster]) { stats[hoster] = { total: 0, completed: 0, failed: 0, bytes: 0 }; } stats[hoster].total += 1; if (item.status === "completed") stats[hoster].completed += 1; if (item.status === "failed") stats[hoster].failed += 1; stats[hoster].bytes += item.downloadedBytes; } return Object.entries(stats); }, [snapshot.session.items]); return (
{ event.preventDefault(); if (draggedPackageIdRef.current) { return; } const hasFiles = event.dataTransfer.types.includes("Files"); const hasUri = event.dataTransfer.types.includes("text/uri-list"); if (!hasFiles && !hasUri) { 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} >
{snapshot.reconnectSeconds > 0 && (
Reconnect: {snapshot.reconnectSeconds}s
)}
{tab === "collector" && (

Linksammler

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