mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 22:06:17 +00:00
feat: enhance download page UI with improved layout and styling for cards
This commit is contained in:
@@ -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;
|
||||
|
||||
&__background-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 25%;
|
||||
}
|
||||
}
|
||||
|
||||
&__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%
|
||||
);
|
||||
}
|
||||
|
||||
&__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 {
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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 {
|
||||
&__left-section {
|
||||
flex: 1;
|
||||
max-width: 50%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__logo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-size: 14px;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
&__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;
|
||||
max-width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DownloadGroupProps>) {
|
||||
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<Record<string, number>>({});
|
||||
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,15 +91,100 @@ export function DownloadGroup({
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
const seedingMap = useMemo(() => {
|
||||
const map = new Map<string, SeedingStatus>();
|
||||
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) return "";
|
||||
|
||||
return map;
|
||||
}, [seedingStatus]);
|
||||
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 status = game.download?.status;
|
||||
|
||||
if (game.download?.extracting) {
|
||||
return t("extracting");
|
||||
}
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return t("deleting");
|
||||
}
|
||||
|
||||
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 (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");
|
||||
}
|
||||
|
||||
if (status === "paused") {
|
||||
return t("paused");
|
||||
}
|
||||
if (status === "waiting") {
|
||||
return t("calculating_eta");
|
||||
}
|
||||
if (status === "error") {
|
||||
return t("paused");
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -99,102 +194,6 @@ 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 <p>{t("extracting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
if (lastPacket?.isDownloadingMetadata) {
|
||||
return <p>{t("downloading_metadata")}</p>;
|
||||
}
|
||||
|
||||
if (lastPacket?.isCheckingFiles) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("checking_files")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(lastPacket.download.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
|
||||
{download.downloader === Downloader.Torrent && (
|
||||
<small
|
||||
className="download-group__details-with-article"
|
||||
data-open-article="peers-and-seeds"
|
||||
>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
<QuestionIcon size={12} />
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (download.progress === 1) {
|
||||
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
|
||||
|
||||
return download.status === "seeding" &&
|
||||
download.downloader === Downloader.Torrent ? (
|
||||
<>
|
||||
<p
|
||||
data-open-article="seeding"
|
||||
className="download-group__details-with-article"
|
||||
>
|
||||
{t("seeding")}
|
||||
|
||||
<QuestionIcon />
|
||||
</p>
|
||||
{uploadSpeed && <p>{uploadSpeed}/s</p>}
|
||||
</>
|
||||
) : (
|
||||
<p>{t("completed")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (download.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
<p>{t(download.queued ? "queued" : "paused")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (download.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(download.bytesDownloaded)} / {finalDownloadSize}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>{t(download.status as string)}</p>;
|
||||
};
|
||||
|
||||
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: <UnlinkIcon />,
|
||||
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: <LinkIcon />,
|
||||
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({
|
||||
|
||||
<ul className="download-group__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;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
@@ -316,55 +327,160 @@ export function DownloadGroup({
|
||||
game.download?.downloader === Downloader.Hydra,
|
||||
})}
|
||||
>
|
||||
<div className="download-group__cover">
|
||||
<div className="download-group__cover-backdrop">
|
||||
<img
|
||||
src={game.libraryImageUrl ?? ""}
|
||||
className="download-group__cover-image"
|
||||
alt={game.title}
|
||||
/>
|
||||
|
||||
<div className="download-group__cover-content">
|
||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="download-group__background-image">
|
||||
<img
|
||||
src={game.libraryHeroImageUrl || game.libraryImageUrl || ""}
|
||||
alt={game.title}
|
||||
/>
|
||||
<div className="download-group__background-overlay" />
|
||||
</div>
|
||||
<div className="download-group__right-content">
|
||||
<div className="download-group__details">
|
||||
<div className="download-group__title-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="download-group__title"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
|
||||
<div className="download-group__content">
|
||||
<div className="download-group__left-section">
|
||||
<div className="download-group__logo-container">
|
||||
{game.logoImageUrl ? (
|
||||
<img
|
||||
src={game.logoImageUrl}
|
||||
alt={game.title}
|
||||
className="download-group__logo"
|
||||
/>
|
||||
) : (
|
||||
<h3 className="download-group__game-title">
|
||||
{game.title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="download-group__downloader-badge">
|
||||
<Badge>
|
||||
{DOWNLOADER_NAME[game.download!.downloader]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="download-group__right-section">
|
||||
<div className="download-group__top-row">
|
||||
<div className="download-group__stats">
|
||||
<div className="download-group__stat">
|
||||
<DownloadIcon size={16} />
|
||||
<div className="download-group__stat-info">
|
||||
<span className="download-group__stat-label">
|
||||
NETWORK
|
||||
</span>
|
||||
<span className="download-group__stat-value">
|
||||
{isGameDownloading
|
||||
? formatSpeed(downloadSpeed)
|
||||
: "0 B/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="download-group__stat">
|
||||
<GraphIcon size={16} />
|
||||
<div className="download-group__stat-info">
|
||||
<span className="download-group__stat-label">
|
||||
PEAK
|
||||
</span>
|
||||
<span className="download-group__stat-value">
|
||||
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="download-group__stat">
|
||||
<DatabaseIcon size={16} />
|
||||
<div className="download-group__stat-info">
|
||||
<span className="download-group__stat-label">
|
||||
size on DISK
|
||||
</span>
|
||||
<span className="download-group__stat-value">
|
||||
{finalDownloadSize}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getGameActions(game) !== null && (
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
<Button
|
||||
className="download-group__menu-button"
|
||||
theme="outline"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{getGameInfo(game)}
|
||||
</div>
|
||||
<div className="download-group__bottom-row">
|
||||
<div className="download-group__progress-section">
|
||||
<div className="download-group__progress-info">
|
||||
<span className="download-group__progress-text">
|
||||
{game.download?.extracting || isGameDeleting(game.id)
|
||||
? getStatusText(game)
|
||||
: formatDownloadProgress(currentProgress)}
|
||||
</span>
|
||||
{isGameDownloading && (
|
||||
<span className="download-group__progress-size">
|
||||
{formatBytes(lastPacket.download.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="download-group__progress-bar">
|
||||
<div
|
||||
className="download-group__progress-fill"
|
||||
style={{
|
||||
width: `${currentProgress * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{getGameActions(game) !== null && (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
items={getGameActions(game)}
|
||||
sideOffset={-75}
|
||||
>
|
||||
<Button
|
||||
className="download-group__menu-button"
|
||||
theme="outline"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="download-group__time-remaining">
|
||||
{getStatusText(game)}
|
||||
{getSeedsPeersText(game) && (
|
||||
<span style={{ opacity: 0.7, marginLeft: "8px" }}>
|
||||
• {getSeedsPeersText(game)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="download-group__quick-actions">
|
||||
{game.download?.progress === 1 ? (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() =>
|
||||
openGameInstaller(game.shop, game.objectId)
|
||||
}
|
||||
className="download-group__action-btn"
|
||||
disabled={isGameDeleting(game.id)}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
{t("install")}
|
||||
</Button>
|
||||
) : isGameDownloading ? (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() =>
|
||||
pauseDownload(game.shop, game.objectId)
|
||||
}
|
||||
className="download-group__action-btn"
|
||||
>
|
||||
<ColumnsIcon size={16} />
|
||||
{t("pause")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() =>
|
||||
resumeDownload(game.shop, game.objectId)
|
||||
}
|
||||
className="download-group__action-btn"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
{t("resume")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{game.download?.downloader === Downloader.Hydra && (
|
||||
|
||||
Reference in New Issue
Block a user