refactor: add cancel download confirmation modal and enhance download management in DownloadGroup

This commit is contained in:
Moyasee
2026-01-10 18:49:31 +02:00
parent ed044d797f
commit 562e30eecf
4 changed files with 199 additions and 125 deletions

View File

@@ -404,6 +404,9 @@
"completed": "Completed", "completed": "Completed",
"removed": "Not downloaded", "removed": "Not downloaded",
"cancel": "Cancel", "cancel": "Cancel",
"cancel_download": "Cancel download",
"cancel_download_description": "Are you sure you want to cancel this download? This will delete all downloaded files.",
"keep_downloading": "Keep downloading",
"filter": "Filter downloaded games", "filter": "Filter downloaded games",
"remove": "Remove", "remove": "Remove",
"downloading_metadata": "Downloading metadata…", "downloading_metadata": "Downloading metadata…",

View File

@@ -34,7 +34,9 @@ export const loadState = async () => {
await import("./events"); await import("./events");
if (!userPreferences?.useNativeHttpDownloader) { // Only spawn aria2 if user explicitly disabled native HTTP downloader
// Default is to use native HTTP downloader (aria2 is opt-in)
if (userPreferences?.useNativeHttpDownloader === false) {
Aria2.spawn(); Aria2.spawn();
} }
@@ -124,8 +126,9 @@ export const loadState = async () => {
// For torrents or if JS downloader is disabled, use Python RPC // For torrents or if JS downloader is disabled, use Python RPC
const isTorrent = downloadToResume?.downloader === Downloader.Torrent; const isTorrent = downloadToResume?.downloader === Downloader.Torrent;
// Default to true - native HTTP downloader is enabled by default
const useJsDownloader = const useJsDownloader =
userPreferences?.useNativeHttpDownloader && !isTorrent; (userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent;
if (useJsDownloader && downloadToResume) { if (useJsDownloader && downloadToResume) {
// Start Python RPC for seeding only, then resume HTTP download with JS // Start Python RPC for seeding only, then resume HTTP download with JS

View File

@@ -111,7 +111,8 @@ export class DownloadManager {
levelKeys.userPreferences, levelKeys.userPreferences,
{ valueEncoding: "json" } { valueEncoding: "json" }
); );
return userPreferences?.useNativeHttpDownloader ?? false; // Default to true - native HTTP downloader is enabled by default (opt-out)
return userPreferences?.useNativeHttpDownloader ?? true;
} }
private static isHttpDownloader(downloader: Downloader): boolean { private static isHttpDownloader(downloader: Downloader): boolean {
@@ -483,6 +484,9 @@ export class DownloadManager {
filename?: string; filename?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
} | null> { } | null> {
// If resuming and we already have a folderName, use it to ensure we find the partial file
const resumingFilename = download.folderName || undefined;
switch (download.downloader) { switch (download.downloader) {
case Downloader.Gofile: { case Downloader.Gofile: {
const id = download.uri.split("/").pop(); const id = download.uri.split("/").pop();
@@ -490,6 +494,7 @@ export class DownloadManager {
const downloadLink = await GofileApi.getDownloadLink(id!); const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink); await GofileApi.checkDownloadUrl(downloadLink);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadLink) || this.extractFilename(download.uri, downloadLink) ||
this.extractFilename(downloadLink); this.extractFilename(downloadLink);
@@ -504,6 +509,7 @@ export class DownloadManager {
const id = download.uri.split("/").pop(); const id = download.uri.split("/").pop();
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadUrl) || this.extractFilename(download.uri, downloadUrl) ||
this.extractFilename(downloadUrl); this.extractFilename(downloadUrl);
@@ -516,6 +522,7 @@ export class DownloadManager {
case Downloader.Qiwi: { case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri); const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadUrl) || this.extractFilename(download.uri, downloadUrl) ||
this.extractFilename(downloadUrl); this.extractFilename(downloadUrl);
@@ -528,6 +535,7 @@ export class DownloadManager {
case Downloader.Datanodes: { case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadUrl) || this.extractFilename(download.uri, downloadUrl) ||
this.extractFilename(downloadUrl); this.extractFilename(downloadUrl);
@@ -543,6 +551,7 @@ export class DownloadManager {
); );
const directUrl = await BuzzheavierApi.getDirectLink(download.uri); const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, directUrl) || this.extractFilename(download.uri, directUrl) ||
this.extractFilename(directUrl); this.extractFilename(directUrl);
@@ -558,6 +567,7 @@ export class DownloadManager {
); );
const directUrl = await FuckingFastApi.getDirectLink(download.uri); const directUrl = await FuckingFastApi.getDirectLink(download.uri);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, directUrl) || this.extractFilename(download.uri, directUrl) ||
this.extractFilename(directUrl); this.extractFilename(directUrl);
@@ -570,6 +580,7 @@ export class DownloadManager {
case Downloader.Mediafire: { case Downloader.Mediafire: {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadUrl) || this.extractFilename(download.uri, downloadUrl) ||
this.extractFilename(downloadUrl); this.extractFilename(downloadUrl);
@@ -583,6 +594,7 @@ export class DownloadManager {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadUrl) || this.extractFilename(download.uri, downloadUrl) ||
this.extractFilename(downloadUrl); this.extractFilename(downloadUrl);
@@ -599,7 +611,7 @@ export class DownloadManager {
return { return {
url, url,
savePath: download.downloadPath, savePath: download.downloadPath,
filename: name, filename: resumingFilename || name,
}; };
} }
case Downloader.Hydra: { case Downloader.Hydra: {
@@ -608,6 +620,7 @@ export class DownloadManager {
); );
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadUrl) || this.extractFilename(download.uri, downloadUrl) ||
this.extractFilename(downloadUrl); this.extractFilename(downloadUrl);
@@ -623,6 +636,7 @@ export class DownloadManager {
); );
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
const filename = const filename =
resumingFilename ||
this.extractFilename(download.uri, downloadUrl) || this.extractFilename(download.uri, downloadUrl) ||
this.extractFilename(downloadUrl); this.extractFilename(downloadUrl);

View File

@@ -1,6 +1,6 @@
import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components"; import { Badge, Button, ConfirmationModal } from "@renderer/components";
import { import {
formatDownloadProgress, formatDownloadProgress,
buildGameDetailsPath, buildGameDetailsPath,
@@ -219,7 +219,7 @@ interface HeroDownloadViewProps {
calculateETA: () => string; calculateETA: () => string;
pauseDownload: (shop: GameShop, objectId: string) => void; pauseDownload: (shop: GameShop, objectId: string) => void;
resumeDownload: (shop: GameShop, objectId: string) => void; resumeDownload: (shop: GameShop, objectId: string) => void;
cancelDownload: (shop: GameShop, objectId: string) => void; onCancelClick: (shop: GameShop, objectId: string) => void;
t: (key: string) => string; t: (key: string) => string;
} }
@@ -238,7 +238,7 @@ function HeroDownloadView({
calculateETA, calculateETA,
pauseDownload, pauseDownload,
resumeDownload, resumeDownload,
cancelDownload, onCancelClick,
t, t,
}: Readonly<HeroDownloadViewProps>) { }: Readonly<HeroDownloadViewProps>) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -353,7 +353,7 @@ function HeroDownloadView({
)} )}
<button <button
type="button" type="button"
onClick={() => cancelDownload(game.shop, game.objectId)} onClick={() => onCancelClick(game.shop, game.objectId)}
className="download-group__glass-btn" className="download-group__glass-btn"
> >
<XCircleIcon size={14} /> <XCircleIcon size={14} />
@@ -523,6 +523,13 @@ export function DownloadGroup({
const [optimisticallyResumed, setOptimisticallyResumed] = useState< const [optimisticallyResumed, setOptimisticallyResumed] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [cancelModalVisible, setCancelModalVisible] = useState(false);
const [gameToCancelShop, setGameToCancelShop] = useState<GameShop | null>(
null
);
const [gameToCancelObjectId, setGameToCancelObjectId] = useState<
string | null
>(null);
const extractDominantColor = useCallback( const extractDominantColor = useCallback(
async (imageUrl: string, gameId: string) => { async (imageUrl: string, gameId: string) => {
@@ -658,6 +665,27 @@ export function DownloadGroup({
[updateLibrary] [updateLibrary]
); );
const handleCancelClick = useCallback((shop: GameShop, objectId: string) => {
setGameToCancelShop(shop);
setGameToCancelObjectId(objectId);
setCancelModalVisible(true);
}, []);
const handleConfirmCancel = useCallback(async () => {
if (gameToCancelShop && gameToCancelObjectId) {
await cancelDownload(gameToCancelShop, gameToCancelObjectId);
}
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, [gameToCancelShop, gameToCancelObjectId, cancelDownload]);
const handleCancelModalClose = useCallback(() => {
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, []);
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download; const download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
@@ -728,7 +756,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -753,7 +781,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -811,136 +839,162 @@ export function DownloadGroup({
const dominantColor = dominantColors[game.id] || "#fff"; const dominantColor = dominantColors[game.id] || "#fff";
return ( return (
<HeroDownloadView <>
game={game} <ConfirmationModal
isGameDownloading={isGameDownloading} visible={cancelModalVisible}
isGameExtracting={isGameExtracting} title={t("cancel_download")}
downloadSpeed={downloadSpeed} descriptionText={t("cancel_download_description")}
finalDownloadSize={finalDownloadSize} confirmButtonLabel={t("cancel")}
peakSpeed={peakSpeed} cancelButtonLabel={t("keep_downloading")}
currentProgress={currentProgress} onConfirm={handleConfirmCancel}
dominantColor={dominantColor} onClose={handleCancelModalClose}
lastPacket={lastPacket} />
speedHistory={gameSpeedHistory} <HeroDownloadView
formatSpeed={formatSpeed} game={game}
calculateETA={calculateETA} isGameDownloading={isGameDownloading}
pauseDownload={pauseDownload} isGameExtracting={isGameExtracting}
resumeDownload={resumeDownload} downloadSpeed={downloadSpeed}
cancelDownload={cancelDownload} finalDownloadSize={finalDownloadSize}
t={t} peakSpeed={peakSpeed}
/> currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={gameSpeedHistory}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
resumeDownload={resumeDownload}
onCancelClick={handleCancelClick}
t={t}
/>
</>
); );
} }
return ( return (
<div <>
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`} <ConfirmationModal
> visible={cancelModalVisible}
<div className="download-group__header"> title={t("cancel_download")}
<div className="download-group__header-title-group"> descriptionText={t("cancel_download_description")}
<h2>{title}</h2> confirmButtonLabel={t("cancel")}
<h3 className="download-group__header-count">{library.length}</h3> cancelButtonLabel={t("keep_downloading")}
onConfirm={handleConfirmCancel}
onClose={handleCancelModalClose}
/>
<div
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
>
<div className="download-group__header">
<div className="download-group__header-title-group">
<h2>{title}</h2>
<h3 className="download-group__header-count">{library.length}</h3>
</div>
</div> </div>
</div>
<ul className="download-group__simple-list"> <ul className="download-group__simple-list">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return ( return (
<li key={game.id} className="download-group__simple-card"> <li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
<div className="download-group__simple-info">
<button <button
type="button" type="button"
onClick={() => navigate(buildGameDetailsPath(game))} onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button" className="download-group__simple-thumbnail"
> >
<h3 className="download-group__simple-title">{game.title}</h3> <img src={game.libraryImageUrl || ""} alt={game.title} />
</button> </button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row"> <div className="download-group__simple-info">
<Badge> <button
{DOWNLOADER_NAME[Number(game.download!.downloader)]} type="button"
</Badge> onClick={() => navigate(buildGameDetailsPath(game))}
</div> className="download-group__simple-title-button"
<div className="download-group__simple-meta-row"> >
{extraction?.visibleId === game.id ? ( <h3 className="download-group__simple-title">
<span className="download-group__simple-extracting"> {game.title}
{t("extracting")} ( </h3>
{Math.round(extraction.progress * 100)}%) </button>
</span> <div className="download-group__simple-meta">
) : ( <div className="download-group__simple-meta-row">
<span className="download-group__simple-size"> <Badge>
<DownloadIcon size={14} /> {DOWNLOADER_NAME[Number(game.download!.downloader)]}
{size} </Badge>
</span> </div>
)} <div className="download-group__simple-meta-row">
{game.download?.progress === 1 && seeding && ( {extraction?.visibleId === game.id ? (
<span className="download-group__simple-seeding"> <span className="download-group__simple-extracting">
{t("seeding")} {t("extracting")} (
</span> {Math.round(extraction.progress * 100)}%)
)} </span>
) : (
<span className="download-group__simple-size">
<DownloadIcon size={14} />
{size}
</span>
)}
{game.download?.progress === 1 && seeding && (
<span className="download-group__simple-seeding">
{t("seeding")}
</span>
)}
</div>
</div> </div>
</div> </div>
</div>
{isQueuedGroup && ( {isQueuedGroup && (
<div className="download-group__simple-progress"> <div className="download-group__simple-progress">
<span className="download-group__simple-progress-text"> <span className="download-group__simple-progress-text">
{formatDownloadProgress(progress)} {formatDownloadProgress(progress)}
</span> </span>
<div className="download-group__progress-bar download-group__progress-bar--small"> <div className="download-group__progress-bar download-group__progress-bar--small">
<div <div
className="download-group__progress-fill" className="download-group__progress-fill"
style={{ style={{
width: `${progress * 100}%`, width: `${progress * 100}%`,
backgroundColor: "#fff", backgroundColor: "#fff",
}} }}
/> />
</div>
</div> </div>
</div> )}
)}
<div className="download-group__simple-actions"> <div className="download-group__simple-actions">
{game.download?.progress === 1 && ( {game.download?.progress === 1 && (
<Button <Button
theme="primary" theme="primary"
onClick={() => openGameInstaller(game.shop, game.objectId)} onClick={() =>
disabled={isGameDeleting(game.id)} openGameInstaller(game.shop, game.objectId)
className="download-group__simple-menu-btn" }
> disabled={isGameDeleting(game.id)}
<PlayIcon size={16} /> className="download-group__simple-menu-btn"
</Button> >
)} <PlayIcon size={16} />
{isQueuedGroup && game.download?.progress !== 1 && ( </Button>
<Button )}
theme="primary" {isQueuedGroup && game.download?.progress !== 1 && (
onClick={() => resumeDownload(game.shop, game.objectId)} <Button
className="download-group__simple-menu-btn" theme="primary"
tooltip={t("resume")} onClick={() => resumeDownload(game.shop, game.objectId)}
> className="download-group__simple-menu-btn"
<DownloadIcon size={16} /> tooltip={t("resume")}
</Button> >
)} <DownloadIcon size={16} />
<DropdownMenu align="end" items={getGameActions(game)}> </Button>
<Button )}
theme="outline" <DropdownMenu align="end" items={getGameActions(game)}>
className="download-group__simple-menu-btn" <Button
> theme="outline"
<ThreeBarsIcon /> className="download-group__simple-menu-btn"
</Button> >
</DropdownMenu> <ThreeBarsIcon />
</div> </Button>
</li> </DropdownMenu>
); </div>
})} </li>
</ul> );
</div> })}
</ul>
</div>
</>
); );
} }