mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 00:33:59 +00:00
feat: update download group UI with hero section and speed chart integration
This commit is contained in:
@@ -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%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__left-section {
|
||||
flex: 1;
|
||||
max-width: 50%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__logo-container {
|
||||
z-index: 1;
|
||||
padding: calc(globals.$spacing-unit * 4);
|
||||
padding-bottom: 0;
|
||||
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;
|
||||
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;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__stats {
|
||||
&__hero-header {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
justify-content: flex-end;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__stat {
|
||||
&__hero-logo {
|
||||
flex: 1;
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
&__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;
|
||||
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;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__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 {
|
||||
@@ -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;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-section {
|
||||
&__simple-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
flex: 1;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__bottom-row {
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-info {
|
||||
&__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;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__progress-text {
|
||||
&__simple-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__progress-size {
|
||||
&__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;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HTMLCanvasElement>(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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={1200}
|
||||
height={100}
|
||||
className="download-group__speed-chart-canvas"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DownloadGroupProps {
|
||||
library: LibraryGame[];
|
||||
@@ -65,14 +153,98 @@ export function DownloadGroup({
|
||||
} = useDownload();
|
||||
|
||||
const peakSpeedsRef = useRef<Record<string, number>>({});
|
||||
const speedHistoryRef = useRef<Record<string, number[]>>({});
|
||||
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
|
||||
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,6 +441,209 @@ export function DownloadGroup({
|
||||
|
||||
if (!library.length) return null;
|
||||
|
||||
const isDownloadingGroup = title === t("download_in_progress");
|
||||
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 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 (
|
||||
<>
|
||||
<div className="download-group download-group--hero">
|
||||
<div className="download-group__hero-background">
|
||||
<img
|
||||
src={game.libraryHeroImageUrl || game.libraryImageUrl || ""}
|
||||
alt={game.title}
|
||||
/>
|
||||
<div className="download-group__hero-overlay" />
|
||||
</div>
|
||||
|
||||
<div className="download-group__hero-content">
|
||||
<div className="download-group__hero-header">
|
||||
<div className="download-group__hero-actions">
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
<Button
|
||||
className="download-group__hero-menu-btn"
|
||||
theme="outline"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="download-group__hero-action-row">
|
||||
<div className="download-group__hero-logo">
|
||||
{game.logoImageUrl ? (
|
||||
<img src={game.logoImageUrl} alt={game.title} />
|
||||
) : (
|
||||
<h1>{game.title}</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGameDownloading ? (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => pauseDownload(game.shop, game.objectId)}
|
||||
className="download-group__hero-action-btn"
|
||||
style={{
|
||||
backgroundColor: dominantColor || "#fff",
|
||||
borderColor: dominantColor || "#fff",
|
||||
}}
|
||||
>
|
||||
<ColumnsIcon size={16} />
|
||||
{t("pause")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => resumeDownload(game.shop, game.objectId)}
|
||||
className="download-group__hero-action-btn"
|
||||
style={{
|
||||
backgroundColor: dominantColor || "#08ea79",
|
||||
borderColor: dominantColor || "#08ea79",
|
||||
}}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
{t("resume")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="download-group__hero-progress">
|
||||
<div className="download-group__progress-header">
|
||||
<span className="download-group__progress-status">
|
||||
{getStatusText(game)}
|
||||
</span>
|
||||
<span className="download-group__progress-percentage">
|
||||
{formatDownloadProgress(currentProgress)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="download-group__progress-bar">
|
||||
<div
|
||||
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;
|
||||
}
|
||||
})(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-group__progress-details">
|
||||
<span className="download-group__progress-size">
|
||||
{isGameDownloading && lastPacket
|
||||
? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}`
|
||||
: `0 B / ${finalDownloadSize}`}
|
||||
</span>
|
||||
<span className="download-group__progress-time">
|
||||
{isGameDownloading &&
|
||||
lastPacket?.timeRemaining &&
|
||||
lastPacket.timeRemaining > 0
|
||||
? calculateETA()
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="download-group__hero-stats">
|
||||
<div className="download-group__stats-column">
|
||||
<div className="download-group__stat-item">
|
||||
<span style={{ color: dominantColor, display: "flex" }}>
|
||||
<DownloadIcon size={16} />
|
||||
</span>
|
||||
<div className="download-group__stat-content">
|
||||
<span className="download-group__stat-label">
|
||||
{t("network")}:
|
||||
</span>
|
||||
<span className="download-group__stat-value">
|
||||
{isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="download-group__stat-item">
|
||||
<span style={{ color: dominantColor, display: "flex" }}>
|
||||
<GraphIcon size={16} />
|
||||
</span>
|
||||
<div className="download-group__stat-content">
|
||||
<span className="download-group__stat-label">
|
||||
{t("peak")}:
|
||||
</span>
|
||||
<span className="download-group__stat-value">
|
||||
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{game.download?.downloader === Downloader.Torrent &&
|
||||
isGameDownloading &&
|
||||
lastPacket &&
|
||||
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
|
||||
<div className="download-group__stat-item">
|
||||
<div className="download-group__stat-content">
|
||||
<span className="download-group__stat-label">
|
||||
Seeds:{" "}
|
||||
<span className="download-group__stat-value">
|
||||
{lastPacket.numSeeds}
|
||||
</span>
|
||||
, Peers:{" "}
|
||||
<span className="download-group__stat-value">
|
||||
{lastPacket.numPeers}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="download-group__speed-chart">
|
||||
<SpeedChart
|
||||
speeds={speedHistoryRef.current[game.id] || []}
|
||||
peakSpeed={peakSpeed}
|
||||
color={dominantColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="download-group">
|
||||
<div className="download-group__header">
|
||||
@@ -306,186 +652,83 @@ export function DownloadGroup({
|
||||
<h3 className="download-group__header-count">{library.length}</h3>
|
||||
</div>
|
||||
|
||||
<ul className="download-group__downloads">
|
||||
<ul className="download-group__simple-list">
|
||||
{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;
|
||||
const currentProgress = game.download?.progress || 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
className={cn("download-group__item", {
|
||||
"download-group__item--hydra":
|
||||
game.download?.downloader === Downloader.Hydra,
|
||||
})}
|
||||
>
|
||||
<div className="download-group__background-image">
|
||||
<img
|
||||
src={game.libraryHeroImageUrl || game.libraryImageUrl || ""}
|
||||
alt={game.title}
|
||||
/>
|
||||
<div className="download-group__background-overlay" />
|
||||
<li key={game.id} className="download-group__simple-card">
|
||||
<div className="download-group__simple-thumbnail">
|
||||
<img src={game.libraryImageUrl || ""} alt={game.title} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 className="download-group__simple-info">
|
||||
<h3 className="download-group__simple-title">{game.title}</h3>
|
||||
<div className="download-group__simple-meta">
|
||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
||||
<span className="download-group__simple-size">
|
||||
{finalDownloadSize}
|
||||
</span>
|
||||
{game.download?.progress === 1 && isGameSeeding(game) && (
|
||||
<span className="download-group__simple-seeding">
|
||||
{t("seeding")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{game.download?.downloader === Downloader.Hydra && (
|
||||
<div className="download-group__hydra-gradient" />
|
||||
{isQueuedGroup && (
|
||||
<div className="download-group__simple-progress">
|
||||
<span className="download-group__simple-progress-text">
|
||||
{formatDownloadProgress(currentProgress)}
|
||||
</span>
|
||||
<div className="download-group__progress-bar download-group__progress-bar--small">
|
||||
<div
|
||||
className="download-group__progress-fill"
|
||||
style={{
|
||||
width: `${currentProgress * 100}%`,
|
||||
background: (() => {
|
||||
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;
|
||||
}
|
||||
})(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="download-group__simple-actions">
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
<Button
|
||||
theme="outline"
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
.downloads {
|
||||
&__container {
|
||||
display: flex;
|
||||
padding: calc(globals.$spacing-unit * 3);
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user