diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3ba3f0b7..022696be 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", "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", diff --git a/src/main/events/torrenting/add-game-to-queue.ts b/src/main/events/torrenting/add-game-to-queue.ts new file mode 100644 index 00000000..85f321b1 --- /dev/null +++ b/src/main/events/torrenting/add-game-to-queue.ts @@ -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); diff --git a/src/main/events/torrenting/index.ts b/src/main/events/torrenting/index.ts index 408ecf17..143135da 100644 --- a/src/main/events/torrenting/index.ts +++ b/src/main/events/torrenting/index.ts @@ -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"; diff --git a/src/main/events/torrenting/update-download-queue-position.ts b/src/main/events/torrenting/update-download-queue-position.ts new file mode 100644 index 00000000..5672d63d --- /dev/null +++ b/src/main/events/torrenting/update-download-queue-position.ts @@ -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); diff --git a/src/preload/index.ts b/src/preload/index.ts index 6d929d99..412f1422 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e86c207b..68beb95d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -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; pauseGameDownload: (shop: GameShop, objectId: string) => Promise; resumeGameDownload: (shop: GameShop, objectId: string) => Promise; pauseGameSeed: (shop: GameShop, objectId: string) => Promise; resumeGameSeed: (shop: GameShop, objectId: string) => Promise; + updateDownloadQueuePosition: ( + shop: GameShop, + objectId: string, + direction: "up" | "down" + ) => Promise; onDownloadProgress: ( cb: (value: DownloadProgress | null) => void ) => () => Electron.IpcRenderer; diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index f6cc071f..8adb9574 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -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, diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 5b26f685..6f969a2a 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -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) { 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: , }, + { + label: t("move_up"), + show: isInQueue && !isFirstInQueue, + onClick: () => { + handleMoveInQueue(game.shop, game.objectId, "up"); + }, + icon: , + }, + { + label: t("move_down"), + show: isInQueue && !isLastInQueue, + onClick: () => { + handleMoveInQueue(game.shop, game.objectId, "down"); + }, + icon: , + }, { label: t("cancel"), onClick: () => { @@ -782,6 +857,8 @@ export function DownloadGroup({ icon: , }, ]; + + 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 ( <> diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index 10d817f1..620a71d2 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -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() { ))} diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index 6bc28c10..f885596c 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -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(); diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index 0a2c6721..6f20e439 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -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(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({ {t("loading")} + ) : hasActiveDownload ? ( + <> + + {t("add_to_queue")} + ) : ( <> diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 1a1132f1..a805c2ad 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -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; }