release: publish Multi-Debrid Downloader v2.0.15

Synchronize live speed telemetry, preserve known package sizes, refine responsive download status presentation, improve progress contrast and accessibility, simplify account creation, and prepare the verified v2.0.15 desktop release.
This commit is contained in:
Sucukdeluxe
2026-08-10 20:38:11 +02:00
parent a5758aa905
commit b670b92147
20 changed files with 452 additions and 172 deletions
+4 -3
View File
@@ -67,7 +67,8 @@ import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logP
import { logRenameEvent as writeRenameLogEvent } from "./rename-log";
import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerification } from "./desktop-rename-log";
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
import { mergeKnownTotalBytes } from "./download-size";
type ActiveTask = {
itemId: string;
@@ -9044,7 +9045,7 @@ export class DownloadManager extends EventEmitter {
? existingTargetPath
: path.join(pkg.outputDir, item.fileName);
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
item.totalBytes = unrestricted.fileSize;
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
item.status = "downloading";
const pLabel = unrestricted.providerLabel;
const statusLabel = providerLabel(unrestricted.provider) || pLabel;
@@ -9122,7 +9123,7 @@ export class DownloadManager extends EventEmitter {
item.status = "integrity_check";
item.progressPercent = 0;
item.downloadedBytes = 0;
item.totalBytes = unrestricted.fileSize;
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
this.emitState();
await sleep(300);
continue;
+12
View File
@@ -0,0 +1,12 @@
export function mergeKnownTotalBytes(
currentTotalBytes: number | null | undefined,
replacementTotalBytes: number | null | undefined
): number | null {
if (replacementTotalBytes != null && Number.isFinite(replacementTotalBytes) && replacementTotalBytes > 0) {
return replacementTotalBytes;
}
if (currentTotalBytes != null && Number.isFinite(currentTotalBytes) && currentTotalBytes > 0) {
return currentTotalBytes;
}
return null;
}
+25 -42
View File
@@ -90,7 +90,7 @@ import {
StatisticsSidebarStatus,
type StatisticsViewActions
} from "./views/statistics/StatisticsView";
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
import {
@@ -1082,7 +1082,14 @@ const historyRetentionLabels: Record<AppSettings["historyRetentionMode"], string
permanent: "Dauerhaft"
};
const AUTO_RENDER_PACKAGE_LIMIT = 260;
const AUTO_RENDER_PACKAGE_LIMIT = 260;
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
let delay = itemCount >= 700 ? 0 : itemCount >= 250 ? 50 : 100;
if (!running) delay = Math.min(delay, 200);
if (activeTab !== "downloads") delay = Math.max(delay, 800);
return delay;
}
const KNOWN_HOSTERS: { id: string; label: string }[] = [
{ id: "rapidgator", label: "Rapidgator" },
@@ -1304,7 +1311,7 @@ export function readBandwidthChartPalette(
return {
grid: readProperty("--ui-border").trim(),
text: readProperty("--ui-text-muted").trim(),
accent: readProperty("--ui-accent").trim(),
accent: readProperty("--ui-speed-accent").trim(),
fontFamily: fontFamily.trim()
};
}
@@ -1507,20 +1514,15 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
});
interface DownloadSpeedSparklineProps {
items: Record<string, DownloadItem>;
running: boolean;
paused: boolean;
speedBps: number;
speedStateRef: React.MutableRefObject<DownloadSpeedHistoryState>;
hidden?: boolean;
}
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement {
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const itemsRef = useRef(items);
const activeRef = useRef(running && !paused);
const [liveSpeed, setLiveSpeed] = useState(0);
itemsRef.current = items;
activeRef.current = running && !paused;
const speedRef = useRef(speedBps);
speedRef.current = speedBps;
useEffect(() => {
const draw = (): void => {
@@ -1571,17 +1573,11 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.stroke();
};
const tick = (): void => {
let target = 0;
if (activeRef.current) {
for (const it of Object.values(itemsRef.current)) {
if (it.status === "downloading") target += it.speedBps || 0;
}
}
};
const tick = (): void => {
const target = speedRef.current;
speedStateRef.current = updateDownloadSpeedHistory(speedStateRef.current, target);
setLiveSpeed(target);
draw();
};
@@ -1592,7 +1588,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
return (
<div className={`speed-sparkline${hidden ? " speed-sparkline-hidden" : ""}`} aria-hidden={hidden} title="Aktuelle Download-Geschwindigkeit (geglättet)">
<canvas ref={canvasRef} className="speed-sparkline-canvas" />
<span className="speed-sparkline-value">{liveSpeed > 0 ? formatSpeedMbps(liveSpeed) : "0 B/s"}</span>
<span className="speed-sparkline-value">{speedBps > 0 ? formatSpeedMbps(speedBps) : "0 B/s"}</span>
</div>
);
});
@@ -2165,20 +2161,8 @@ export function App(): ReactElement {
latestStateRef.current = merged;
if (stateFlushTimerRef.current) { return; }
const itemCount = Object.keys(merged.session.items).length;
let flushDelay = itemCount >= 1500
? 900
: itemCount >= 700
? 650
: itemCount >= 250
? 400
: 150;
if (!merged.session.running) {
flushDelay = Math.min(flushDelay, 200);
}
if (activeTabRef.current !== "downloads") {
flushDelay = Math.max(flushDelay, 800);
}
const itemCount = Object.keys(merged.session.items).length;
const flushDelay = getSnapshotRenderDelay(itemCount, merged.session.running, activeTabRef.current);
stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null;
@@ -4961,6 +4945,7 @@ export function App(): ReactElement {
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
const liveDownloadSpeedBps = useMemo(() => getDownloadSpeedBps(snapshot.packageSpeedBps), [snapshot.packageSpeedBps]);
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
...downloadsViewCore,
@@ -4992,10 +4977,10 @@ export function App(): ReactElement {
total: humanSize(downloadQueueTotalBytes),
totalBytes: downloadQueueTotalBytes,
hosters: providerStats.length,
speed: snapshot.speedText,
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const downloadsActions: DownloadsViewActions = {
onDisplayModeChange: setDownloadDisplayMode,
@@ -5651,9 +5636,7 @@ export function App(): ReactElement {
headerActions={(
<>
<DownloadSpeedSparkline
items={snapshot.session.items}
running={snapshot.session.running}
paused={snapshot.session.paused}
speedBps={liveDownloadSpeedBps}
speedStateRef={speedSparklineStateRef}
hidden={tab !== "downloads"}
/>
+4
View File
@@ -29,6 +29,10 @@ export function compactProviderLabels(labels: string[]): string {
return [...groups].map(([base, details]) => details.length === 0 ? base : `${base} (${details.join(" + ")})`).join(", ");
}
export function normalizeDownloadServiceLabel(label: string): string {
return label.replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1");
}
export function formatDateTime(timestamp: number): string {
if (!timestamp) return "";
const date = new Date(timestamp);
+1 -1
View File
@@ -604,7 +604,7 @@
overflow-y: auto;
}
.md-application-menu-tree .menu-bar-item:last-child > .menu-dropdown {
.md-application-menu-tree > .menu-bar-item > .menu-dropdown {
right: 0;
left: auto;
}
+6
View File
@@ -14,6 +14,9 @@
--ui-primary: #D6D6D6;
--ui-primary-hover: #E6E6E6;
--ui-accent: #4A4A4A;
--ui-speed-accent: #F2942D;
--ui-progress-track-text: #FFFFFF;
--ui-progress-fill-text: #181A1F;
--ui-success: #4ADE80;
--ui-warning: #F1C786;
--ui-danger: #F06464;
@@ -38,6 +41,9 @@
--ui-primary: #3A3A3A;
--ui-primary-hover: #202020;
--ui-accent: #5E5E5E;
--ui-speed-accent: #C2701A;
--ui-progress-track-text: #181A1F;
--ui-progress-fill-text: #181A1F;
--ui-success: #1E9E55;
--ui-warning: #E8B85D;
--ui-danger: #D94747;
+53 -18
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
import type { DownloadItem } from "../../../shared/types";
import {
compactProviderLabels,
@@ -8,6 +8,7 @@ import {
formatHosterLabel,
formatSpeedMbps,
humanSize,
normalizeDownloadServiceLabel,
providerLabels
} from "../../download-format";
import type { DownloadPackageRow } from "./downloads-model";
@@ -45,7 +46,7 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
status: { label: "Status", width: "minmax(90px, 0.85fr)" },
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
@@ -117,6 +118,35 @@ function progress(value: number): number {
return Math.max(0, Math.min(100, Math.round(value || 0)));
}
export function compactDownloadStatus(value: string): string {
const status = value.trim();
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
if (/Download läuft\b/i.test(status)) return "DL läuft";
const extracting = status.match(/(Entpacken\s+\d+%)/i);
return extracting?.[1] ?? status;
}
function DownloadMeter({ value, text }: { value: number; text: string }): ReactElement {
const normalized = progress(value);
const style = { "--downloads-progress": `${normalized}%` } as CSSProperties;
return (
<span aria-label={text} aria-valuemax={100} aria-valuemin={0} aria-valuenow={normalized} className="downloads-meter" role="progressbar" style={style}>
<span className="downloads-meter-fill" />
<b aria-hidden="true" className="downloads-meter-label is-track">{text}</b>
<span aria-hidden="true" className="downloads-meter-filled-clip"><b className="downloads-meter-label is-filled">{text}</b></span>
</span>
);
}
function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement {
return (
<span aria-label={status} className="downloads-cell downloads-status-cell" title={title || status}>
<span aria-hidden="true" className="downloads-status-full">{status}</span>
<span aria-hidden="true" className="downloads-status-compact">{compactDownloadStatus(status)}</span>
</span>
);
}
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
const displayStatus = displayedStatus(item, sessionRunning);
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
@@ -130,20 +160,24 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
if (column === "size") {
const total = item.totalBytes || item.downloadedBytes || 0;
const value = total > 0 ? progress((item.downloadedBytes / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(item.downloadedBytes)} / {humanSize(total)}</b></span> : null}</span>;
const text = `${humanSize(item.downloadedBytes)} / ${humanSize(total)}`;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <DownloadMeter text={text} value={value} /> : null}</span>;
}
if (column === "progress") {
const value = progress(item.progressPercent);
return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{value}%</b></span></span>;
return <span className="downloads-cell downloads-progress-cell"><DownloadMeter text={`${value}%`} value={value} /></span>;
}
if (column === "hoster") {
const hoster = extractHoster(item.url);
const label = formatHosterLabel(hoster);
return <HosterLabels labels={[label]} />;
}
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
if (column === "account") {
const full = item.providerLabel || (item.provider ? providerLabels[item.provider] : "");
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
}
if (column === "prio") return <span className="downloads-cell" />;
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />;
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
if (column === "availability") {
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
@@ -227,12 +261,13 @@ interface PackageItemsTransitionProps {
collapsed: boolean;
columnOrder: readonly string[];
gridTemplate: string;
id: string;
items: DownloadItem[];
selectedIds: ReadonlySet<string>;
sessionRunning: boolean;
}
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, id, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
const [renderItems, setRenderItems] = useState(!collapsed);
const animationRef = useRef<Animation | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -278,6 +313,7 @@ function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate,
<div
aria-hidden={collapsed}
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
id={id}
ref={containerRef}
>
<div className="downloads-package-items-inner" ref={innerRef}>
@@ -327,7 +363,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
if (column === "name") {
return (
<span className="downloads-cell downloads-name-cell">
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
<button aria-controls={`downloads-package-items-${entry.id}`} aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
{editing
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
@@ -347,21 +383,24 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
const value = total > 0 ? progress((downloaded / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(downloaded)} / {humanSize(total)}</b></span> : null}</span>;
const text = `${humanSize(downloaded)} / ${humanSize(total)}`;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <DownloadMeter text={text} value={value} /> : null}</span>;
}
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><DownloadMeter text={`${stats.value}%`} value={stats.value} /></span>;
if (column === "hoster") {
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
return <HosterLabels labels={labels} />;
}
if (column === "account") {
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
return <span className="downloads-cell" title={value}>{value}</span>;
const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
}
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
if (column === "status") {
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
const status = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
const title = audio?.tooltip ? `${status}\n${audio.tooltip}` : status;
return <DownloadStatusCell status={status} title={title} />;
}
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
if (column === "availability") {
@@ -418,13 +457,9 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
role="row"
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey);
return;
}
if (target.closest("button, input, select")) return;
actions.onTogglePackageCollapse(entry.id);
}}
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
@@ -433,7 +468,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button"></button></span>
</div>
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} id={`downloads-package-items-${entry.id}`} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
</article>
);
}
@@ -119,8 +119,6 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
<span className="downloads-toolbar-divider" />
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
@@ -82,6 +82,14 @@ export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): numbe
}
return total;
}
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
let total = 0;
for (const speed of Object.values(packageSpeeds)) {
if (Number.isFinite(speed) && speed > 0) total += speed;
}
return total;
}
function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
+38 -5
View File
@@ -621,14 +621,16 @@
background: var(--ui-modal-secondary);
}
.downloads-meter > span {
.downloads-meter-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--downloads-progress);
background: var(--ui-success);
}
.downloads-meter > b {
position: relative;
.downloads-meter-label {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
@@ -638,8 +640,39 @@
font-weight: 600;
}
.downloads-item-row .downloads-meter > b {
color: var(--ui-text);
.downloads-meter-label.is-track {
color: var(--ui-progress-track-text, #fff);
}
.downloads-meter-filled-clip {
position: absolute;
inset: 0;
z-index: 2;
clip-path: inset(0 calc(100% - var(--downloads-progress)) 0 0);
pointer-events: none;
}
.downloads-meter-label.is-filled {
color: var(--ui-progress-fill-text, #171a1f);
}
.downloads-status-compact {
display: none;
}
.md-shell.is-compact .downloads-view,
.md-shell.is-minimum .downloads-view {
--downloads-status-min: 90px;
}
.md-shell.is-compact .downloads-status-full,
.md-shell.is-minimum .downloads-status-full {
display: none;
}
.md-shell.is-compact .downloads-status-compact,
.md-shell.is-minimum .downloads-status-compact {
display: inline;
}
.downloads-empty-state,
@@ -1,6 +1,5 @@
import {
cloneElement,
type ChangeEvent,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
@@ -482,9 +481,7 @@ export function AccountAddDialog({
model: AccountAddDialogModel;
actions: AccountAddDialogActions;
}): ReactElement | null {
const onFilterChange = (event: ChangeEvent<HTMLSelectElement>): void => {
actions.onFilterChange(event.target.value as AccountAddFilter);
};
const selectedOption = model.options.find((option) => option.id === model.selectedOptionId);
return (
<Dialog
actions={(
@@ -501,49 +498,34 @@ export function AccountAddDialog({
size="account"
title="Account hinzufügen"
>
<div className="settings-account-picker-controls">
<input
aria-label="Accounts durchsuchen"
<label className="settings-account-picker-selector">
<span>Dienst / Zugangstyp</span>
<select
aria-label="Dienst / Zugangstyp"
className="settings-control"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Dienst oder Zugangstyp suchen"
type="search"
value={model.query}
/>
<select aria-label="Account-Typ filtern" className="settings-control" onChange={onFilterChange} value={model.filter}>
<option value="all">Alle</option>
<option value="api">API</option>
<option value="web">Web</option>
onChange={(event) => actions.onOptionSelect(event.target.value)}
value={model.selectedOptionId ?? ""}
>
{model.options.map((option) => (
<option key={option.id} value={option.id}>{option.title} · {option.mode}</option>
))}
</select>
</div>
<div aria-label="Verfügbare Account-Typen" className="settings-account-picker" role="listbox">
{model.options.length === 0 ? (
<span className="settings-account-picker-empty">Keine passenden Account-Typen.</span>
) : model.options.map((option) => {
const selected = option.id === model.selectedOptionId;
return (
<div className="settings-account-picker-entry" key={option.id}>
<button
aria-selected={selected}
className={`settings-account-picker-row${selected ? " is-selected" : ""}`}
onClick={() => actions.onOptionSelect(option.id)}
role="option"
type="button"
>
<span>
<strong>{option.title}</strong>
<small>{option.description}</small>
</span>
<span>
<strong>{option.mode}</strong>
<small>{option.functionLabel}</small>
</span>
</button>
{selected ? <AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} /> : null}
</label>
{selectedOption ? (
<>
<div className="settings-account-option-meta">
<div>
<strong>{selectedOption.title}</strong>
<span>{selectedOption.description}</span>
</div>
);
})}
</div>
<div>
<strong>{selectedOption.mode}</strong>
<span>{selectedOption.functionLabel}</span>
</div>
</div>
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
</>
) : null}
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
</Dialog>
);
+13 -37
View File
@@ -589,7 +589,7 @@
}
.settings-account-status-badge.is-ok::before {
background: var(--ui-primary);
background: var(--ui-success);
}
.settings-account-status-badge.is-free::before,
@@ -739,52 +739,36 @@
min-height: 0;
}
.settings-account-picker-controls {
.settings-account-picker-selector {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 8px;
gap: 7px;
}
.settings-account-picker {
display: grid;
max-height: 430px;
min-height: 0;
gap: 6px;
overflow-y: auto;
.settings-account-picker-selector > span {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.settings-account-picker-entry {
display: grid;
gap: 10px;
}
.settings-account-picker-row {
.settings-account-option-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 12px;
width: 100%;
min-height: 52px;
padding: 8px 10px;
padding: 10px 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-account-picker-row.is-selected {
border-color: var(--ui-accent);
background: var(--ui-active);
}
.settings-account-picker-row > span {
.settings-account-option-meta > div {
display: grid;
min-width: 0;
gap: 2px;
}
.settings-account-picker-row small {
.settings-account-option-meta span {
overflow: hidden;
color: var(--ui-text-muted);
font-size: 12px;
@@ -792,16 +776,9 @@
white-space: nowrap;
}
.settings-account-picker-empty {
padding: 24px;
color: var(--ui-text-muted);
text-align: center;
}
.settings-account-dialog-fields {
display: grid;
gap: 14px;
padding: 2px 10px 14px;
}
.settings-account-dialog-field {
@@ -873,8 +850,7 @@
}
.settings-theme-options,
.settings-account-picker-controls,
.settings-account-picker-row {
.settings-account-option-meta {
grid-template-columns: minmax(0, 1fr);
}
}