From 83fbf203839e223cf61640ccea608283acbd0638 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Fri, 14 Nov 2025 20:02:10 +0530 Subject: [PATCH 1/5] feat: enhance download page UI with improved layout and styling for cards --- .../src/pages/downloads/download-group.scss | 301 +++++++++---- .../src/pages/downloads/download-group.tsx | 414 +++++++++++------- 2 files changed, 488 insertions(+), 227 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 7602307b7..22bff5271 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -5,14 +5,6 @@ flex-direction: column; gap: calc(globals.$spacing-unit * 2); - &__details-with-article { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit / 2); - align-self: flex-start; - cursor: pointer; - } - &__header { display: flex; align-items: center; @@ -30,29 +22,9 @@ } } - &__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 { width: 100%; - gap: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit * 3); display: flex; flex-direction: column; margin: 0; @@ -67,86 +39,259 @@ border-radius: 8px; border: solid 1px globals.$border-color; overflow: hidden; - box-shadow: 0px 0px 5px 0px #000000; + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.5); transition: all ease 0.2s; - height: 140px; - min-height: 140px; - max-height: 140px; + height: 250px; + min-height: 250px; + max-height: 250px; position: relative; + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover { + transform: scale(1.01); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + &--hydra { box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); } } - &__cover { - width: 280px; - min-width: 280px; - height: auto; - border-right: solid 1px globals.$border-color; - position: relative; - z-index: 1; - &-content { - width: 100%; - height: 100%; - padding: globals.$spacing-unit; - display: flex; - align-items: flex-end; - justify-content: flex-end; - } + &__background-image { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 0; - &-backdrop { + img { width: 100%; height: 100%; - background: linear-gradient( - 0deg, - rgba(0, 0, 0, 0.8) 5%, - transparent 100% - ); - display: flex; - overflow: hidden; - z-index: 1; + object-fit: cover; + object-position: 50% 25%; } + } - &-image { - width: 100%; - height: 100%; - position: absolute; - z-index: -1; - } + &__background-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 130deg, + rgba(0, 0, 0, 0.2) 0%, + rgba(0, 0, 0, 0.5) 50%, + rgba(0, 0, 0, 0.8) 100% + ); } - &__right-content { + &__content { + position: relative; + z-index: 2; + width: 100%; + height: 100%; display: flex; - padding: calc(globals.$spacing-unit * 2); + } + + &__left-section { flex: 1; - gap: globals.$spacing-unit; - background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%); + max-width: 50%; + height: 100%; + display: flex; + align-items: flex-end; + padding: calc(globals.$spacing-unit * 2); } - &__details { + &__logo-container { display: flex; flex-direction: column; + gap: globals.$spacing-unit; + } + + &__logo { + max-width: 350px; + max-height: 150px; + object-fit: contain; + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8)); + } + + &__game-title { + font-size: 24px; + font-weight: 700; + color: #ffffff; + text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9); + margin: 0; + } + + &__downloader-badge { + align-self: flex-start; + } + + &__right-section { flex: 1; - justify-content: center; - gap: calc(globals.$spacing-unit / 2); - font-size: 14px; + max-width: 50%; + display: flex; + flex-direction: column; + padding: calc(globals.$spacing-unit * 2); + position: relative; + justify-content: space-between; } - &__actions { + &__top-row { display: flex; align-items: center; - gap: globals.$spacing-unit; + justify-content: space-between; + gap: calc(globals.$spacing-unit * 2); + } + + &__stats { + display: flex; + gap: calc(globals.$spacing-unit * 3); + } + + &__stat { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + + svg { + opacity: 0.8; + flex-shrink: 0; + } + } + + &__stat-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__stat-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 10px; + color: rgba(255, 255, 255, 0.6); + line-height: 1; + } + + &__stat-value { + color: #ffffff; + font-weight: 700; + font-size: 14px; + line-height: 1.2; } &__menu-button { - position: absolute; - top: 12px; - right: 12px; - border-radius: 50%; border: none; padding: 8px; min-height: unset; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + flex-shrink: 0; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + } + } + + &__progress-section { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + flex: 1; + } + + &__bottom-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + } + + &__progress-info { + display: flex; + justify-content: space-between; + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + } + + &__progress-text { + font-weight: 600; + } + + &__progress-size { + color: globals.$muted-color; + } + + &__progress-bar { + width: 100%; + height: 6px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + position: relative; + } + + &__progress-fill { + height: 100%; + background-color: globals.$muted-color; + transition: width 0.3s ease; + border-radius: 4px; + } + + &__time-remaining { + font-size: 11px; + color: globals.$muted-color; + text-align: left; + min-height: 16px; + } + + &__quick-actions { + display: flex; + flex-shrink: 0; + min-height: 40px; + align-items: center; + } + + &__action-btn { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2); + font-size: 13px; + font-weight: 600; + + svg { + width: 14px; + height: 14px; + } } &__hydra-gradient { @@ -156,6 +301,6 @@ position: absolute; bottom: 0; height: 2px; - z-index: 1; + z-index: 2; } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9facea..9f9993175 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,21 +1,18 @@ -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, useRef } from "react"; import { DropdownMenu, DropdownMenuItem, @@ -26,11 +23,12 @@ import { FileDirectoryIcon, LinkIcon, PlayIcon, - QuestionIcon, ThreeBarsIcon, TrashIcon, UnlinkIcon, XCircleIcon, + DatabaseIcon, + GraphIcon, } from "@primer/octicons-react"; export interface DownloadGroupProps { @@ -48,8 +46,6 @@ export function DownloadGroup({ openGameInstaller, seedingStatus, }: Readonly) { - const navigate = useNavigate(); - const { t } = useTranslation("downloads"); const userPreferences = useAppSelector( @@ -60,7 +56,6 @@ export function DownloadGroup({ const { lastPacket, - progress, pauseDownload, resumeDownload, cancelDownload, @@ -69,11 +64,26 @@ export function DownloadGroup({ resumeSeeding, } = useDownload(); + const peakSpeedsRef = useRef>({}); + useEffect(() => { + if (lastPacket?.gameId && lastPacket.downloadSpeed) { + const currentPeak = peakSpeedsRef.current[lastPacket.gameId] || 0; + if (lastPacket.downloadSpeed > currentPeak) { + peakSpeedsRef.current[lastPacket.gameId] = lastPacket.downloadSpeed; + } + } + }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); + const isGameSeeding = (game: LibraryGame) => { + const entry = seedingStatus.find((s) => s.gameId === game.id); + if (entry && entry.status) return entry.status === "seeding"; + return game.download?.status === "seeding"; + }; + const getFinalDownloadSize = (game: LibraryGame) => { const download = game.download!; const isGameDownloading = lastPacket?.gameId === 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,120 +91,109 @@ export function DownloadGroup({ return "N/A"; }; - const seedingMap = useMemo(() => { - const map = new Map(); - - seedingStatus.forEach((seed) => { - map.set(seed.gameId, seed); - }); + const formatSpeed = (speed: number): string => { + return userPreferences?.showDownloadSpeedInMegabytes + ? `${formatBytes(speed)}/s` + : formatBytesToMbps(speed); + }; - return map; - }, [seedingStatus]); + const calculateETA = () => { + if (!lastPacket || lastPacket.timeRemaining < 0) return ""; - const extractGameDownload = useCallback( - async (shop: GameShop, objectId: string) => { - await window.electron.extractGameDownload(shop, objectId); - updateLibrary(); - }, - [updateLibrary] - ); - - const getGameInfo = (game: LibraryGame) => { - const download = game.download!; + try { + return formatDistance( + addMilliseconds(new Date(), lastPacket.timeRemaining), + new Date(), + { addSuffix: true } + ); + } catch (err) { + return ""; + } + }; + const getStatusText = (game: LibraryGame) => { const isGameDownloading = lastPacket?.gameId === game.id; - const finalDownloadSize = getFinalDownloadSize(game); - const seedingStatus = seedingMap.get(game.id); + const status = game.download?.status; - if (download.extracting) { - return

