mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-25 20:01:03 +00:00
feat: add functionality to manage download queue with new actions and translations
This commit is contained in:
@@ -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",
|
||||
|
||||
96
src/main/events/torrenting/add-game-to-queue.ts
Normal file
96
src/main/events/torrenting/add-game-to-queue.ts
Normal 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);
|
||||
@@ -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";
|
||||
|
||||
67
src/main/events/torrenting/update-download-queue-position.ts
Normal file
67
src/main/events/torrenting/update-download-queue-position.ts
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
8
src/renderer/src/declaration.d.ts
vendored
8
src/renderer/src/declaration.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user