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:
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.15] - 2026-08-10
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Unified live speed reporting across the header, package table, and sidebar with a single telemetry source and consistent two-decimal formatting.
|
||||||
|
- Reworked account creation into one clear service and access-type selector with the relevant credentials form directly below it.
|
||||||
|
- Improved dense download views with responsive status labels, clearer progress text, and safer package expansion behavior.
|
||||||
|
|
||||||
|
### Downloads and telemetry
|
||||||
|
|
||||||
|
- Removed the redundant Add links toolbar action so Start is now the first download control.
|
||||||
|
- Removed the additional renderer delay for large active queues, allowing manager telemetry to appear without a second buffering interval.
|
||||||
|
- Kept the header sparkline, package speeds, and sidebar speed synchronized from the same package telemetry snapshot.
|
||||||
|
- Preserved known file sizes when an unrestrict response does not provide a replacement size, preventing temporary queue-total drops when a download starts.
|
||||||
|
- Restricted package expansion and collapse to the visible disclosure button so ordinary row clicks no longer change the package state.
|
||||||
|
- Added compact window labels for link conversion, active downloads, and extraction while retaining complete status details in tooltips and accessibility labels.
|
||||||
|
- Removed duplicated access-mode wording from Mega-Debrid service labels while preserving the complete source label as a tooltip.
|
||||||
|
|
||||||
|
### Interface and accessibility
|
||||||
|
|
||||||
|
- Added clipped dual-color progress labels so text remains light over the unfilled track and dark over the green fill.
|
||||||
|
- Added a dedicated orange bandwidth-chart accent with a restrained matching area fill.
|
||||||
|
- Changed active premium account indicators to the success color.
|
||||||
|
- Kept the File, Settings, and Help menus inside the application frame at narrow window widths.
|
||||||
|
- Added semantic progressbar values and accessible labels to package and file size/progress meters.
|
||||||
|
|
||||||
|
### Settings and accounts
|
||||||
|
|
||||||
|
- Replaced the expandable account-type list with a single service/access selector.
|
||||||
|
- Displayed the selected account type description and exactly one matching credentials form.
|
||||||
|
- Retained the existing account validation, protected secret fields, and save flow.
|
||||||
|
|
||||||
|
### Reliability and testing
|
||||||
|
|
||||||
|
- Added regression coverage for stable file-size transitions, unified speed telemetry, responsive status presentation, service-label cleanup, progress contrast, package disclosure behavior, account selection, premium status colors, chart colors, toolbar ordering, and narrow application menus.
|
||||||
|
|
||||||
## [2.0.14] - 2026-08-10
|
## [2.0.14] - 2026-08-10
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.14",
|
"version": "2.0.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.14",
|
"version": "2.0.15",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.14",
|
"version": "2.0.15",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { logRenameEvent as writeRenameLogEvent } from "./rename-log";
|
|||||||
import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerification } from "./desktop-rename-log";
|
import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerification } from "./desktop-rename-log";
|
||||||
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
|
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 = {
|
type ActiveTask = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -9044,7 +9045,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
? existingTargetPath
|
? existingTargetPath
|
||||||
: path.join(pkg.outputDir, item.fileName);
|
: path.join(pkg.outputDir, item.fileName);
|
||||||
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
|
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
|
||||||
item.totalBytes = unrestricted.fileSize;
|
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
|
||||||
item.status = "downloading";
|
item.status = "downloading";
|
||||||
const pLabel = unrestricted.providerLabel;
|
const pLabel = unrestricted.providerLabel;
|
||||||
const statusLabel = providerLabel(unrestricted.provider) || pLabel;
|
const statusLabel = providerLabel(unrestricted.provider) || pLabel;
|
||||||
@@ -9122,7 +9123,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.status = "integrity_check";
|
item.status = "integrity_check";
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.totalBytes = unrestricted.fileSize;
|
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
|
||||||
this.emitState();
|
this.emitState();
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+20
-37
@@ -90,7 +90,7 @@ import {
|
|||||||
StatisticsSidebarStatus,
|
StatisticsSidebarStatus,
|
||||||
type StatisticsViewActions
|
type StatisticsViewActions
|
||||||
} from "./views/statistics/StatisticsView";
|
} 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 { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
||||||
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
|
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
|
||||||
import {
|
import {
|
||||||
@@ -1084,6 +1084,13 @@ const historyRetentionLabels: Record<AppSettings["historyRetentionMode"], string
|
|||||||
|
|
||||||
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 }[] = [
|
const KNOWN_HOSTERS: { id: string; label: string }[] = [
|
||||||
{ id: "rapidgator", label: "Rapidgator" },
|
{ id: "rapidgator", label: "Rapidgator" },
|
||||||
{ id: "uploaded", label: "Uploaded" },
|
{ id: "uploaded", label: "Uploaded" },
|
||||||
@@ -1304,7 +1311,7 @@ export function readBandwidthChartPalette(
|
|||||||
return {
|
return {
|
||||||
grid: readProperty("--ui-border").trim(),
|
grid: readProperty("--ui-border").trim(),
|
||||||
text: readProperty("--ui-text-muted").trim(),
|
text: readProperty("--ui-text-muted").trim(),
|
||||||
accent: readProperty("--ui-accent").trim(),
|
accent: readProperty("--ui-speed-accent").trim(),
|
||||||
fontFamily: fontFamily.trim()
|
fontFamily: fontFamily.trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1507,20 +1514,15 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface DownloadSpeedSparklineProps {
|
interface DownloadSpeedSparklineProps {
|
||||||
items: Record<string, DownloadItem>;
|
speedBps: number;
|
||||||
running: boolean;
|
|
||||||
paused: boolean;
|
|
||||||
speedStateRef: React.MutableRefObject<DownloadSpeedHistoryState>;
|
speedStateRef: React.MutableRefObject<DownloadSpeedHistoryState>;
|
||||||
hidden?: boolean;
|
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const itemsRef = useRef(items);
|
const speedRef = useRef(speedBps);
|
||||||
const activeRef = useRef(running && !paused);
|
speedRef.current = speedBps;
|
||||||
const [liveSpeed, setLiveSpeed] = useState(0);
|
|
||||||
itemsRef.current = items;
|
|
||||||
activeRef.current = running && !paused;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draw = (): void => {
|
const draw = (): void => {
|
||||||
@@ -1574,14 +1576,8 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tick = (): void => {
|
const tick = (): void => {
|
||||||
let target = 0;
|
const target = speedRef.current;
|
||||||
if (activeRef.current) {
|
|
||||||
for (const it of Object.values(itemsRef.current)) {
|
|
||||||
if (it.status === "downloading") target += it.speedBps || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
speedStateRef.current = updateDownloadSpeedHistory(speedStateRef.current, target);
|
speedStateRef.current = updateDownloadSpeedHistory(speedStateRef.current, target);
|
||||||
setLiveSpeed(target);
|
|
||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1592,7 +1588,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
|
|||||||
return (
|
return (
|
||||||
<div className={`speed-sparkline${hidden ? " speed-sparkline-hidden" : ""}`} aria-hidden={hidden} title="Aktuelle Download-Geschwindigkeit (geglättet)">
|
<div className={`speed-sparkline${hidden ? " speed-sparkline-hidden" : ""}`} aria-hidden={hidden} title="Aktuelle Download-Geschwindigkeit (geglättet)">
|
||||||
<canvas ref={canvasRef} className="speed-sparkline-canvas" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2166,19 +2162,7 @@ export function App(): ReactElement {
|
|||||||
if (stateFlushTimerRef.current) { return; }
|
if (stateFlushTimerRef.current) { return; }
|
||||||
|
|
||||||
const itemCount = Object.keys(merged.session.items).length;
|
const itemCount = Object.keys(merged.session.items).length;
|
||||||
let flushDelay = itemCount >= 1500
|
const flushDelay = getSnapshotRenderDelay(itemCount, merged.session.running, activeTabRef.current);
|
||||||
? 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
stateFlushTimerRef.current = setTimeout(() => {
|
stateFlushTimerRef.current = setTimeout(() => {
|
||||||
stateFlushTimerRef.current = null;
|
stateFlushTimerRef.current = null;
|
||||||
@@ -4961,6 +4945,7 @@ export function App(): ReactElement {
|
|||||||
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
|
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
|
||||||
|
|
||||||
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
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 downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||||
...downloadsViewCore,
|
...downloadsViewCore,
|
||||||
@@ -4992,10 +4977,10 @@ export function App(): ReactElement {
|
|||||||
total: humanSize(downloadQueueTotalBytes),
|
total: humanSize(downloadQueueTotalBytes),
|
||||||
totalBytes: downloadQueueTotalBytes,
|
totalBytes: downloadQueueTotalBytes,
|
||||||
hosters: providerStats.length,
|
hosters: providerStats.length,
|
||||||
speed: snapshot.speedText,
|
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||||
eta: snapshot.etaText
|
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 = {
|
const downloadsActions: DownloadsViewActions = {
|
||||||
onDisplayModeChange: setDownloadDisplayMode,
|
onDisplayModeChange: setDownloadDisplayMode,
|
||||||
@@ -5651,9 +5636,7 @@ export function App(): ReactElement {
|
|||||||
headerActions={(
|
headerActions={(
|
||||||
<>
|
<>
|
||||||
<DownloadSpeedSparkline
|
<DownloadSpeedSparkline
|
||||||
items={snapshot.session.items}
|
speedBps={liveDownloadSpeedBps}
|
||||||
running={snapshot.session.running}
|
|
||||||
paused={snapshot.session.paused}
|
|
||||||
speedStateRef={speedSparklineStateRef}
|
speedStateRef={speedSparklineStateRef}
|
||||||
hidden={tab !== "downloads"}
|
hidden={tab !== "downloads"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export function compactProviderLabels(labels: string[]): string {
|
|||||||
return [...groups].map(([base, details]) => details.length === 0 ? base : `${base} (${details.join(" + ")})`).join(", ");
|
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 {
|
export function formatDateTime(timestamp: number): string {
|
||||||
if (!timestamp) return "";
|
if (!timestamp) return "";
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|||||||
@@ -604,7 +604,7 @@
|
|||||||
overflow-y: auto;
|
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;
|
right: 0;
|
||||||
left: auto;
|
left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
--ui-primary: #D6D6D6;
|
--ui-primary: #D6D6D6;
|
||||||
--ui-primary-hover: #E6E6E6;
|
--ui-primary-hover: #E6E6E6;
|
||||||
--ui-accent: #4A4A4A;
|
--ui-accent: #4A4A4A;
|
||||||
|
--ui-speed-accent: #F2942D;
|
||||||
|
--ui-progress-track-text: #FFFFFF;
|
||||||
|
--ui-progress-fill-text: #181A1F;
|
||||||
--ui-success: #4ADE80;
|
--ui-success: #4ADE80;
|
||||||
--ui-warning: #F1C786;
|
--ui-warning: #F1C786;
|
||||||
--ui-danger: #F06464;
|
--ui-danger: #F06464;
|
||||||
@@ -38,6 +41,9 @@
|
|||||||
--ui-primary: #3A3A3A;
|
--ui-primary: #3A3A3A;
|
||||||
--ui-primary-hover: #202020;
|
--ui-primary-hover: #202020;
|
||||||
--ui-accent: #5E5E5E;
|
--ui-accent: #5E5E5E;
|
||||||
|
--ui-speed-accent: #C2701A;
|
||||||
|
--ui-progress-track-text: #181A1F;
|
||||||
|
--ui-progress-fill-text: #181A1F;
|
||||||
--ui-success: #1E9E55;
|
--ui-success: #1E9E55;
|
||||||
--ui-warning: #E8B85D;
|
--ui-warning: #E8B85D;
|
||||||
--ui-danger: #D94747;
|
--ui-danger: #D94747;
|
||||||
|
|||||||
@@ -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 type { DownloadItem } from "../../../shared/types";
|
||||||
import {
|
import {
|
||||||
compactProviderLabels,
|
compactProviderLabels,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
formatHosterLabel,
|
formatHosterLabel,
|
||||||
formatSpeedMbps,
|
formatSpeedMbps,
|
||||||
humanSize,
|
humanSize,
|
||||||
|
normalizeDownloadServiceLabel,
|
||||||
providerLabels
|
providerLabels
|
||||||
} from "../../download-format";
|
} from "../../download-format";
|
||||||
import type { DownloadPackageRow } from "./downloads-model";
|
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" },
|
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
|
||||||
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
|
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
|
||||||
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
|
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)" },
|
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
|
||||||
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
|
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
|
||||||
added: { label: "Hinzugefügt am", width: "minmax(135px, 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)));
|
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 {
|
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
|
||||||
const displayStatus = displayedStatus(item, sessionRunning);
|
const displayStatus = displayedStatus(item, sessionRunning);
|
||||||
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
|
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
|
||||||
@@ -130,20 +160,24 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
|
|||||||
if (column === "size") {
|
if (column === "size") {
|
||||||
const total = item.totalBytes || item.downloadedBytes || 0;
|
const total = item.totalBytes || item.downloadedBytes || 0;
|
||||||
const value = total > 0 ? progress((item.downloadedBytes / total) * 100) : 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") {
|
if (column === "progress") {
|
||||||
const value = progress(item.progressPercent);
|
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") {
|
if (column === "hoster") {
|
||||||
const hoster = extractHoster(item.url);
|
const hoster = extractHoster(item.url);
|
||||||
const label = formatHosterLabel(hoster);
|
const label = formatHosterLabel(hoster);
|
||||||
return <HosterLabels labels={[label]} />;
|
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 === "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 === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
|
||||||
if (column === "availability") {
|
if (column === "availability") {
|
||||||
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
|
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
|
||||||
@@ -227,12 +261,13 @@ interface PackageItemsTransitionProps {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
columnOrder: readonly string[];
|
columnOrder: readonly string[];
|
||||||
gridTemplate: string;
|
gridTemplate: string;
|
||||||
|
id: string;
|
||||||
items: DownloadItem[];
|
items: DownloadItem[];
|
||||||
selectedIds: ReadonlySet<string>;
|
selectedIds: ReadonlySet<string>;
|
||||||
sessionRunning: boolean;
|
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 [renderItems, setRenderItems] = useState(!collapsed);
|
||||||
const animationRef = useRef<Animation | null>(null);
|
const animationRef = useRef<Animation | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -278,6 +313,7 @@ function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate,
|
|||||||
<div
|
<div
|
||||||
aria-hidden={collapsed}
|
aria-hidden={collapsed}
|
||||||
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
|
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
|
||||||
|
id={id}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<div className="downloads-package-items-inner" ref={innerRef}>
|
<div className="downloads-package-items-inner" ref={innerRef}>
|
||||||
@@ -327,7 +363,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
if (column === "name") {
|
if (column === "name") {
|
||||||
return (
|
return (
|
||||||
<span className="downloads-cell downloads-name-cell">
|
<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
|
{editing
|
||||||
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
? <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") {
|
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 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 downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
|
||||||
const value = total > 0 ? progress((downloaded / total) * 100) : 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") {
|
if (column === "hoster") {
|
||||||
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
|
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
|
||||||
return <HosterLabels labels={labels} />;
|
return <HosterLabels labels={labels} />;
|
||||||
}
|
}
|
||||||
if (column === "account") {
|
if (column === "account") {
|
||||||
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
||||||
return <span className="downloads-cell" title={value}>{value}</span>;
|
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 === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
||||||
if (column === "status") {
|
if (column === "status") {
|
||||||
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
|
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 === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
||||||
if (column === "availability") {
|
if (column === "availability") {
|
||||||
@@ -418,13 +457,9 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
|||||||
role="row"
|
role="row"
|
||||||
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||||
actions.onToggleSelection(entry.id, 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)}
|
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
|
||||||
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
|
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>)}
|
{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>
|
<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>
|
</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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
|
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
|
||||||
return (
|
return (
|
||||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
<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.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.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): numbe
|
|||||||
return total;
|
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 {
|
function isExtracted(item: DownloadItem): boolean {
|
||||||
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -621,14 +621,16 @@
|
|||||||
background: var(--ui-modal-secondary);
|
background: var(--ui-modal-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-meter > span {
|
.downloads-meter-fill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
|
width: var(--downloads-progress);
|
||||||
background: var(--ui-success);
|
background: var(--ui-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-meter > b {
|
.downloads-meter-label {
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -638,8 +640,39 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-item-row .downloads-meter > b {
|
.downloads-meter-label.is-track {
|
||||||
color: var(--ui-text);
|
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,
|
.downloads-empty-state,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
cloneElement,
|
cloneElement,
|
||||||
type ChangeEvent,
|
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
@@ -482,9 +481,7 @@ export function AccountAddDialog({
|
|||||||
model: AccountAddDialogModel;
|
model: AccountAddDialogModel;
|
||||||
actions: AccountAddDialogActions;
|
actions: AccountAddDialogActions;
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const onFilterChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
const selectedOption = model.options.find((option) => option.id === model.selectedOptionId);
|
||||||
actions.onFilterChange(event.target.value as AccountAddFilter);
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
actions={(
|
actions={(
|
||||||
@@ -501,49 +498,34 @@ export function AccountAddDialog({
|
|||||||
size="account"
|
size="account"
|
||||||
title="Account hinzufügen"
|
title="Account hinzufügen"
|
||||||
>
|
>
|
||||||
<div className="settings-account-picker-controls">
|
<label className="settings-account-picker-selector">
|
||||||
<input
|
<span>Dienst / Zugangstyp</span>
|
||||||
aria-label="Accounts durchsuchen"
|
<select
|
||||||
|
aria-label="Dienst / Zugangstyp"
|
||||||
className="settings-control"
|
className="settings-control"
|
||||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
onChange={(event) => actions.onOptionSelect(event.target.value)}
|
||||||
placeholder="Dienst oder Zugangstyp suchen"
|
value={model.selectedOptionId ?? ""}
|
||||||
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>
|
|
||||||
</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>
|
{model.options.map((option) => (
|
||||||
<strong>{option.title}</strong>
|
<option key={option.id} value={option.id}>{option.title} · {option.mode}</option>
|
||||||
<small>{option.description}</small>
|
))}
|
||||||
</span>
|
</select>
|
||||||
<span>
|
</label>
|
||||||
<strong>{option.mode}</strong>
|
{selectedOption ? (
|
||||||
<small>{option.functionLabel}</small>
|
<>
|
||||||
</span>
|
<div className="settings-account-option-meta">
|
||||||
</button>
|
<div>
|
||||||
{selected ? <AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} /> : null}
|
<strong>{selectedOption.title}</strong>
|
||||||
|
<span>{selectedOption.description}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div>
|
||||||
})}
|
<strong>{selectedOption.mode}</strong>
|
||||||
|
<span>{selectedOption.functionLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -589,7 +589,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-status-badge.is-ok::before {
|
.settings-account-status-badge.is-ok::before {
|
||||||
background: var(--ui-primary);
|
background: var(--ui-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-status-badge.is-free::before,
|
.settings-account-status-badge.is-free::before,
|
||||||
@@ -739,52 +739,36 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-picker-controls {
|
.settings-account-picker-selector {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 128px;
|
gap: 7px;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-picker {
|
.settings-account-picker-selector > span {
|
||||||
display: grid;
|
color: var(--ui-text-secondary);
|
||||||
max-height: 430px;
|
font-size: 13px;
|
||||||
min-height: 0;
|
font-weight: 600;
|
||||||
gap: 6px;
|
line-height: 18px;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-picker-entry {
|
.settings-account-option-meta {
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-account-picker-row {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 150px;
|
grid-template-columns: minmax(0, 1fr) 150px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 100%;
|
padding: 10px 12px;
|
||||||
min-height: 52px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font: inherit;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-picker-row.is-selected {
|
.settings-account-option-meta > div {
|
||||||
border-color: var(--ui-accent);
|
|
||||||
background: var(--ui-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-account-picker-row > span {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-picker-row small {
|
.settings-account-option-meta span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -792,16 +776,9 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-picker-empty {
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--ui-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-account-dialog-fields {
|
.settings-account-dialog-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 2px 10px 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-dialog-field {
|
.settings-account-dialog-field {
|
||||||
@@ -873,8 +850,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-theme-options,
|
.settings-theme-options,
|
||||||
.settings-account-picker-controls,
|
.settings-account-option-meta {
|
||||||
.settings-account-picker-row {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
|
|||||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||||
|
import { getSnapshotRenderDelay } from "../src/renderer/App";
|
||||||
|
|
||||||
describe("desktop shell", () => {
|
describe("desktop shell", () => {
|
||||||
|
it("does not stack renderer latency on the manager cadence for large active queues", () => {
|
||||||
|
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
||||||
|
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps application menus mounted for animated opening and closing", () => {
|
it("keeps application menus mounted for animated opening and closing", () => {
|
||||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||||
@@ -18,6 +24,12 @@ describe("desktop shell", () => {
|
|||||||
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-dropdown\s*\{[^}]*transition-duration:\s*150ms, 180ms, 0s !important;/);
|
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-dropdown\s*\{[^}]*transition-duration:\s*150ms, 180ms, 0s !important;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("anchors every top-level application dropdown to the right header edge", () => {
|
||||||
|
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(shellCss).toMatch(/\.md-application-menu-tree > \.menu-bar-item > \.menu-dropdown\s*\{[^}]*right:\s*0;[^}]*left:\s*auto;/s);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the product asset in the header brand", () => {
|
it("uses the product asset in the header brand", () => {
|
||||||
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
||||||
expect(html).toContain("Multi-Debrid Downloader");
|
expect(html).toContain("Multi-Debrid Downloader");
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { mergeKnownTotalBytes } from "../src/main/download-size";
|
||||||
|
|
||||||
|
describe("download size transitions", () => {
|
||||||
|
it("preserves a known metadata size when unrestrict has no replacement", () => {
|
||||||
|
expect(mergeKnownTotalBytes(2_000, null)).toBe(2_000);
|
||||||
|
expect(mergeKnownTotalBytes(2_000, 0)).toBe(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a positive replacement size", () => {
|
||||||
|
expect(mergeKnownTotalBytes(2_000, 2_500)).toBe(2_500);
|
||||||
|
expect(mergeKnownTotalBytes(null, 2_500)).toBe(2_500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the guarded merge to every unrestrict size transition", () => {
|
||||||
|
const source = readFileSync(new URL("../src/main/download-manager.ts", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).not.toContain("item.totalBytes = unrestricted.fileSize");
|
||||||
|
expect(source.match(/item\.totalBytes = mergeKnownTotalBytes\(item\.totalBytes, unrestricted\.fileSize\)/g)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
buildDownloadsViewModel,
|
buildDownloadsViewModel,
|
||||||
classifyDownloadStatus,
|
classifyDownloadStatus,
|
||||||
getDownloadQueueTotalBytes,
|
getDownloadQueueTotalBytes,
|
||||||
|
getDownloadSpeedBps,
|
||||||
type DownloadSidebarFilter,
|
type DownloadSidebarFilter,
|
||||||
type DownloadsModelInput
|
type DownloadsModelInput
|
||||||
} from "../src/renderer/views/downloads/downloads-model";
|
} from "../src/renderer/views/downloads/downloads-model";
|
||||||
@@ -28,9 +29,11 @@ import {
|
|||||||
PackageCardContent,
|
PackageCardContent,
|
||||||
areItemRowPropsEqual,
|
areItemRowPropsEqual,
|
||||||
arePackageCardPropsEqual,
|
arePackageCardPropsEqual,
|
||||||
|
compactDownloadStatus,
|
||||||
downloadColumnDefinitions,
|
downloadColumnDefinitions,
|
||||||
getAvailabilitySummary
|
getAvailabilitySummary
|
||||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||||
|
import { normalizeDownloadServiceLabel } from "../src/renderer/download-format";
|
||||||
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
||||||
|
|
||||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||||
@@ -122,6 +125,21 @@ describe("Download-Gesamtgröße", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("responsive Downloadstatus und Servicebezeichnungen", () => {
|
||||||
|
it("keeps full status details while providing compact table text", () => {
|
||||||
|
expect(compactDownloadStatus("Link wird umgewandelt")).toBe("Umwandeln");
|
||||||
|
expect(compactDownloadStatus("Download läuft (Mega-Debrid)")).toBe("DL läuft");
|
||||||
|
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken 1%");
|
||||||
|
expect(compactDownloadStatus("0/11 · Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken 1%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes duplicated access-mode wording from service labels", () => {
|
||||||
|
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web");
|
||||||
|
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API");
|
||||||
|
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
|
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
|
||||||
const items = [
|
const items = [
|
||||||
item("active", "package-a", "downloading"),
|
item("active", "package-a", "downloading"),
|
||||||
@@ -265,6 +283,11 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("downloads model", () => {
|
describe("downloads model", () => {
|
||||||
|
it("uses the package telemetry as the single live speed source", () => {
|
||||||
|
expect(getDownloadSpeedBps({ a: 45_000_000, b: 30_400_000, idle: 0 })).toBe(75_400_000);
|
||||||
|
expect(getDownloadSpeedBps({ stale: -1, invalid: Number.NaN })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("exports the sidebar filter contract used by the downloads shell", () => {
|
it("exports the sidebar filter contract used by the downloads shell", () => {
|
||||||
const filter: DownloadSidebarFilter = "queued";
|
const filter: DownloadSidebarFilter = "queued";
|
||||||
|
|
||||||
@@ -487,10 +510,9 @@ describe("downloads view", () => {
|
|||||||
expect(calls).toEqual(["start:package-a", "drop:package-b", "end"]);
|
expect(calls).toEqual(["start:package-a", "drop:package-b", "end"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches add, start, pause, stop, scheduling and selection actions through separate existing callbacks", () => {
|
it("starts with the local Start action and dispatches toolbar actions separately", () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const actions = createActions({
|
const actions = createActions({
|
||||||
onAddLinks: () => calls.push("add"),
|
|
||||||
onStartDownloads: () => calls.push("start"),
|
onStartDownloads: () => calls.push("start"),
|
||||||
onPauseDownloads: () => calls.push("pause"),
|
onPauseDownloads: () => calls.push("pause"),
|
||||||
onStopDownloads: () => calls.push("stop"),
|
onStopDownloads: () => calls.push("stop"),
|
||||||
@@ -505,11 +527,14 @@ describe("downloads view", () => {
|
|||||||
model: withRuntime(createInput({ selectedIds: ["active"] }), { scheduleOpen: true })
|
model: withRuntime(createInput({ selectedIds: ["active"] }), { scheduleOpen: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const label of ["Links hinzufügen", "Start", "Pause", "Stop", "Planen", "Nach oben", "Nach unten", "Umbenennen", "Entfernen"]) {
|
for (const label of ["Start", "Pause", "Stop", "Planen", "Nach oben", "Nach unten", "Umbenennen", "Entfernen"]) {
|
||||||
findButton(toolbar, label).props.onClick();
|
findButton(toolbar, label).props.onClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(calls).toEqual(["add", "start", "pause", "stop", "schedule", "up", "down", "rename", "remove"]);
|
const html = renderToStaticMarkup(toolbar);
|
||||||
|
expect(html).not.toContain("Links hinzufügen");
|
||||||
|
expect(html.indexOf(">Start<")).toBeLessThan(html.indexOf(">Pause<"));
|
||||||
|
expect(calls).toEqual(["start", "pause", "stop", "schedule", "up", "down", "rename", "remove"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses exact toolbar disabled semantics without a dead reconnect branch", () => {
|
it("uses exact toolbar disabled semantics without a dead reconnect branch", () => {
|
||||||
@@ -596,7 +621,8 @@ describe("downloads view", () => {
|
|||||||
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
||||||
expect(css).toMatch(/\.downloads-item-row\s+\.downloads-meter\s*>\s*b\s*\{[^}]*color:\s*var\(--ui-text\);/s);
|
expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*color:\s*var\(--ui-progress-track-text/s);
|
||||||
|
expect(css).toMatch(/\.downloads-meter-label\.is-filled\s*\{[^}]*color:\s*var\(--ui-progress-fill-text/s);
|
||||||
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
||||||
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
||||||
@@ -739,7 +765,42 @@ describe("download table row contracts", () => {
|
|||||||
selectedVersion: 0
|
selectedVersion: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(html).toContain("<b>70%</b>");
|
expect(html).toContain(">70%</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders meter text in clipped track and fill layers", () => {
|
||||||
|
const html = renderToStaticMarkup(ItemRowContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["size", "progress"],
|
||||||
|
gridTemplate: "180px 120px",
|
||||||
|
item: item("meter", "package-a", "downloading", { downloadedBytes: 750, totalBytes: 1_000, progressPercent: 75 }),
|
||||||
|
selected: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html.match(/class="downloads-meter-label is-track"/g)).toHaveLength(2);
|
||||||
|
expect(html.match(/class="downloads-meter-label is-filled"/g)).toHaveLength(2);
|
||||||
|
expect(html.match(/role="progressbar"/g)).toHaveLength(2);
|
||||||
|
expect(html).toContain("--downloads-progress:75%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows compact status visually while preserving the full accessible text", () => {
|
||||||
|
const html = renderToStaticMarkup(ItemRowContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["status", "account"],
|
||||||
|
gridTemplate: "120px 120px",
|
||||||
|
item: item("status", "package-a", "downloading", {
|
||||||
|
fullStatus: "Download läuft (Mega-Debrid)",
|
||||||
|
providerLabel: "Mega-Debrid Web (Web Account)"
|
||||||
|
}),
|
||||||
|
selected: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html).toContain('aria-label="Download läuft (Mega-Debrid)"');
|
||||||
|
expect(html).toContain('class="downloads-status-full"');
|
||||||
|
expect(html).toContain('class="downloads-status-compact"');
|
||||||
|
expect(html).toContain("DL läuft");
|
||||||
|
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
||||||
|
expect(html).toContain(">Mega-Debrid Web</span>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets the whole visible selection atomically from the header checkbox", () => {
|
it("sets the whole visible selection atomically from the header checkbox", () => {
|
||||||
@@ -818,6 +879,62 @@ describe("download table row contracts", () => {
|
|||||||
expect(calls).toEqual(["select"]);
|
expect(calls).toEqual(["select"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("only collapses a package through its visible disclosure button", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const model = withRuntime(createInput());
|
||||||
|
const row = model.packageRows[0];
|
||||||
|
const component = PackageCardContent({
|
||||||
|
actions: createActions({ onTogglePackageCollapse: () => calls.push("collapse") }),
|
||||||
|
columnOrder: model.columnOrder,
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: model.gridTemplate,
|
||||||
|
packageSpeedBps: 0,
|
||||||
|
row,
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 1
|
||||||
|
});
|
||||||
|
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
|
||||||
|
const collapseButton = findElement(component, (element) => element.type === "button" && String(element.props.className || "").includes("downloads-collapse-button"));
|
||||||
|
|
||||||
|
packageRow.props.onClick({ ctrlKey: false, metaKey: false, shiftKey: false, target: {}, currentTarget: {} });
|
||||||
|
collapseButton.props.onClick({ stopPropagation: () => {} });
|
||||||
|
|
||||||
|
expect(calls).toEqual(["collapse"]);
|
||||||
|
expect(collapseButton.props["aria-expanded"]).toBe(true);
|
||||||
|
expect(collapseButton.props["aria-controls"]).toBe(`downloads-package-items-${row.package.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the complete package status and audio details in the tooltip", () => {
|
||||||
|
const audioPackage = {
|
||||||
|
...pkg("audio-package", "Audio package", ["audio-item"]),
|
||||||
|
postProcessLabel: "Entpacken 1%",
|
||||||
|
audioStripSummary: {
|
||||||
|
at: now,
|
||||||
|
candidates: 1,
|
||||||
|
remuxed: 1,
|
||||||
|
keptSingle: 0,
|
||||||
|
skippedNoGerman: 0,
|
||||||
|
skippedNoTool: 0,
|
||||||
|
failed: 0,
|
||||||
|
files: [{ name: "episode.mkv", action: "remuxed", reason: "German kept" }]
|
||||||
|
}
|
||||||
|
} as PackageEntry;
|
||||||
|
const html = renderToStaticMarkup(PackageCardContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["status"],
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: "220px",
|
||||||
|
packageSpeedBps: 0,
|
||||||
|
row: { package: audioPackage, items: [item("audio-item", audioPackage.id, "queued")], collapsed: true },
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(html).toMatch(/title="0\/1 · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
||||||
|
});
|
||||||
|
|
||||||
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
|
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
|
||||||
const commits: string[] = [];
|
const commits: string[] = [];
|
||||||
const model = withRuntime(createInput());
|
const model = withRuntime(createInput());
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ describe("account workspace", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps add and edit dialogs separate and every secret field protected", () => {
|
it("keeps add and edit dialogs separate and every secret field protected", () => {
|
||||||
|
const options = accountOptions();
|
||||||
const addHtml = renderToStaticMarkup(
|
const addHtml = renderToStaticMarkup(
|
||||||
<AccountAddDialog
|
<AccountAddDialog
|
||||||
actions={{
|
actions={{
|
||||||
@@ -569,7 +570,7 @@ describe("account workspace", () => {
|
|||||||
open: true,
|
open: true,
|
||||||
query: "",
|
query: "",
|
||||||
filter: "all",
|
filter: "all",
|
||||||
options: accountOptions(),
|
options,
|
||||||
selectedOptionId: "megadebrid-api",
|
selectedOptionId: "megadebrid-api",
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
|
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
|
||||||
@@ -609,9 +610,19 @@ describe("account workspace", () => {
|
|||||||
|
|
||||||
expect(addHtml).toContain("Account hinzufügen");
|
expect(addHtml).toContain("Account hinzufügen");
|
||||||
expect(addHtml).toContain("Prüfen und speichern");
|
expect(addHtml).toContain("Prüfen und speichern");
|
||||||
expect(addHtml).toContain("Alle");
|
expect(count(addHtml, "<select")).toBe(1);
|
||||||
expect(addHtml).toContain("API");
|
expect(addHtml).toContain('aria-label="Dienst / Zugangstyp"');
|
||||||
expect(addHtml).toContain("Web");
|
expect(count(addHtml, "<option")).toBe(options.length);
|
||||||
|
options.forEach((option) => expect(addHtml).toContain(`value="${option.id}"`));
|
||||||
|
expect(addHtml).toContain('<option value="megadebrid-api" selected="">Mega-Debrid · API</option>');
|
||||||
|
expect(addHtml).toContain("Weiteren Account hinzufügen");
|
||||||
|
expect(addHtml).toContain("Login:Passwort");
|
||||||
|
expect(addHtml).not.toContain('type="search"');
|
||||||
|
expect(addHtml).not.toContain("Account-Typ filtern");
|
||||||
|
expect(addHtml).not.toContain("settings-account-picker-row");
|
||||||
|
expect(count(addHtml, 'class="settings-account-dialog-fields"')).toBe(1);
|
||||||
|
expect(addHtml.indexOf('aria-label="Dienst / Zugangstyp"')).toBeLessThan(addHtml.indexOf("settings-account-option-meta"));
|
||||||
|
expect(addHtml.indexOf("settings-account-option-meta")).toBeLessThan(addHtml.indexOf("settings-account-dialog-fields"));
|
||||||
expect(editHtml).toContain("Account bearbeiten");
|
expect(editHtml).toContain("Account bearbeiten");
|
||||||
expect(editHtml).toContain("member@example.test");
|
expect(editHtml).toContain("member@example.test");
|
||||||
expect(editHtml).toContain("Entfernen");
|
expect(editHtml).toContain("Entfernen");
|
||||||
@@ -619,6 +630,35 @@ describe("account workspace", () => {
|
|||||||
expect(count(addHtml, "type=\"password\"")).toBe(1);
|
expect(count(addHtml, "type=\"password\"")).toBe(1);
|
||||||
expect(count(editHtml, "type=\"password\"")).toBe(2);
|
expect(count(editHtml, "type=\"password\"")).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("selects the account option through the single service selector", () => {
|
||||||
|
const selected: string[] = [];
|
||||||
|
const tree = AccountAddDialog({
|
||||||
|
actions: {
|
||||||
|
onQueryChange: () => {},
|
||||||
|
onFilterChange: () => {},
|
||||||
|
onOptionSelect: (optionId) => selected.push(optionId),
|
||||||
|
onFieldChange: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
onSubmit: () => {}
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
open: true,
|
||||||
|
query: "",
|
||||||
|
filter: "all",
|
||||||
|
options: accountOptions(),
|
||||||
|
selectedOptionId: "megadebrid-api",
|
||||||
|
fields: [],
|
||||||
|
error: "",
|
||||||
|
busy: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const selector = findElement(tree, (element) => element.type === "select" && element.props["aria-label"] === "Dienst / Zugangstyp");
|
||||||
|
|
||||||
|
selector.props.onChange({ target: { value: "debridlink-api" } });
|
||||||
|
|
||||||
|
expect(selected).toEqual(["debridlink-api"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settings App integration", () => {
|
describe("settings App integration", () => {
|
||||||
@@ -662,6 +702,7 @@ describe("settings geometry", () => {
|
|||||||
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
|
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
|
||||||
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
|
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
|
||||||
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
|
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
|
||||||
|
expect(css).toMatch(/\.settings-account-status-badge\.is-ok::before\s*{[^}]*background:\s*var\(--ui-success\);/s);
|
||||||
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
|
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
|
||||||
expect(css).toMatch(/\.settings-view\s*{[^}]*min-width:\s*0;/s);
|
expect(css).toMatch(/\.settings-view\s*{[^}]*min-width:\s*0;/s);
|
||||||
expect(css).toMatch(/\.settings-account-workspace\s*{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s);
|
expect(css).toMatch(/\.settings-account-workspace\s*{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
@@ -384,12 +385,25 @@ describe("statistics view", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("bandwidth chart palette", () => {
|
describe("bandwidth chart palette", () => {
|
||||||
|
it("defines semantic speed and progress text colors for both themes", () => {
|
||||||
|
const css = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||||||
|
const dark = css.match(/:root,\s*:root\[data-theme="dark"\]\s*\{([\s\S]*?)\}/)?.[1];
|
||||||
|
const light = css.match(/:root\[data-theme="light"\]\s*\{([\s\S]*?)\}/)?.[1];
|
||||||
|
|
||||||
|
expect(dark).toContain("--ui-speed-accent: #F2942D;");
|
||||||
|
expect(dark).toContain("--ui-progress-track-text: #FFFFFF;");
|
||||||
|
expect(dark).toContain("--ui-progress-fill-text: #181A1F;");
|
||||||
|
expect(light).toContain("--ui-speed-accent: #C2701A;");
|
||||||
|
expect(light).toContain("--ui-progress-track-text: #181A1F;");
|
||||||
|
expect(light).toContain("--ui-progress-fill-text: #181A1F;");
|
||||||
|
});
|
||||||
|
|
||||||
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
||||||
const requested: string[] = [];
|
const requested: string[] = [];
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
"--ui-border": " rgb(61, 61, 61) ",
|
"--ui-border": " rgb(61, 61, 61) ",
|
||||||
"--ui-text-muted": " rgb(145, 145, 145) ",
|
"--ui-text-muted": " rgb(145, 145, 145) ",
|
||||||
"--ui-accent": " rgb(56, 134, 255) "
|
"--ui-speed-accent": " rgb(242, 148, 45) "
|
||||||
};
|
};
|
||||||
|
|
||||||
const palette = readBandwidthChartPalette((property) => {
|
const palette = readBandwidthChartPalette((property) => {
|
||||||
@@ -397,11 +411,11 @@ describe("bandwidth chart palette", () => {
|
|||||||
return values[property];
|
return values[property];
|
||||||
}, "Inter, Segoe UI, sans-serif");
|
}, "Inter, Segoe UI, sans-serif");
|
||||||
|
|
||||||
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
|
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-speed-accent"]);
|
||||||
expect(palette).toEqual({
|
expect(palette).toEqual({
|
||||||
grid: "rgb(61, 61, 61)",
|
grid: "rgb(61, 61, 61)",
|
||||||
text: "rgb(145, 145, 145)",
|
text: "rgb(145, 145, 145)",
|
||||||
accent: "rgb(56, 134, 255)",
|
accent: "rgb(242, 148, 45)",
|
||||||
fontFamily: "Inter, Segoe UI, sans-serif"
|
fontFamily: "Inter, Segoe UI, sans-serif"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user