{t("extracting")}

; + if (game.download?.extracting) { + return t("extracting"); } if (isGameDeleting(game.id)) { - return

{t("deleting")}

; + return t("deleting"); } - if (isGameDownloading) { - if (lastPacket?.isDownloadingMetadata) { - return

{t("downloading_metadata")}

; + if (game.download?.progress === 1) { + const isTorrent = game.download?.downloader === Downloader.Torrent; + if (isTorrent) { + if (isGameSeeding(game)) { + return `${t("completed")} (${t("seeding")})`; + } + return `${t("completed")} (${t("paused")})`; } + return t("completed"); + } - if (lastPacket?.isCheckingFiles) { - return ( - <> -

{progress}

-

{t("checking_files")}

- - ); + if (isGameDownloading) { + if (lastPacket.isDownloadingMetadata) { + return t("downloading_metadata"); + } + if (lastPacket.isCheckingFiles) { + return t("checking_files"); + } + if (lastPacket.timeRemaining && lastPacket.timeRemaining > 0) { + return calculateETA(); } + return t("calculating_eta"); + } - return ( - <> -

{progress}

+ if (status === "paused") { + return t("paused"); + } + if (status === "waiting") { + return t("calculating_eta"); + } + if (status === "error") { + return t("paused"); + } -

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

+ return t("paused"); + }; - {download.downloader === Downloader.Torrent && ( - - {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds - - - )} - - ); - } + const getSeedsPeersText = (game: LibraryGame) => { + const isGameDownloading = lastPacket?.gameId === game.id; + const isTorrent = game.download?.downloader === Downloader.Torrent; - 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 (!isTorrent) return null; - if (download.status === "paused") { - return ( - <> -

{formatDownloadProgress(download.progress)}

-

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

- - ); + if (game.download?.progress === 1 && isGameSeeding(game)) { + if ( + isGameDownloading && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) + ) { + return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`; + } + return null; } - if (download.status === "active") { - return ( - <> -

{formatDownloadProgress(download.progress)}

- -

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

- - ); + if ( + isGameDownloading && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) + ) { + return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`; } - return

{t(download.status as string)}

; + return null; }; + const extractGameDownload = useCallback( + async (shop: GameShop, objectId: string) => { + await window.electron.extractGameDownload(shop, objectId); + updateLibrary(); + }, + [updateLibrary] + ); + const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const download = lastPacket?.download; const isGameDownloading = lastPacket?.gameId === game.id; @@ -202,7 +201,7 @@ export function DownloadGroup({ const deleting = isGameDeleting(game.id); if (game.download?.progress === 1) { - return [ + const actions = [ { label: t("install"), disabled: deleting, @@ -224,7 +223,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 +234,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 +249,7 @@ export function DownloadGroup({ }, }, ]; + return actions.filter((action) => action.show !== false); } if (isGameDownloading) { @@ -308,6 +308,17 @@ export function DownloadGroup({
    {library.map((game) => { + const isGameDownloading = lastPacket?.gameId === game.id; + const downloadSpeed = isGameDownloading + ? (lastPacket?.downloadSpeed ?? 0) + : 0; + const finalDownloadSize = getFinalDownloadSize(game); + const peakSpeed = peakSpeedsRef.current[game.id] || 0; + + const currentProgress = isGameDownloading + ? lastPacket.progress + : game.download?.progress || 0; + return (
  • -
    -
    - {game.title} - -
    - {DOWNLOADER_NAME[game.download!.downloader]} +
    + {game.title} +
    +
    + +
    +
    +
    + {game.logoImageUrl ? ( + {game.title} + ) : ( +

    + {game.title} +

    + )} +
    + + {DOWNLOADER_NAME[game.download!.downloader]} + +
    -
    -
    -
    -
    - +
    +
    +
    +
    + +
    + + NETWORK + + + {isGameDownloading + ? formatSpeed(downloadSpeed) + : "0 B/s"} + +
    +
    +
    + +
    + + PEAK + + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
    +
    +
    + +
    + + size on DISK + + + {finalDownloadSize} + +
    +
    +
    + + {getGameActions(game) !== null && ( + + + + )}
    - {getGameInfo(game)} +
    +
    +
    + + {game.download?.extracting || isGameDeleting(game.id) + ? getStatusText(game) + : formatDownloadProgress(currentProgress)} + + {isGameDownloading && ( + + {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} + {finalDownloadSize} + + )} +
    +
    +
    +
    + +
    + {getStatusText(game)} + {getSeedsPeersText(game) && ( + + • {getSeedsPeersText(game)} + + )} +
    +
    + +
    + {game.download?.progress === 1 ? ( + + ) : isGameDownloading ? ( + + ) : ( + + )} +
    +
    - - {getGameActions(game) !== null && ( - - - - )}
    {game.download?.downloader === Downloader.Hydra && ( From 3ff50a993241313d58db8713bb758a1d00e7c8e5 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Sat, 15 Nov 2025 00:44:54 +0530 Subject: [PATCH 2/5] feat: update download group UI with hero section and speed chart integration --- .../src/pages/downloads/download-group.scss | 366 +++++----- .../src/pages/downloads/download-group.tsx | 651 ++++++++++++------ .../src/pages/downloads/downloads.scss | 1 - 3 files changed, 652 insertions(+), 366 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 22bff5271..e8549ef32 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -4,11 +4,13 @@ display: flex; flex-direction: column; gap: calc(globals.$spacing-unit * 2); + margin-inline: calc(globals.$spacing-unit * 3); &__header { display: flex; align-items: center; justify-content: space-between; + padding-top: calc(globals.$spacing-unit * 4); gap: calc(globals.$spacing-unit * 2); &-divider { @@ -21,164 +23,180 @@ font-weight: 400; } } - - &__downloads { + &--hero { width: 100%; - gap: calc(globals.$spacing-unit * 3); - display: flex; - flex-direction: column; + position: relative; + overflow: hidden; margin: 0; padding: 0; - margin-top: globals.$spacing-unit; + margin-bottom: calc(globals.$spacing-unit * 3); } - &__item { - width: 100%; - background-color: globals.$background-color; - display: flex; - border-radius: 8px; - border: solid 1px globals.$border-color; - overflow: hidden; - box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.5); - transition: all ease 0.2s; - height: 250px; - min-height: 250px; - max-height: 250px; - position: relative; - - &:before { - content: ""; - top: 0; - left: 0; - width: 100%; - height: 172%; - position: absolute; - background: linear-gradient( - 35deg, - rgba(0, 0, 0, 0.1) 0%, - rgba(0, 0, 0, 0.07) 51.5%, - rgba(255, 255, 255, 0.15) 64%, - rgba(255, 255, 255, 0.1) 100% - ); - transition: all ease 0.3s; - transform: translateY(-36%); - opacity: 0.5; - z-index: 1; - } - - &:hover { - transform: scale(1.01); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); - border-color: rgba(255, 255, 255, 0.1); - } - - &:hover::before { - opacity: 1; - transform: translateY(-20%); - } - - &--hydra { - box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); - } - } - - &__background-image { - width: 100%; - height: 100%; + &__hero-background { position: absolute; top: 0; left: 0; + width: 100%; + height: 120%; z-index: 0; img { width: 100%; height: 100%; object-fit: cover; - object-position: 50% 25%; + object-position: 50% 20%; } } - - &__background-overlay { + // PLEASE FIX THE COLORS + &__hero-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient( - 130deg, - rgba(0, 0, 0, 0.2) 0%, - rgba(0, 0, 0, 0.5) 50%, - rgba(0, 0, 0, 0.8) 100% + to bottom, + rgba(0, 0, 0, 0.3) 0%, + rgba(0, 0, 0, 1) 70%, + rgb(27, 27, 27) 100% ); } - &__content { + &__hero-content { position: relative; - z-index: 2; - width: 100%; - height: 100%; + z-index: 1; + padding: calc(globals.$spacing-unit * 4); + padding-bottom: 0; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__hero-header { display: flex; + justify-content: flex-end; + margin-bottom: calc(globals.$spacing-unit * 2); } - &__left-section { + &__hero-logo { flex: 1; - max-width: 50%; - height: 100%; + + img { + max-width: 600px; + max-height: 200px; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.8)); + } + + 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; - padding: calc(globals.$spacing-unit * 2); + 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); } - &__logo-container { + &__hero-progress { display: flex; flex-direction: column; - gap: globals.$spacing-unit; + 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); } - &__logo { - max-width: 350px; - max-height: 150px; - object-fit: contain; - filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8)); + &__progress-status { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; } - &__game-title { - font-size: 24px; + &__progress-percentage { + font-size: 14px; font-weight: 700; color: #ffffff; - text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9); - margin: 0; } - &__downloader-badge { - align-self: flex-start; + &__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; } - &__right-section { - flex: 1; - max-width: 50%; + &__progress-time { + color: globals.$muted-color; + } + + &__hero-stats { display: flex; - flex-direction: column; + gap: calc(globals.$spacing-unit * 4); padding: calc(globals.$spacing-unit * 2); - position: relative; - justify-content: space-between; + border-radius: 12px; + background: rgba(26, 26, 26, 0.1); + backdrop-filter: blur(8px); + margin-top: calc(globals.$spacing-unit * 2); } - &__top-row { + &__stats-column { display: flex; - align-items: center; - justify-content: space-between; + 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); } - &__stats { + &__speed-chart { + flex: 1; display: flex; - gap: calc(globals.$spacing-unit * 3); + align-items: center; + justify-content: center; + overflow: hidden; + } + + &__speed-chart-canvas { + width: 100%; + height: 100px; + image-rendering: crisp-edges; } - &__stat { + &__stat-item { display: flex; - align-items: center; + align-items: flex-end; gap: calc(globals.$spacing-unit); svg { @@ -187,9 +205,8 @@ } } - &__stat-info { + &__stat-content { display: flex; - flex-direction: column; gap: 2px; } @@ -199,108 +216,135 @@ letter-spacing: 0.5px; font-size: 10px; color: rgba(255, 255, 255, 0.6); - line-height: 1; } &__stat-value { color: #ffffff; font-weight: 700; - font-size: 14px; + font-size: 11px; line-height: 1.2; } - &__menu-button { - border: none; - padding: 8px; - min-height: unset; - background-color: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); - flex-shrink: 0; + &__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(0, 0, 0, 0.8); + 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; } } - &__progress-section { + &__simple-info { + flex: 1; + min-width: 0; display: flex; flex-direction: column; gap: calc(globals.$spacing-unit / 2); - flex: 1; } - &__bottom-row { + &__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; } - &__progress-info { + &__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; - justify-content: space-between; + 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; } - &__progress-text { - font-weight: 600; + &__simple-actions { + flex-shrink: 0; } - &__progress-size { - color: globals.$muted-color; + &__simple-menu-btn { + padding: calc(globals.$spacing-unit); + min-height: unset; } &__progress-bar { width: 100%; - height: 6px; + 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; + transition: + width 0.3s ease, + background 0.35s ease; border-radius: 4px; } - - &__time-remaining { - font-size: 11px; - color: globals.$muted-color; - text-align: left; - min-height: 16px; - } - - &__quick-actions { - display: flex; - flex-shrink: 0; - min-height: 40px; - align-items: center; - } - - &__action-btn { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit / 2); - padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2); - font-size: 13px; - font-weight: 600; - - svg { - width: 14px; - height: 14px; - } - } - - &__hydra-gradient { - background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%); - box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15); - width: 100%; - position: absolute; - bottom: 0; - height: 2px; - z-index: 2; - } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 9f9993175..64cf58032 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,5 +1,3 @@ -import cn from "classnames"; - import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import { Badge, Button } from "@renderer/components"; @@ -12,7 +10,7 @@ import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { DropdownMenu, DropdownMenuItem, @@ -27,9 +25,99 @@ import { TrashIcon, UnlinkIcon, XCircleIcon, - DatabaseIcon, GraphIcon, } from "@primer/octicons-react"; +import { average } from "color.js"; + +interface SpeedChartProps { + speeds: number[]; + peakSpeed: number; + color?: string; +} + +function SpeedChart({ + speeds, + peakSpeed, + color = "rgba(255, 255, 255, 1)", +}: SpeedChartProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const width = canvas.width; + const height = canvas.height; + 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); + + 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 < speeds.length) { + const speed = speeds[i] || 0; + const filledHeight = (speed / maxHeight) * height; + + if (filledHeight > 0) { + const gradient = ctx.createLinearGradient( + 0, + height - filledHeight, + 0, + height + ); + + let r = 8, + g = 234, + b = 121; + + if (color.startsWith("#")) { + const hex = color.replace("#", ""); + r = parseInt(hex.substring(0, 2), 16); + g = parseInt(hex.substring(2, 4), 16); + b = parseInt(hex.substring(4, 6), 16); + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = parseInt(matches[0]); + g = parseInt(matches[1]); + b = parseInt(matches[2]); + } + } + + 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(); + } + } + } + }, [speeds, peakSpeed, color]); + + return ( + + ); +} export interface DownloadGroupProps { library: LibraryGame[]; @@ -65,14 +153,98 @@ export function DownloadGroup({ } = 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) { - const currentPeak = peakSpeedsRef.current[lastPacket.gameId] || 0; + if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) { + const gameId = lastPacket.gameId; + + const currentPeak = peakSpeedsRef.current[gameId] || 0; if (lastPacket.downloadSpeed > currentPeak) { - peakSpeedsRef.current[lastPacket.gameId] = lastPacket.downloadSpeed; + peakSpeedsRef.current[gameId] = lastPacket.downloadSpeed; + } + + if (!speedHistoryRef.current[gameId]) { + speedHistoryRef.current[gameId] = []; + } + + speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed); + + if (speedHistoryRef.current[gameId].length > 60) { + speedHistoryRef.current[gameId].shift(); } } }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); + + useEffect(() => { + library.forEach((game) => { + 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 cleanupIntervals: NodeJS.Timeout[] = []; + + library.forEach((game) => { + if (game.download?.progress === 1 || !game.download) { + if (speedHistoryRef.current[game.id]?.length > 0) { + const interval = setInterval(() => { + if (speedHistoryRef.current[game.id]?.length > 0) { + speedHistoryRef.current[game.id].shift(); + } else { + clearInterval(interval); + } + }, 50); + cleanupIntervals.push(interval); + } + } + }); + + return () => { + cleanupIntervals.forEach((interval) => clearInterval(interval)); + }; + }, [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 && entry.status) return entry.status === "seeding"; @@ -141,10 +313,7 @@ export function DownloadGroup({ if (lastPacket.isCheckingFiles) { return t("checking_files"); } - if (lastPacket.timeRemaining && lastPacket.timeRemaining > 0) { - return calculateETA(); - } - return t("calculating_eta"); + return t("download_in_progress"); } if (status === "paused") { @@ -160,32 +329,6 @@ export function DownloadGroup({ return t("paused"); }; - const getSeedsPeersText = (game: LibraryGame) => { - const isGameDownloading = lastPacket?.gameId === game.id; - const isTorrent = game.download?.downloader === Downloader.Torrent; - - if (!isTorrent) return null; - - if (game.download?.progress === 1 && isGameSeeding(game)) { - if ( - isGameDownloading && - (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) - ) { - return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`; - } - return null; - } - - if ( - isGameDownloading && - (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) - ) { - return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`; - } - - return null; - }; - const extractGameDownload = useCallback( async (shop: GameShop, objectId: string) => { await window.electron.extractGameDownload(shop, objectId); @@ -298,194 +441,294 @@ export function DownloadGroup({ if (!library.length) return null; - return ( -
    -
    -

    {title}

    -
    -

    {library.length}

    -
    + const isDownloadingGroup = title === t("download_in_progress"); + const isQueuedGroup = title === t("queued_downloads"); -
      - {library.map((game) => { - const isGameDownloading = lastPacket?.gameId === game.id; - const downloadSpeed = isGameDownloading - ? (lastPacket?.downloadSpeed ?? 0) - : 0; - const finalDownloadSize = getFinalDownloadSize(game); - const peakSpeed = peakSpeedsRef.current[game.id] || 0; - - const currentProgress = isGameDownloading - ? lastPacket.progress - : game.download?.progress || 0; + if (isDownloadingGroup && library.length > 0) { + const game = library[0]; // Only one active download + const isGameDownloading = lastPacket?.gameId === game.id; + const downloadSpeed = isGameDownloading + ? (lastPacket?.downloadSpeed ?? 0) + : 0; + const finalDownloadSize = getFinalDownloadSize(game); + const peakSpeed = peakSpeedsRef.current[game.id] || 0; + const currentProgress = isGameDownloading + ? lastPacket.progress + : game.download?.progress || 0; + + const dominantColor = dominantColors[game.id] || "#ffffff"; + + return ( + <> +
      +
      + {game.title} +
      +
      + +
      +
      +
      + + + +
      +
      + +
      +
      + {game.logoImageUrl ? ( + {game.title} + ) : ( +

      {game.title}

      + )} +
      - return ( -
    • -
      - {game.title} pauseDownload(game.shop, game.objectId)} + className="download-group__hero-action-btn" + style={{ + backgroundColor: dominantColor || "#fff", + borderColor: dominantColor || "#fff", + }} + > + + {t("pause")} + + ) : ( + + )} +
      + +
      +
      + + {getStatusText(game)} + + + {formatDownloadProgress(currentProgress)} + +
      +
      +
      { + try { + const isPaused = game.download?.status === "paused"; + const colorToUse = isPaused + ? "#ffffff" + : dominantColor || "#ffffff"; + const hex = colorToUse; + if (hex.startsWith("#")) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + if (hex.startsWith("rgb")) { + const nums = hex.match(/\d+/g) || []; + const r = nums[0] || 8; + const g = nums[1] || 234; + const b = nums[2] || 121; + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + return undefined; + } catch (e) { + return undefined; + } + })(), + }} /> -
      +
      + + {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"} + +
      +
      -
      -
      -
      - {game.logoImageUrl ? ( - {game.title} - ) : ( -

      - {game.title} -

      - )} -
      - - {DOWNLOADER_NAME[game.download!.downloader]} - -
      +
      + + + +
      + + {t("peak")}: + + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} +
      -
      -
      -
      -
      - -
      - - NETWORK - + + {game.download?.downloader === Downloader.Torrent && + isGameDownloading && + lastPacket && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( +
      +
      + + Seeds:{" "} - {isGameDownloading - ? formatSpeed(downloadSpeed) - : "0 B/s"} - -
      -
      -
      - -
      - - PEAK + {lastPacket.numSeeds} + , Peers:{" "} - {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} - -
      -
      -
      - -
      - - size on DISK + {lastPacket.numPeers} - - {finalDownloadSize} - -
      +
      + )} +
      - {getGameActions(game) !== null && ( - - - - )} -
      +
      + +
      +
      +
      +
      + + ); + } -
      -
      -
      - - {game.download?.extracting || isGameDeleting(game.id) - ? getStatusText(game) - : formatDownloadProgress(currentProgress)} - - {isGameDownloading && ( - - {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} - {finalDownloadSize} - - )} -
      -
      -
      -
      + return ( +
      +
      +

      {title}

      +
      +

      {library.length}

      +
      -
      - {getStatusText(game)} - {getSeedsPeersText(game) && ( - - • {getSeedsPeersText(game)} - - )} -
      -
      +
        + {library.map((game) => { + const finalDownloadSize = getFinalDownloadSize(game); + const currentProgress = game.download?.progress || 0; -
        - {game.download?.progress === 1 ? ( - - ) : isGameDownloading ? ( - - ) : ( - - )} -
        -
      + return ( +
    • +
      + {game.title} +
      + +
      +

      {game.title}

      +
      + {DOWNLOADER_NAME[game.download!.downloader]} + + {finalDownloadSize} + + {game.download?.progress === 1 && isGameSeeding(game) && ( + + {t("seeding")} + + )}
      - {game.download?.downloader === Downloader.Hydra && ( -
      + {isQueuedGroup && ( +
      + + {formatDownloadProgress(currentProgress)} + +
      +
      { + try { + const isPaused = game.download?.status === "paused"; + const colorToUse = isPaused + ? "#ffffff" + : dominantColors[game.id] || "#ffffff"; + const hex = colorToUse; + if (hex.startsWith("#")) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + if (hex.startsWith("rgb")) { + const nums = hex.match(/\d+/g) || []; + const r = nums[0] || 8; + const g = nums[1] || 234; + const b = nums[2] || 121; + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + return undefined; + } catch (e) { + return undefined; + } + })(), + }} + /> +
      +
      )} + +
      + + + +
    • ); })} diff --git a/src/renderer/src/pages/downloads/downloads.scss b/src/renderer/src/pages/downloads/downloads.scss index 8290a66e7..abada8d7f 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%; } From 0b70a28c0873ff89ea06c6d777993f77e3f147b1 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Sat, 15 Nov 2025 01:16:23 +0530 Subject: [PATCH 3/5] feat: enhance download group UI with speed chart improvements and gradient progress bar --- .../src/pages/downloads/download-group.scss | 3 +- .../src/pages/downloads/download-group.tsx | 292 +++++++++--------- 2 files changed, 147 insertions(+), 148 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index e8549ef32..0891e6824 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -5,12 +5,12 @@ flex-direction: column; gap: calc(globals.$spacing-unit * 2); margin-inline: calc(globals.$spacing-unit * 3); + margin-bottom: calc(globals.$spacing-unit * 4); &__header { display: flex; align-items: center; justify-content: space-between; - padding-top: calc(globals.$spacing-unit * 4); gap: calc(globals.$spacing-unit * 2); &-divider { @@ -166,6 +166,7 @@ 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); diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 64cf58032..b70fbc5bd 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -10,7 +10,7 @@ import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DropdownMenu, DropdownMenuItem, @@ -29,6 +29,23 @@ import { } 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 = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = 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; @@ -49,73 +66,86 @@ function SpeedChart({ const ctx = canvas.getContext("2d"); if (!ctx) return; - const width = canvas.width; - const height = canvas.height; - 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); - - 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 < speeds.length) { - const speed = speeds[i] || 0; - const filledHeight = (speed / maxHeight) * height; - - if (filledHeight > 0) { - const gradient = ctx.createLinearGradient( - 0, - height - filledHeight, - 0, - height - ); + 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 = parseInt(hex.substring(0, 2), 16); + g = parseInt(hex.substring(2, 4), 16); + b = parseInt(hex.substring(4, 6), 16); + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = parseInt(matches[0]); + g = parseInt(matches[1]); + b = parseInt(matches[2]); + } + } - let r = 8, - g = 234, - b = 121; - - if (color.startsWith("#")) { - const hex = color.replace("#", ""); - r = parseInt(hex.substring(0, 2), 16); - g = parseInt(hex.substring(2, 4), 16); - b = parseInt(hex.substring(4, 6), 16); - } else if (color.startsWith("rgb")) { - const matches = color.match(/\d+/g); - if (matches && matches.length >= 3) { - r = parseInt(matches[0]); - g = parseInt(matches[1]); - b = parseInt(matches[2]); - } + 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 < speeds.length) { + const speed = speeds[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(); } + } + } + }; - gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); - gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); + animationFrameId = requestAnimationFrame(draw); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); - ctx.fill(); - } + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); } - } + }; }, [speeds, peakSpeed, color]); return ( - + ); } @@ -212,25 +242,23 @@ export function DownloadGroup({ }, [library]); useEffect(() => { - const cleanupIntervals: NodeJS.Timeout[] = []; + const timeouts: NodeJS.Timeout[] = []; library.forEach((game) => { - if (game.download?.progress === 1 || !game.download) { - if (speedHistoryRef.current[game.id]?.length > 0) { - const interval = setInterval(() => { - if (speedHistoryRef.current[game.id]?.length > 0) { - speedHistoryRef.current[game.id].shift(); - } else { - clearInterval(interval); - } - }, 50); - cleanupIntervals.push(interval); - } + 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 () => { - cleanupIntervals.forEach((interval) => clearInterval(interval)); + timeouts.forEach((timeout) => clearTimeout(timeout)); }; }, [library]); @@ -251,9 +279,17 @@ export function DownloadGroup({ return game.download?.status === "seeding"; }; + const isGameDownloadingMap = useMemo(() => { + const map: Record = {}; + library.forEach((game) => { + 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 != null) return formatBytes(download.fileSize); @@ -284,7 +320,7 @@ export function DownloadGroup({ }; const getStatusText = (game: LibraryGame) => { - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; const status = game.download?.status; if (game.download?.extracting) { @@ -306,7 +342,7 @@ export function DownloadGroup({ return t("completed"); } - if (isGameDownloading) { + if (isGameDownloading && lastPacket) { if (lastPacket.isDownloadingMetadata) { return t("downloading_metadata"); } @@ -339,7 +375,7 @@ export function DownloadGroup({ 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); @@ -445,18 +481,19 @@ export function DownloadGroup({ const isQueuedGroup = title === t("queued_downloads"); if (isDownloadingGroup && library.length > 0) { - const game = library[0]; // Only one active download - const isGameDownloading = lastPacket?.gameId === game.id; + 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.progress - : game.download?.progress || 0; + const currentProgress = + isGameDownloading && lastPacket + ? lastPacket.progress + : game.download?.progress || 0; - const dominantColor = dominantColors[game.id] || "#ffffff"; + const dominantColor = dominantColors[game.id] || "#fff"; return ( <> @@ -498,8 +535,8 @@ export function DownloadGroup({ onClick={() => pauseDownload(game.shop, game.objectId)} className="download-group__hero-action-btn" style={{ - backgroundColor: dominantColor || "#fff", - borderColor: dominantColor || "#fff", + backgroundColor: dominantColor, + borderColor: dominantColor, }} > @@ -511,8 +548,8 @@ export function DownloadGroup({ onClick={() => resumeDownload(game.shop, game.objectId)} className="download-group__hero-action-btn" style={{ - backgroundColor: dominantColor || "#08ea79", - borderColor: dominantColor || "#08ea79", + backgroundColor: dominantColor, + borderColor: dominantColor, }} > @@ -535,31 +572,10 @@ export function DownloadGroup({ className="download-group__progress-fill" style={{ width: `${currentProgress * 100}%`, - background: (() => { - try { - const isPaused = game.download?.status === "paused"; - const colorToUse = isPaused - ? "#ffffff" - : dominantColor || "#ffffff"; - const hex = colorToUse; - if (hex.startsWith("#")) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - if (hex.startsWith("rgb")) { - const nums = hex.match(/\d+/g) || []; - const r = nums[0] || 8; - const g = nums[1] || 234; - const b = nums[2] || 121; - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - return undefined; - } catch (e) { - return undefined; - } - })(), + background: getProgressGradient( + dominantColor, + game.download?.status === "paused" + ), }} />
      @@ -644,6 +660,17 @@ export function DownloadGroup({ ); } + const downloadInfo = useMemo( + () => + library.map((game) => ({ + game, + size: getFinalDownloadSize(game), + progress: game.download?.progress || 0, + isSeeding: isGameSeeding(game), + })), + [library, lastPacket?.gameId] + ); + return (
      @@ -653,10 +680,7 @@ export function DownloadGroup({
        - {library.map((game) => { - const finalDownloadSize = getFinalDownloadSize(game); - const currentProgress = game.download?.progress || 0; - + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { return (
      • @@ -667,10 +691,8 @@ export function DownloadGroup({

        {game.title}

        {DOWNLOADER_NAME[game.download!.downloader]} - - {finalDownloadSize} - - {game.download?.progress === 1 && isGameSeeding(game) && ( + {size} + {game.download?.progress === 1 && seeding && ( {t("seeding")} @@ -681,38 +703,14 @@ export function DownloadGroup({ {isQueuedGroup && (
        - {formatDownloadProgress(currentProgress)} + {formatDownloadProgress(progress)}
        { - try { - const isPaused = game.download?.status === "paused"; - const colorToUse = isPaused - ? "#ffffff" - : dominantColors[game.id] || "#ffffff"; - const hex = colorToUse; - if (hex.startsWith("#")) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - if (hex.startsWith("rgb")) { - const nums = hex.match(/\d+/g) || []; - const r = nums[0] || 8; - const g = nums[1] || 234; - const b = nums[2] || 121; - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - return undefined; - } catch (e) { - return undefined; - } - })(), + width: `${progress * 100}%`, + backgroundColor: "#fff", }} />
        From cc38be4383c5ba15046cbda060693cbfce290b13 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Sat, 15 Nov 2025 11:31:39 +0530 Subject: [PATCH 4/5] Fixed linter and sonarcloud errors, refactored some functions and fixed UI padding issues with certain themes. --- .../src/pages/downloads/download-group.scss | 27 +- .../src/pages/downloads/download-group.tsx | 527 ++++++++++-------- 2 files changed, 323 insertions(+), 231 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 0891e6824..4f2a63167 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -5,7 +5,15 @@ flex-direction: column; gap: calc(globals.$spacing-unit * 2); margin-inline: calc(globals.$spacing-unit * 3); - margin-bottom: calc(globals.$spacing-unit * 4); + padding-block: calc(globals.$spacing-unit * 3); + + &--queued { + padding-bottom: 0; + } + + &--completed { + padding-top: 0; + } &__header { display: flex; @@ -29,7 +37,7 @@ overflow: hidden; margin: 0; padding: 0; - margin-bottom: calc(globals.$spacing-unit * 3); + padding-bottom: calc(globals.$spacing-unit * 3); } &__hero-background { @@ -57,8 +65,8 @@ background: linear-gradient( to bottom, rgba(0, 0, 0, 0.3) 0%, - rgba(0, 0, 0, 1) 70%, - rgb(27, 27, 27) 100% + rgb(5, 5, 5) 70%, + rgb(26, 26, 26) 100% ); } @@ -85,7 +93,6 @@ max-width: 600px; max-height: 200px; object-fit: contain; - filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.8)); } h1 { @@ -113,7 +120,11 @@ &__hero-menu-btn { background-color: rgba(0, 0, 0, 0.4); - padding: calc(globals.$spacing-unit); + padding: calc(globals.$spacing-unit * 1); + min-height: unset; + } + &__hero-menu-btn:hover { + background-color: rgba(0, 0, 0, 0.8); } &__hero-progress { @@ -320,6 +331,10 @@ &__simple-actions { flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + gap: calc(globals.$spacing-unit); } &__simple-menu-btn { diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index b70fbc5bd..3f9c50eac 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -37,9 +37,9 @@ const getProgressGradient = ( if (!hex.startsWith("#")) return undefined; try { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); + 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; @@ -56,7 +56,7 @@ function SpeedChart({ speeds, peakSpeed, color = "rgba(255, 255, 255, 1)", -}: SpeedChartProps) { +}: Readonly) { const canvasRef = useRef(null); useEffect(() => { @@ -91,28 +91,28 @@ function SpeedChart({ b = 255; if (color.startsWith("#")) { const hex = color.replace("#", ""); - r = parseInt(hex.substring(0, 2), 16); - g = parseInt(hex.substring(2, 4), 16); - b = parseInt(hex.substring(4, 6), 16); + 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 = parseInt(matches[0]); - g = parseInt(matches[1]); - b = parseInt(matches[2]); + 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 < speeds.length) { - const speed = speeds[i] || 0; + if (i < displaySpeeds.length) { + const speed = displaySpeeds[i] || 0; const filledHeight = (speed / maxHeight) * height; if (filledHeight > 0) { @@ -133,14 +133,13 @@ function SpeedChart({ } } } + animationFrameId = requestAnimationFrame(draw); }; animationFrameId = requestAnimationFrame(draw); return () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } + cancelAnimationFrame(animationFrameId); }; }, [speeds, peakSpeed, color]); @@ -149,6 +148,201 @@ function SpeedChart({ ); } +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[]; title: string; @@ -219,14 +413,14 @@ export function DownloadGroup({ speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed); - if (speedHistoryRef.current[gameId].length > 60) { + if (speedHistoryRef.current[gameId].length > 120) { speedHistoryRef.current[gameId].shift(); } } }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); useEffect(() => { - library.forEach((game) => { + for (const game of library) { if ( game.download && game.download.progress < 0.01 && @@ -238,13 +432,13 @@ export function DownloadGroup({ peakSpeedsRef.current[game.id] = 0; } } - }); + } }, [library]); useEffect(() => { const timeouts: NodeJS.Timeout[] = []; - library.forEach((game) => { + for (const game of library) { if ( game.download?.progress === 1 && speedHistoryRef.current[game.id]?.length > 0 @@ -255,10 +449,12 @@ export function DownloadGroup({ }, 10_000); timeouts.push(timeout); } - }); + } return () => { - timeouts.forEach((timeout) => clearTimeout(timeout)); + for (const timeout of timeouts) { + clearTimeout(timeout); + } }; }, [library]); @@ -275,15 +471,15 @@ export function DownloadGroup({ const isGameSeeding = (game: LibraryGame) => { const entry = seedingStatus.find((s) => s.gameId === game.id); - if (entry && entry.status) return entry.status === "seeding"; + if (entry?.status) return entry.status === "seeding"; return game.download?.status === "seeding"; }; const isGameDownloadingMap = useMemo(() => { const map: Record = {}; - library.forEach((game) => { + for (const game of library) { map[game.id] = lastPacket?.gameId === game.id; - }); + } return map; }, [library, lastPacket?.gameId]); @@ -306,17 +502,29 @@ export function DownloadGroup({ }; const calculateETA = () => { - if (!lastPacket || lastPacket.timeRemaining < 0) return ""; - - try { - return formatDistance( - addMilliseconds(new Date(), lastPacket.timeRemaining), - new Date(), - { addSuffix: true } - ); - } catch (err) { + if ( + !lastPacket || + lastPacket.timeRemaining < 0 || + !Number.isFinite(lastPacket.timeRemaining) + ) { return ""; } + + 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) => { @@ -332,14 +540,7 @@ export function DownloadGroup({ } if (game.download?.progress === 1) { - const isTorrent = game.download?.downloader === Downloader.Torrent; - if (isTorrent) { - if (isGameSeeding(game)) { - return `${t("completed")} (${t("seeding")})`; - } - return `${t("completed")} (${t("paused")})`; - } - return t("completed"); + return getCompletedStatusText(game); } if (isGameDownloading && lastPacket) { @@ -352,17 +553,15 @@ export function DownloadGroup({ return t("download_in_progress"); } - if (status === "paused") { - return t("paused"); - } - if (status === "waiting") { - return t("calculating_eta"); + switch (status) { + case "paused": + case "error": + return t("paused"); + case "waiting": + return t("calculating_eta"); + default: + return t("paused"); } - if (status === "error") { - return t("paused"); - } - - return t("paused"); }; const extractGameDownload = useCallback( @@ -475,10 +674,22 @@ 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]; @@ -496,183 +707,31 @@ export function DownloadGroup({ const dominantColor = dominantColors[game.id] || "#fff"; 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} - - -
        -
        - )} -
        - -
        - -
        -
        -
        -
        - + ); } - const downloadInfo = useMemo( - () => - library.map((game) => ({ - game, - size: getFinalDownloadSize(game), - progress: game.download?.progress || 0, - isSeeding: isGameSeeding(game), - })), - [library, lastPacket?.gameId] - ); - return ( -
        +

        {title}

        @@ -718,6 +777,24 @@ export function DownloadGroup({ )}
        + {game.download?.progress === 1 ? ( + + ) : isQueuedGroup ? ( + + ) : null} - ) : isQueuedGroup ? ( + )} + {isQueuedGroup && game.download?.progress !== 1 && ( - ) : null} + )}