feat: add functionality to manage download queue with new actions and translations

This commit is contained in:
Moyasee
2026-01-24 18:46:07 +02:00
parent baa2c8471a
commit fb1380356e
12 changed files with 331 additions and 19 deletions

View File

@@ -185,6 +185,7 @@
"repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now",
"add_to_queue": "Add to queue",
"loading": "Loading...",
"no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options",
@@ -447,7 +448,9 @@
"yes": "Yes",
"no": "No",
"network": "NETWORK",
"peak": "PEAK"
"peak": "PEAK",
"move_up": "Move up",
"move_down": "Move down"
},
"settings": {
"downloads_path": "Downloads path",

View File

@@ -0,0 +1,96 @@
import { registerEvent } from "../register-event";
import type { Download, StartGameDownloadPayload } from "@types";
import { HydraApi, logger } from "@main/services";
import { createGame } from "@main/services/library-sync";
import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
const addGameToQueue = async (
_event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload
) => {
const {
objectId,
title,
shop,
downloadPath,
downloader,
uri,
automaticallyExtract,
} = payload;
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
await downloadsSublevel.del(gameKey);
if (game) {
await gamesSublevel.put(gameKey, {
...game,
isDeleted: false,
});
} else {
await gamesSublevel.put(gameKey, {
title,
iconUrl: gameAssets?.iconUrl ?? null,
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
logoImageUrl: gameAssets?.logoImageUrl ?? null,
objectId,
shop,
remoteId: null,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
isDeleted: false,
});
}
const download: Download = {
shop,
objectId,
status: "paused",
progress: 0,
bytesDownloaded: 0,
downloadPath,
downloader,
uri,
folderName: null,
fileSize: null,
shouldSeed: false,
timestamp: Date.now(),
queued: true,
extracting: false,
automaticallyExtract,
extractionProgress: 0,
};
try {
await downloadsSublevel.put(gameKey, download);
const updatedGame = await gamesSublevel.get(gameKey);
await Promise.all([
createGame(updatedGame!).catch(() => {}),
HydraApi.post(`/games/${shop}/${objectId}/download`, null, {
needsAuth: false,
}).catch(() => {}),
]);
return { ok: true };
} catch (err: unknown) {
logger.error("Failed to add game to queue", err);
if (err instanceof Error) {
return { ok: false, error: err.message };
}
return { ok: false };
}
};
registerEvent("addGameToQueue", addGameToQueue);

View File

@@ -1,3 +1,4 @@
import "./add-game-to-queue";
import "./cancel-game-download";
import "./check-debrid-availability";
import "./pause-game-download";
@@ -5,3 +6,4 @@ import "./pause-game-seed";
import "./resume-game-download";
import "./resume-game-seed";
import "./start-game-download";
import "./update-download-queue-position";

View File

@@ -0,0 +1,67 @@
import { registerEvent } from "../register-event";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
import { orderBy } from "lodash-es";
const updateDownloadQueuePosition = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
direction: "up" | "down"
) => {
const gameKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(gameKey);
if (!download || !download.queued || download.status !== "paused") {
return false;
}
const allDownloads = await downloadsSublevel.values().all();
const queuedDownloads = orderBy(
allDownloads.filter((d) => d.status === "paused" && d.queued),
"timestamp",
"desc"
);
const currentIndex = queuedDownloads.findIndex(
(d) => d.shop === shop && d.objectId === objectId
);
if (currentIndex === -1) {
return false;
}
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= queuedDownloads.length) {
return false;
}
const currentDownload = queuedDownloads[currentIndex];
const adjacentDownload = queuedDownloads[targetIndex];
const currentKey = levelKeys.game(
currentDownload.shop,
currentDownload.objectId
);
const adjacentKey = levelKeys.game(
adjacentDownload.shop,
adjacentDownload.objectId
);
const tempTimestamp = currentDownload.timestamp;
await downloadsSublevel.put(currentKey, {
...currentDownload,
timestamp: adjacentDownload.timestamp,
});
await downloadsSublevel.put(adjacentKey, {
...adjacentDownload,
timestamp: tempTimestamp,
});
return true;
};
registerEvent("updateDownloadQueuePosition", updateDownloadQueuePosition);

View File

