diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 7602307b..3dfea93d 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -4,13 +4,15 @@ display: flex; flex-direction: column; gap: calc(globals.$spacing-unit * 2); + margin-inline: calc(globals.$spacing-unit * 3); + padding-block: calc(globals.$spacing-unit * 3); - &__details-with-article { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit / 2); - align-self: flex-start; - cursor: pointer; + &--queued { + padding-bottom: 0; + } + + &--completed { + padding-top: calc(globals.$spacing-unit * 3); } &__header { @@ -29,133 +31,336 @@ font-weight: 400; } } - - &__title-wrapper { - display: flex; - align-items: center; - margin-bottom: globals.$spacing-unit; - gap: globals.$spacing-unit; - } - - &__title { - font-weight: bold; - cursor: pointer; - color: globals.$body-color; - text-align: left; - font-size: 16px; - display: block; - - &:hover { - text-decoration: underline; - } - } - - &__downloads { + &--hero { width: 100%; - gap: calc(globals.$spacing-unit * 2); - display: flex; - flex-direction: column; + position: relative; + overflow: hidden; margin: 0; padding: 0; - margin-top: globals.$spacing-unit; + padding-bottom: calc(globals.$spacing-unit * 3); } - &__item { + &__hero-background { + position: absolute; + top: 0; + left: 0; width: 100%; - background-color: globals.$background-color; - display: flex; - border-radius: 8px; - border: solid 1px globals.$border-color; - overflow: hidden; - box-shadow: 0px 0px 5px 0px #000000; - transition: all ease 0.2s; - height: 140px; - min-height: 140px; - max-height: 140px; - position: relative; + height: 120%; + z-index: 0; - &--hydra { - box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); + img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 20%; } } - &__cover { - width: 280px; - min-width: 280px; - height: auto; - border-right: solid 1px globals.$border-color; + // PLEASE FIX THE COLORS + &__hero-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.3) 0%, + rgb(5, 5, 5) 70%, + rgb(26, 26, 26) 100% + ); + } + + &__hero-content { position: relative; z-index: 1; - - &-content { - width: 100%; - height: 100%; - padding: globals.$spacing-unit; - display: flex; - align-items: flex-end; - justify-content: flex-end; - } - - &-backdrop { - width: 100%; - height: 100%; - background: linear-gradient( - 0deg, - rgba(0, 0, 0, 0.8) 5%, - transparent 100% - ); - display: flex; - overflow: hidden; - z-index: 1; - } - - &-image { - width: 100%; - height: 100%; - position: absolute; - z-index: -1; - } - } - - &__right-content { - display: flex; - padding: calc(globals.$spacing-unit * 2); - flex: 1; - gap: globals.$spacing-unit; - background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%); - } - - &__details { + padding: calc(globals.$spacing-unit * 4); + padding-bottom: 0; display: flex; flex-direction: column; - flex: 1; - justify-content: center; - gap: calc(globals.$spacing-unit / 2); - font-size: 14px; + gap: calc(globals.$spacing-unit * 2); } - &__actions { + &__hero-header { + display: flex; + justify-content: flex-end; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__hero-logo { + flex: 1; + + img { + max-width: 600px; + max-height: 200px; + object-fit: contain; + } + + h1 { + font-size: 64px; + font-weight: 700; + color: #ffffff; + text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9); + margin: 0; + } + } + + &__hero-actions { + display: flex; + gap: calc(globals.$spacing-unit); + align-items: center; + } + + &__hero-action-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 3); + margin-bottom: calc(globals.$spacing-unit * 3); + } + + &__hero-menu-btn { + background-color: rgba(0, 0, 0, 0.4); + padding: calc(globals.$spacing-unit * 1); + min-height: unset; + } + &__hero-menu-btn:hover { + background-color: rgba(0, 0, 0, 0.8); + } + + &__hero-progress { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit); + margin-bottom: calc(globals.$spacing-unit * 3); + } + + &__progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(globals.$spacing-unit / 2); + } + + &__progress-status { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__progress-percentage { + font-size: 14px; + font-weight: 700; + color: #ffffff; + } + + &__progress-details { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + color: rgba(255, 255, 255, 0.9); + margin-top: calc(globals.$spacing-unit / 2); + } + + &__progress-size { + font-weight: 600; + } + + &__progress-time { + color: globals.$muted-color; + } + + &__hero-stats { + display: flex; + gap: calc(globals.$spacing-unit * 4); + padding: calc(globals.$spacing-unit * 2); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(26, 26, 26, 0.1); + backdrop-filter: blur(8px); + margin-top: calc(globals.$spacing-unit * 2); + } + + &__stats-column { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + min-width: 200px; + padding-right: calc(globals.$spacing-unit * 2); + border-right: 1px solid rgba(255, 255, 255, 0.1); + } + + &__speed-chart { + flex: 1; display: flex; align-items: center; - gap: globals.$spacing-unit; + justify-content: center; + overflow: hidden; } - &__menu-button { - position: absolute; - top: 12px; - right: 12px; - border-radius: 50%; - border: none; - padding: 8px; + &__speed-chart-canvas { + width: 100%; + height: 100px; + image-rendering: crisp-edges; + } + + &__stat-item { + display: flex; + align-items: flex-end; + gap: calc(globals.$spacing-unit); + + svg { + opacity: 0.8; + flex-shrink: 0; + } + } + + &__stat-content { + display: flex; + gap: 2px; + } + + &__stat-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 10px; + color: rgba(255, 255, 255, 0.6); + } + + &__stat-value { + color: #ffffff; + font-weight: 700; + font-size: 11px; + line-height: 1.2; + } + + &__simple-list { + width: 100%; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + margin: 0; + padding: 0; + list-style: none; + } + + &__simple-card { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2); + border-radius: 8px; + transition: all ease 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.1); + } + } + + &__simple-thumbnail { + width: 200px; + height: 100px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + background-color: rgba(0, 0, 0, 0.3); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__simple-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + } + + &__simple-title { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__simple-meta { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + font-size: 13px; + color: globals.$muted-color; + } + + &__simple-size { + font-weight: 500; + } + + &__simple-seeding { + color: #4ade80; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__simple-progress { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + width: 200px; + flex-shrink: 0; + } + + &__simple-progress-text { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + text-align: right; + } + + &__simple-actions { + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__simple-menu-btn { + padding: calc(globals.$spacing-unit); min-height: unset; } - &__hydra-gradient { - background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%); - box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15); + &__progress-bar { width: 100%; - position: absolute; - bottom: 0; - height: 2px; - z-index: 1; + height: 8px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + position: relative; + + &--small { + height: 6px; + } + } + + &__progress-fill { + height: 100%; + background-color: globals.$muted-color; + transition: + width 0.3s ease, + background 0.35s ease; + border-radius: 4px; } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9face..6db52ac8 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,21 +1,16 @@ -import { useNavigate } from "react-router-dom"; -import cn from "classnames"; - import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import { Badge, Button } from "@renderer/components"; -import { - buildGameDetailsPath, - formatDownloadProgress, -} from "@renderer/helpers"; +import { formatDownloadProgress } from "@renderer/helpers"; -import { Downloader, formatBytes } from "@shared"; +import { Downloader, formatBytes, formatBytesToMbps } from "@shared"; +import { formatDistance, addMilliseconds } from "date-fns"; import { DOWNLOADER_NAME } from "@renderer/constants"; import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DropdownMenu, DropdownMenuItem, @@ -26,12 +21,327 @@ import { FileDirectoryIcon, LinkIcon, PlayIcon, - QuestionIcon, ThreeBarsIcon, TrashIcon, UnlinkIcon, XCircleIcon, + GraphIcon, } from "@primer/octicons-react"; +import { average } from "color.js"; + +const getProgressGradient = ( + colorHex: string, + isPaused = false +): string | undefined => { + const hex = isPaused ? "#ffffff" : colorHex || "#08ea79"; + if (!hex.startsWith("#")) return undefined; + + try { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `linear-gradient(90deg, rgba(${r},${g},${b},0.95) 0%, rgba(${r},${g},${b},0.65) 100%)`; + } catch { + return undefined; + } +}; + +interface SpeedChartProps { + speeds: number[]; + peakSpeed: number; + color?: string; +} + +function SpeedChart({ + speeds, + peakSpeed, + color = "rgba(255, 255, 255, 1)", +}: Readonly) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationFrameId: number; + + const draw = () => { + const clientWidth = canvas.clientWidth; + const dpr = window.devicePixelRatio || 1; + + canvas.width = clientWidth * dpr; + canvas.height = 100 * dpr; + ctx.scale(dpr, dpr); + + const width = clientWidth; + const height = 100; + const totalBars = 120; + const barWidth = 4; + const barGap = 10; + const barSpacing = barWidth + barGap; + const maxHeight = peakSpeed || Math.max(...speeds, 1); + + ctx.clearRect(0, 0, width, height); + + let r = 255, + g = 255, + b = 255; + if (color.startsWith("#")) { + const hex = color.replace("#", ""); + r = Number.parseInt(hex.substring(0, 2), 16); + g = Number.parseInt(hex.substring(2, 4), 16); + b = Number.parseInt(hex.substring(4, 6), 16); + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = Number.parseInt(matches[0]); + g = Number.parseInt(matches[1]); + b = Number.parseInt(matches[2]); + } + } + const displaySpeeds = speeds.slice(-totalBars); + + for (let i = 0; i < totalBars; i++) { + const x = i * barSpacing; + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.beginPath(); + ctx.roundRect(x, 0, barWidth, height, 3); + ctx.fill(); + + if (i < displaySpeeds.length) { + const speed = displaySpeeds[i] || 0; + const filledHeight = (speed / maxHeight) * height; + + if (filledHeight > 0) { + const gradient = ctx.createLinearGradient( + 0, + height - filledHeight, + 0, + height + ); + + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); + ctx.fill(); + } + } + } + animationFrameId = requestAnimationFrame(draw); + }; + + animationFrameId = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(animationFrameId); + }; + }, [speeds, peakSpeed, color]); + + return ( + + ); +} + +interface HeroDownloadViewProps { + game: LibraryGame; + isGameDownloading: boolean; + downloadSpeed: number; + finalDownloadSize: string; + peakSpeed: number; + currentProgress: number; + dominantColor: string; + lastPacket: ReturnType["lastPacket"]; + speedHistory: number[]; + getGameActions: (game: LibraryGame) => DropdownMenuItem[]; + getStatusText: (game: LibraryGame) => string; + formatSpeed: (speed: number) => string; + calculateETA: () => string; + pauseDownload: (shop: GameShop, objectId: string) => void; + resumeDownload: (shop: GameShop, objectId: string) => void; + t: (key: string) => string; +} + +function HeroDownloadView({ + game, + isGameDownloading, + downloadSpeed, + finalDownloadSize, + peakSpeed, + currentProgress, + dominantColor, + lastPacket, + speedHistory, + getGameActions, + getStatusText, + formatSpeed, + calculateETA, + pauseDownload, + resumeDownload, + t, +}: Readonly) { + return ( +
+
+ {game.title} +
+
+ +
+
+
+ + + +
+
+ +
+
+ {game.logoImageUrl ? ( + {game.title} + ) : ( +

{game.title}

+ )} +
+ + {isGameDownloading ? ( + + ) : ( + + )} +
+ +
+
+ + {getStatusText(game)} + + + {formatDownloadProgress(currentProgress)} + +
+
+
+
+
+ + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `0 B / ${finalDownloadSize}`} + + + {isGameDownloading && + lastPacket?.timeRemaining && + lastPacket.timeRemaining > 0 + ? calculateETA() + : ""} + +
+
+ +
+
+
+ + + +
+ + {t("network")}: + + + {isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"} + +
+
+ +
+ + + +
+ {t("peak")}: + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
+
+ + {game.download?.downloader === Downloader.Torrent && + isGameDownloading && + lastPacket && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( +
+
+ + Seeds:{" "} + + {lastPacket.numSeeds} + + , Peers:{" "} + + {lastPacket.numPeers} + + +
+
+ )} +
+ +
+ +
+
+
+
+ ); +} export interface DownloadGroupProps { library: LibraryGame[]; @@ -48,8 +358,6 @@ export function DownloadGroup({ openGameInstaller, seedingStatus, }: Readonly) { - const navigate = useNavigate(); - const { t } = useTranslation("downloads"); const userPreferences = useAppSelector( @@ -60,7 +368,6 @@ export function DownloadGroup({ const { lastPacket, - progress, pauseDownload, resumeDownload, cancelDownload, @@ -69,11 +376,118 @@ export function DownloadGroup({ resumeSeeding, } = useDownload(); + const peakSpeedsRef = useRef>({}); + const speedHistoryRef = useRef>({}); + const [dominantColors, setDominantColors] = useState>( + {} + ); + + const extractDominantColor = useCallback( + async (imageUrl: string, gameId: string) => { + if (dominantColors[gameId]) return; + + try { + const color = await average(imageUrl, { amount: 1, format: "hex" }); + const colorString = + typeof color === "string" ? color : color.toString(); + setDominantColors((prev) => ({ ...prev, [gameId]: colorString })); + } catch (error) { + console.error("Failed to extract dominant color:", error); + } + }, + [dominantColors] + ); + + useEffect(() => { + if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) { + const gameId = lastPacket.gameId; + + const currentPeak = peakSpeedsRef.current[gameId] || 0; + if (lastPacket.downloadSpeed > currentPeak) { + peakSpeedsRef.current[gameId] = lastPacket.downloadSpeed; + } + + if (!speedHistoryRef.current[gameId]) { + speedHistoryRef.current[gameId] = []; + } + + speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed); + + if (speedHistoryRef.current[gameId].length > 120) { + speedHistoryRef.current[gameId].shift(); + } + } + }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); + + useEffect(() => { + for (const game of library) { + if ( + game.download && + game.download.progress < 0.01 && + game.download.status !== "paused" + ) { + // Fresh download - clear any old data + if (speedHistoryRef.current[game.id]?.length > 0) { + speedHistoryRef.current[game.id] = []; + peakSpeedsRef.current[game.id] = 0; + } + } + } + }, [library]); + + useEffect(() => { + const timeouts: NodeJS.Timeout[] = []; + + for (const game of library) { + if ( + game.download?.progress === 1 && + speedHistoryRef.current[game.id]?.length > 0 + ) { + const timeout = setTimeout(() => { + speedHistoryRef.current[game.id] = []; + peakSpeedsRef.current[game.id] = 0; + }, 10_000); + timeouts.push(timeout); + } + } + + return () => { + for (const timeout of timeouts) { + clearTimeout(timeout); + } + }; + }, [library]); + + useEffect(() => { + if (library.length > 0 && title === t("download_in_progress")) { + const game = library[0]; + const heroImageUrl = + game.libraryHeroImageUrl || game.libraryImageUrl || ""; + if (heroImageUrl && game.id) { + extractDominantColor(heroImageUrl, game.id); + } + } + }, [library, title, t, extractDominantColor]); + + const isGameSeeding = (game: LibraryGame) => { + const entry = seedingStatus.find((s) => s.gameId === game.id); + if (entry?.status) return entry.status === "seeding"; + return game.download?.status === "seeding"; + }; + + const isGameDownloadingMap = useMemo(() => { + const map: Record = {}; + for (const game of library) { + map[game.id] = lastPacket?.gameId === game.id; + } + return map; + }, [library, lastPacket?.gameId]); + const getFinalDownloadSize = (game: LibraryGame) => { const download = game.download!; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; - if (download.fileSize) return formatBytes(download.fileSize); + if (download.fileSize != null) return formatBytes(download.fileSize); if (lastPacket?.download.fileSize && isGameDownloading) return formatBytes(lastPacket.download.fileSize); @@ -81,15 +495,74 @@ export function DownloadGroup({ return "N/A"; }; - const seedingMap = useMemo(() => { - const map = new Map(); + const formatSpeed = (speed: number): string => { + return userPreferences?.showDownloadSpeedInMegabytes + ? `${formatBytes(speed)}/s` + : formatBytesToMbps(speed); + }; - seedingStatus.forEach((seed) => { - map.set(seed.gameId, seed); - }); + const calculateETA = () => { + if ( + !lastPacket || + lastPacket.timeRemaining < 0 || + !Number.isFinite(lastPacket.timeRemaining) + ) { + return ""; + } - return map; - }, [seedingStatus]); + return formatDistance( + addMilliseconds(new Date(), lastPacket.timeRemaining), + new Date(), + { addSuffix: true } + ); + }; + + const getCompletedStatusText = (game: LibraryGame) => { + const isTorrent = game.download?.downloader === Downloader.Torrent; + if (isTorrent) { + return isGameSeeding(game) + ? `${t("completed")} (${t("seeding")})` + : `${t("completed")} (${t("paused")})`; + } + return t("completed"); + }; + + const getStatusText = (game: LibraryGame) => { + const isGameDownloading = isGameDownloadingMap[game.id]; + const status = game.download?.status; + + if (game.download?.extracting) { + return t("extracting"); + } + + if (isGameDeleting(game.id)) { + return t("deleting"); + } + + if (game.download?.progress === 1) { + return getCompletedStatusText(game); + } + + if (isGameDownloading && lastPacket) { + if (lastPacket.isDownloadingMetadata) { + return t("downloading_metadata"); + } + if (lastPacket.isCheckingFiles) { + return t("checking_files"); + } + return t("download_in_progress"); + } + + switch (status) { + case "paused": + case "error": + return t("paused"); + case "waiting": + return t("calculating_eta"); + default: + return t("paused"); + } + }; const extractGameDownload = useCallback( async (shop: GameShop, objectId: string) => { @@ -99,110 +572,14 @@ export function DownloadGroup({ [updateLibrary] ); - const getGameInfo = (game: LibraryGame) => { - const download = game.download!; - - const isGameDownloading = lastPacket?.gameId === game.id; - const finalDownloadSize = getFinalDownloadSize(game); - const seedingStatus = seedingMap.get(game.id); - - if (download.extracting) { - return

{t("extracting")}

; - } - - if (isGameDeleting(game.id)) { - return

{t("deleting")}

; - } - - if (isGameDownloading) { - if (lastPacket?.isDownloadingMetadata) { - return

{t("downloading_metadata")}

; - } - - if (lastPacket?.isCheckingFiles) { - return ( - <> -

{progress}

-

{t("checking_files")}

- - ); - } - - return ( - <> -

{progress}

- -

- {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} - {finalDownloadSize} -

- - {download.downloader === Downloader.Torrent && ( - - {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds - - - )} - - ); - } - - if (download.progress === 1) { - const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0); - - return download.status === "seeding" && - download.downloader === Downloader.Torrent ? ( - <> -

- {t("seeding")} - - -

- {uploadSpeed &&

{uploadSpeed}/s

} - - ) : ( -

{t("completed")}

- ); - } - - if (download.status === "paused") { - return ( - <> -

{formatDownloadProgress(download.progress)}

-

{t(download.queued ? "queued" : "paused")}

- - ); - } - - if (download.status === "active") { - return ( - <> -

{formatDownloadProgress(download.progress)}

- -

- {formatBytes(download.bytesDownloaded)} / {finalDownloadSize} -

- - ); - } - - return

{t(download.status as string)}

; - }; - const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const download = lastPacket?.download; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; const deleting = isGameDeleting(game.id); if (game.download?.progress === 1) { - return [ + const actions = [ { label: t("install"), disabled: deleting, @@ -224,7 +601,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status === "seeding" && + isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { pauseSeeding(game.shop, game.objectId); @@ -235,7 +612,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status !== "seeding" && + !isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { resumeSeeding(game.shop, game.objectId); @@ -250,6 +627,7 @@ export function DownloadGroup({ }, }, ]; + return actions.filter((action) => action.show !== false); } if (isGameDownloading) { @@ -296,80 +674,137 @@ export function DownloadGroup({ ]; }; + const downloadInfo = useMemo( + () => + library.map((game) => ({ + game, + size: getFinalDownloadSize(game), + progress: game.download?.progress || 0, + isSeeding: isGameSeeding(game), + })), + [library, lastPacket?.gameId] + ); + if (!library.length) return null; + const isDownloadingGroup = title === t("download_in_progress"); + const isQueuedGroup = title === t("queued_downloads"); + const isCompletedGroup = title === t("downloads_completed"); + + if (isDownloadingGroup && library.length > 0) { + const game = library[0]; + const isGameDownloading = isGameDownloadingMap[game.id]; + const downloadSpeed = isGameDownloading + ? (lastPacket?.downloadSpeed ?? 0) + : 0; + const finalDownloadSize = getFinalDownloadSize(game); + const peakSpeed = peakSpeedsRef.current[game.id] || 0; + const currentProgress = + isGameDownloading && lastPacket + ? lastPacket.progress + : game.download?.progress || 0; + + const dominantColor = dominantColors[game.id] || "#fff"; + + return ( + + ); + } + return ( -
+

{title}

{library.length}

-
    - {library.map((game) => { +
      + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { return ( -
    • -
      -
      - {game.title} - -
      - {DOWNLOADER_NAME[game.download!.downloader]} -
      -
      -
      -
      -
      -
      - -
      - - {getGameInfo(game)} -
      - - {getGameActions(game) !== null && ( - - - - )} +
    • +
      + {game.title}
      - {game.download?.downloader === Downloader.Hydra && ( -
      +
      +

      {game.title}

      +
      + {DOWNLOADER_NAME[game.download!.downloader]} + {size} + {game.download?.progress === 1 && seeding && ( + + {t("seeding")} + + )} +
      +
      + + {isQueuedGroup && ( +
      + + {formatDownloadProgress(progress)} + +
      +
      +
      +
      )} + +
      + {game.download?.progress === 1 && ( + + )} + {isQueuedGroup && game.download?.progress !== 1 && ( + + )} + + + +
    • ); })} diff --git a/src/renderer/src/pages/downloads/downloads.scss b/src/renderer/src/pages/downloads/downloads.scss index 8290a66e..abada8d7 100644 --- a/src/renderer/src/pages/downloads/downloads.scss +++ b/src/renderer/src/pages/downloads/downloads.scss @@ -3,7 +3,6 @@ .downloads { &__container { display: flex; - padding: calc(globals.$spacing-unit * 3); flex-direction: column; width: 100%; }