@@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Torrenting */
startGameDownload: (payload: StartGameDownloadPayload) =>
ipcRenderer.invoke("startGameDownload", payload),
addGameToQueue: (payload: StartGameDownloadPayload) =>
ipcRenderer.invoke("addGameToQueue", payload),
cancelGameDownload: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("cancelGameDownload", shop, objectId),
pauseGameDownload: (shop: GameShop, objectId: string) =>
@@ -37,6 +39,17 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("pauseGameSeed", shop, objectId),
resumeGameSeed: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resumeGameSeed", shop, objectId),
updateDownloadQueuePosition: (
shop: GameShop,
objectId: string,
direction: "up" | "down"
) =>
ipcRenderer.invoke(
"updateDownloadQueuePosition",
shop,
objectId,
direction
),
onDownloadProgress: (cb: (value: DownloadProgress | null) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,

View File

@@ -47,11 +47,19 @@ declare global {
startGameDownload: (
payload: StartGameDownloadPayload
) => Promise<{ ok: boolean; error?: string }>;
addGameToQueue: (
payload: StartGameDownloadPayload
) => Promise<{ ok: boolean; error?: string }>;
cancelGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
pauseGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
updateDownloadQueuePosition: (
shop: GameShop,
objectId: string,
direction: "up" | "down"
) => Promise<boolean>;
onDownloadProgress: (
cb: (value: DownloadProgress | null) => void
) => () => Electron.IpcRenderer;

View File

@@ -38,6 +38,14 @@ export function useDownload() {
return response;
};
const addGameToQueue = async (payload: StartGameDownloadPayload) => {
const response = await window.electron.addGameToQueue(payload);
if (response.ok) updateLibrary();
return response;
};
const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary();
@@ -113,6 +121,7 @@ export function useDownload() {
lastPacket,
eta: calculateETA(),
startDownload,
addGameToQueue,
pauseDownload,
resumeDownload,
cancelDownload,

View File

@@ -26,6 +26,8 @@ import {
DropdownMenuItem,
} from "@renderer/components/dropdown-menu/dropdown-menu";
import {
ArrowDownIcon,
ArrowUpIcon,
ClockIcon,
ColumnsIcon,
DownloadIcon,
@@ -40,6 +42,44 @@ import {
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js";
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "");
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
}
const r = parseInt(h.substring(0, 2), 16) || 0;
const g = parseInt(h.substring(2, 4), 16) || 0;
const b = parseInt(h.substring(4, 6), 16) || 0;
return [r, g, b];
}
function isTooCloseRGB(a: string, b: string, threshold: number): boolean {
const [r1, g1, b1] = hexToRgb(a);
const [r2, g2, b2] = hexToRgb(b);
const distance = Math.sqrt(
Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2)
);
return distance < threshold;
}
const CHART_BACKGROUND_COLOR = "#1a1a1a";
const COLOR_DISTANCE_THRESHOLD = 28;
const FALLBACK_CHART_COLOR = "#fff";
function pickChartColor(dominant?: string): string {
if (!dominant || typeof dominant !== "string" || !dominant.startsWith("#")) {
return FALLBACK_CHART_COLOR;
}
if (
isTooCloseRGB(dominant, CHART_BACKGROUND_COLOR, COLOR_DISTANCE_THRESHOLD)
) {
return FALLBACK_CHART_COLOR;
}
return dominant;
}
interface AnimatedPercentageProps {
value: number;
}
@@ -442,6 +482,7 @@ export interface DownloadGroupProps {
openDeleteGameModal: (shop: GameShop, objectId: string) => void;
openGameInstaller: (shop: GameShop, objectId: string) => void;
seedingStatus: SeedingStatus[];
queuedGameIds?: string[];
}
export function DownloadGroup({
@@ -450,6 +491,7 @@ export function DownloadGroup({
openDeleteGameModal,
openGameInstaller,
seedingStatus,
queuedGameIds = [],
}: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
@@ -690,6 +732,18 @@ export function DownloadGroup({
setGameToCancelObjectId(null);
}, []);
const handleMoveInQueue = useCallback(
async (shop: GameShop, objectId: string, direction: "up" | "down") => {
await window.electron.updateDownloadQueuePosition(
shop,
objectId,
direction
);
updateLibrary();
},
[updateLibrary]
);
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id];
@@ -765,7 +819,12 @@ export function DownloadGroup({
(download?.downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken);
return [
const queueIndex = queuedGameIds.indexOf(game.id);
const isFirstInQueue = queueIndex === 0;
const isLastInQueue = queueIndex === queuedGameIds.length - 1;
const isInQueue = queueIndex !== -1;
const actions = [
{
label: t("resume"),
disabled: isResumeDisabled,
@@ -774,6 +833,22 @@ export function DownloadGroup({
},
icon: <PlayIcon />,
},
{
label: t("move_up"),
show: isInQueue && !isFirstInQueue,
onClick: () => {
handleMoveInQueue(game.shop, game.objectId, "up");
},
icon: <ArrowUpIcon />,
},
{
label: t("move_down"),
show: isInQueue && !isLastInQueue,
onClick: () => {
handleMoveInQueue(game.shop, game.objectId, "down");
},
icon: <ArrowDownIcon />,
},
{
label: t("cancel"),
onClick: () => {
@@ -782,6 +857,8 @@ export function DownloadGroup({
icon: <XCircleIcon />,
},
];
return actions.filter((action) => action.show !== false);
};
const downloadInfo = useMemo(
@@ -863,7 +940,7 @@ export function DownloadGroup({
currentProgress = lastPacket.progress;
}
const dominantColor = dominantColors[game.id] || "#fff";
const dominantColor = pickChartColor(dominantColors[game.id]);
return (
<>

View File

@@ -103,18 +103,26 @@ export default function Downloads() {
};
}, [library, lastPacket?.gameId, extraction?.visibleId]);
const queuedGameIds = useMemo(
() => libraryGroup.queued.map((game) => game.id),
[libraryGroup.queued]
);
const downloadGroups = [
{
title: t("download_in_progress"),
library: libraryGroup.downloading,
queuedGameIds: [] as string[],
},
{
title: t("queued_downloads"),
library: libraryGroup.queued,
queuedGameIds,
},
{
title: t("downloads_completed"),
library: libraryGroup.complete,
queuedGameIds: [] as string[],
},
];
@@ -142,10 +150,11 @@ export default function Downloads() {
<DownloadGroup
key={group.title}
title={group.title}
library={orderBy(group.library, ["updatedAt"], ["desc"])}
library={group.library}
openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller}
seedingStatus={seedingStatus}
queuedGameIds={group.queuedGameIds}
/>
))}
</div>

View File

@@ -37,7 +37,7 @@ export default function GameDetails() {
const fromRandomizer = searchParams.get("fromRandomizer");
const gameTitle = searchParams.get("title");
const { startDownload } = useDownload();
const { startDownload, addGameToQueue } = useDownload();
const { t } = useTranslation("game_details");
@@ -100,17 +100,28 @@ export default function GameDetails() {
repack: GameRepack,
downloader: Downloader,
downloadPath: string,
automaticallyExtract: boolean
automaticallyExtract: boolean,
addToQueueOnly = false
) => {
const response = await startDownload({
objectId: objectId!,
title: gameTitle,
downloader,
shop,
downloadPath,
uri: selectRepackUri(repack, downloader),
automaticallyExtract: automaticallyExtract,
});
const response = addToQueueOnly
? await addGameToQueue({
objectId: objectId!,
title: gameTitle,
downloader,
shop,
downloadPath,
uri: selectRepackUri(repack, downloader),
automaticallyExtract: automaticallyExtract,
})
: await startDownload({
objectId: objectId!,
title: gameTitle,
downloader,
shop,
downloadPath,
uri: selectRepackUri(repack, downloader),
automaticallyExtract: automaticallyExtract,
});
if (response.ok) {
await updateGame();

View File

@@ -12,11 +12,17 @@ import {
DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
PlusIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
import {
useAppSelector,
useDownload,
useFeature,
useToast,
} from "@renderer/hooks";
import { motion } from "framer-motion";
import { Tooltip } from "react-tooltip";
import { RealDebridInfoModal } from "./real-debrid-info-modal";
@@ -29,7 +35,8 @@ export interface DownloadSettingsModalProps {
repack: GameRepack,
downloader: Downloader,
downloadPath: string,
automaticallyExtract: boolean
automaticallyExtract: boolean,
addToQueueOnly?: boolean
) => Promise<{ ok: boolean; error?: string }>;
repack: GameRepack | null;
}
@@ -46,8 +53,11 @@ export function DownloadSettingsModal({
(state) => state.userPreferences.value
);
const { lastPacket } = useDownload();
const { showErrorToast } = useToast();
const hasActiveDownload = lastPacket !== null;
const [diskFreeSpace, setDiskFreeSpace] = useState<number | null>(null);
const [selectedPath, setSelectedPath] = useState("");
const [downloadStarting, setDownloadStarting] = useState(false);
@@ -220,7 +230,8 @@ export function DownloadSettingsModal({
repack,
selectedDownloader!,
selectedPath,
automaticExtractionEnabled
automaticExtractionEnabled,
hasActiveDownload
);
if (response.ok) {
@@ -456,6 +467,11 @@ export function DownloadSettingsModal({
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : hasActiveDownload ? (
<>
<PlusIcon />
{t("add_to_queue")}
</>
) : (
<>
<DownloadIcon />

View File

@@ -39,7 +39,8 @@ export interface RepacksModalProps {
repack: GameRepack,
downloader: Downloader,
downloadPath: string,
automaticallyExtract: boolean
automaticallyExtract: boolean,
addToQueueOnly?: boolean
) => Promise<{ ok: boolean; error?: string }>;
onClose: () => void;
}