Compare commits

...

14 Commits

Author SHA1 Message Date
Chubby Granny Chaser
e872b2ea8a chore: bump version to 3.7.5
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-30 06:26:43 +00:00
Chubby Granny Chaser
dd7c84b433 Merge pull request #1881 from hydralauncher/fix/downloads-ui
fix: auto-resuming download isnt working after restart
2025-11-30 06:26:08 +00:00
Chubby Granny Chaser
1546da29cf Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra into fix/downloads-ui 2025-11-30 06:25:39 +00:00
Chubby Granny Chaser
a89b0bb2a8 style: refactor download group component to optimize download state management and improve UI responsiveness 2025-11-30 06:25:17 +00:00
Moyasee
9bdb216e0f fix: deleted comment 2025-11-30 08:23:49 +02:00
Moyasee
9779aed8c1 fix: auto-resuming download isnt working after restart 2025-11-30 08:05:45 +02:00
Chubby Granny Chaser
058a148c7f style: add button styling and refactor logo click handling in download group for improved accessibility and user experience 2025-11-30 05:44:18 +00:00
Chubby Granny Chaser
16e3d52508 style: enhance download group styling for improved layout, responsiveness, and user interaction 2025-11-30 05:39:01 +00:00
Chubby Granny Chaser
7e0002cf95 style: format imports in download-group.tsx for improved readability 2025-11-30 05:14:48 +00:00
Chubby Granny Chaser
bf8b3ca836 style: update download group layout and styling for improved responsiveness 2025-11-30 05:14:26 +00:00
Moyasee
77e376e742 fix: peak spead not working 2025-11-30 07:13:12 +02:00
Chubby Granny Chaser
bd28b202c4 Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra 2025-11-30 05:06:59 +00:00
Moyasee
153b954e78 fix: progress bar, context menu, repacks modal, responsiveness and styling fix 2025-11-30 07:05:19 +02:00
Chubby Granny Chaser
a9e63730be Merge pull request #1880 from hydralauncher/fix/fixing-hls-videos
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Fix/fixing hls videos
2025-11-30 03:45:10 +00:00
8 changed files with 540 additions and 235 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.4",
"version": "3.7.5",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",

View File

@@ -13,7 +13,11 @@ const resumeGameDownload = async (
const download = await downloadsSublevel.get(gameKey);
if (download?.status === "paused") {
if (
download &&
(download.status === "paused" || download.status === "active") &&
download.progress !== 1
) {
await DownloadManager.pauseDownload();
for await (const [key, value] of downloadsSublevel.iterator()) {

View File

@@ -1,5 +1,5 @@
import { downloadsSublevel } from "./level/sublevels/downloads";
import { sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { Downloader } from "@shared";
import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types";
@@ -68,7 +68,7 @@ export const loadState = async () => {
.values()
.all()
.then((games) => {
return sortBy(games, "timestamp", "DESC");
return orderBy(games, "timestamp", "desc");
});
downloads.forEach((download) => {

View File

@@ -20,7 +20,7 @@ import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
@@ -194,10 +194,10 @@ export class DownloadManager {
.values()
.all()
.then((games) => {
return sortBy(
return orderBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
"desc"
);
});

View File

@@ -18,6 +18,7 @@ interface DropdownMenuProps {
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
alignOffset?: number;
collisionPadding?: number;
}
export function DropdownMenu({
@@ -29,6 +30,7 @@ export function DropdownMenu({
loop = true,
align = "center",
alignOffset = 0,
collisionPadding = 16,
}: Readonly<DropdownMenuProps>) {
return (
<DropdownMenuPrimitive.Root>
@@ -43,6 +45,7 @@ export function DropdownMenu({
loop={loop}
align={align}
alignOffset={alignOffset}
collisionPadding={collisionPadding}
className="dropdown-menu__content"
>
{title && (

View File

@@ -18,17 +18,32 @@
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit);
&-divider {
&-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
background-color: globals.$border-color;
height: 1px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
}
}
&-count {
font-weight: 400;
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
}
&--hero {
@@ -37,7 +52,7 @@
overflow: hidden;
margin: 0;
padding: 0;
padding-bottom: calc(globals.$spacing-unit * 3);
padding-bottom: globals.$spacing-unit;
}
&__hero-background {
@@ -80,65 +95,166 @@
gap: calc(globals.$spacing-unit * 2);
}
&__hero-header {
display: flex;
justify-content: flex-end;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__hero-logo {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
&-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
transition: opacity 0.2s ease;
outline: none;
&:hover {
opacity: 0.8;
}
&:focus,
&:focus-visible {
outline: none;
}
}
img {
max-width: 600px;
max-height: 200px;
max-width: 180px;
max-height: 60px;
object-fit: contain;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
&:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 4px;
border-radius: 4px;
}
@container #{globals.$app-container} (min-width: 700px) {
max-width: 220px;
max-height: 75px;
}
@container #{globals.$app-container} (min-width: 900px) {
max-width: 280px;
max-height: 95px;
}
@container #{globals.$app-container} (min-width: 1200px) {
max-width: 340px;
max-height: 115px;
}
@container #{globals.$app-container} (min-width: 1500px) {
max-width: 400px;
max-height: 130px;
}
}
h1 {
font-size: 64px;
font-size: 20px;
font-weight: 700;
color: #ffffff;
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9);
margin: 0;
}
}
cursor: pointer;
transition: opacity 0.2s ease;
&__hero-actions {
display: flex;
gap: calc(globals.$spacing-unit);
align-items: center;
&:hover {
opacity: 0.8;
}
&:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 4px;
border-radius: 4px;
}
@container #{globals.$app-container} (min-width: 700px) {
font-size: 26px;
}
@container #{globals.$app-container} (min-width: 900px) {
font-size: 32px;
}
@container #{globals.$app-container} (min-width: 1200px) {
font-size: 38px;
}
@container #{globals.$app-container} (min-width: 1500px) {
font-size: 44px;
}
}
}
&__hero-action-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 3);
margin-bottom: calc(globals.$spacing-unit * 3);
margin-top: calc(globals.$spacing-unit * 4);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__hero-menu-btn {
background-color: rgba(0, 0, 0, 0.4);
padding: calc(globals.$spacing-unit * 1);
min-height: unset;
&__hero-buttons {
display: flex;
gap: calc(globals.$spacing-unit);
align-items: center;
flex-shrink: 0;
}
&__hero-menu-btn:hover {
background-color: rgba(0, 0, 0, 0.8);
&__glass-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
&__hero-progress {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 3);
}
&__progress-header {
&__progress-info-row {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit / 2);
}
&__progress-row {
display: flex;
align-items: flex-end;
gap: calc(globals.$spacing-unit * 2);
&--bar {
margin-top: calc(globals.$spacing-unit);
}
}
&__progress-status {
@@ -153,22 +269,36 @@
font-size: 14px;
font-weight: 700;
color: #ffffff;
}
align-self: flex-end;
display: inline-block;
overflow: hidden;
line-height: 1.2;
&__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);
span {
display: inline-block;
}
}
&__progress-size {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
&__progress-status {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
&__progress-time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 13px;
color: globals.$muted-color;
}
@@ -190,6 +320,7 @@
min-width: 200px;
padding-right: calc(globals.$spacing-unit * 2);
border-right: 1px solid rgba(255, 255, 255, 0.1);
align-self: flex-start;
}
&__speed-chart {
@@ -202,7 +333,7 @@
&__speed-chart-canvas {
width: 100%;
height: 100px;
height: 80px;
image-rendering: crisp-edges;
}
@@ -219,7 +350,9 @@
&__stat-content {
display: flex;
gap: 2px;
justify-content: space-between;
gap: calc(globals.$spacing-unit / 2);
width: 100%;
}
&__stat-label {
@@ -251,14 +384,7 @@
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
border-radius: 8px;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.1);
}
}
&__simple-thumbnail {
@@ -268,6 +394,7 @@
overflow: hidden;
flex-shrink: 0;
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid globals.$border-color;
img {
width: 100%;
@@ -281,7 +408,7 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
gap: calc(globals.$spacing-unit / 1);
}
&__simple-title {
@@ -295,6 +422,12 @@
}
&__simple-meta {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__simple-meta-row {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
@@ -303,9 +436,20 @@
}
&__simple-size {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-weight: 500;
}
&__simple-extracting {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-weight: 500;
color: globals.$muted-color;
}
&__simple-seeding {
color: #4ade80;
font-weight: 600;
@@ -342,13 +486,20 @@
min-height: unset;
}
&__progress-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__progress-bar {
width: 100%;
height: 8px;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
position: relative;
margin-top: calc(globals.$spacing-unit / 2);
&--small {
height: 6px;
@@ -357,10 +508,8 @@
&__progress-fill {
height: 100%;
background-color: globals.$muted-color;
transition:
width 0.3s ease,
background 0.35s ease;
background-color: #fff;
transition: width 0.3s ease;
border-radius: 4px;
}
}

View File

@@ -1,21 +1,32 @@
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components";
import { formatDownloadProgress } from "@renderer/helpers";
import {
formatDownloadProgress,
buildGameDetailsPath,
} from "@renderer/helpers";
import { Downloader, formatBytes, formatBytesToMbps } from "@shared";
import { formatDistance, addMilliseconds } from "date-fns";
import { addMilliseconds } from "date-fns";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import {
useAppSelector,
useDownload,
useLibrary,
useDate,
} from "@renderer/hooks";
import "./download-group.scss";
import { useTranslation } from "react-i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import {
DropdownMenu,
DropdownMenuItem,
} from "@renderer/components/dropdown-menu/dropdown-menu";
import {
ClockIcon,
ColumnsIcon,
DownloadIcon,
FileDirectoryIcon,
@@ -29,22 +40,46 @@ 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;
interface AnimatedPercentageProps {
value: number;
}
try {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `linear-gradient(90deg, rgba(${r},${g},${b},0.95) 0%, rgba(${r},${g},${b},0.65) 100%)`;
} catch {
return undefined;
}
};
function AnimatedPercentage({ value }: Readonly<AnimatedPercentageProps>) {
const percentageText = formatDownloadProgress(value);
const prevTextRef = useRef<string>(percentageText);
const chars = percentageText.split("");
const prevChars = prevTextRef.current.split("");
useEffect(() => {
prevTextRef.current = percentageText;
}, [percentageText]);
return (
<>
{chars.map((char, index) => {
const prevChar = prevChars[index];
const charChanged = prevChar !== char;
return (
<AnimatePresence key={`${index}`} mode="wait" initial={false}>
<motion.span
key={`${char}-${value}-${index}`}
initial={
charChanged ? { y: 10, opacity: 0 } : { y: 0, opacity: 1 }
}
animate={{ y: 0, opacity: 1 }}
exit={charChanged ? { y: -10, opacity: 0 } : undefined}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ display: "inline-block" }}
>
{char}
</motion.span>
</AnimatePresence>
);
})}
</>
);
}
interface SpeedChartProps {
speeds: number[];
@@ -67,6 +102,7 @@ function SpeedChart({
if (!ctx) return;
let animationFrameId: number;
let resizeObserver: ResizeObserver | null = null;
const draw = () => {
const clientWidth = canvas.clientWidth;
@@ -78,10 +114,12 @@ function SpeedChart({
const width = clientWidth;
const height = 100;
const totalBars = 120;
const barWidth = 4;
const barGap = 10;
const barSpacing = barWidth + barGap;
// Calculate how many bars can fit in the available width
const totalBars = Math.max(1, Math.floor((width + barGap) / barSpacing));
const maxHeight = peakSpeed || Math.max(...speeds, 1);
ctx.clearRect(0, 0, width, height);
@@ -138,8 +176,22 @@ function SpeedChart({
animationFrameId = requestAnimationFrame(draw);
// Handle resize - trigger redraw when canvas size changes
resizeObserver = new ResizeObserver(() => {
// Cancel any pending animation frame to force immediate redraw
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
// Trigger a redraw that will recalculate bars based on new width
draw();
});
resizeObserver.observe(canvas);
return () => {
cancelAnimationFrame(animationFrameId);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [speeds, peakSpeed, color]);
@@ -158,12 +210,11 @@ interface HeroDownloadViewProps {
dominantColor: string;
lastPacket: ReturnType<typeof useDownload>["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;
cancelDownload: (shop: GameShop, objectId: string) => void;
t: (key: string) => string;
}
@@ -177,14 +228,19 @@ function HeroDownloadView({
dominantColor,
lastPacket,
speedHistory,
getGameActions,
getStatusText,
formatSpeed,
calculateETA,
pauseDownload,
resumeDownload,
cancelDownload,
t,
}: Readonly<HeroDownloadViewProps>) {
const navigate = useNavigate();
const handleLogoClick = useCallback(() => {
navigate(buildGameDetailsPath(game));
}, [navigate, game]);
return (
<div className="download-group download-group--hero">
<div className="download-group__hero-background">
@@ -196,88 +252,101 @@ function HeroDownloadView({
</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} />
<button
type="button"
onClick={handleLogoClick}
className="download-group__hero-logo-button"
>
<img src={game.logoImageUrl} alt={game.title} />
</button>
) : (
<h1>{game.title}</h1>
<button
type="button"
onClick={handleLogoClick}
className="download-group__hero-logo-button"
>
<h1>{game.title}</h1>
</button>
)}
</div>
{isGameDownloading ? (
<Button
theme="primary"
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__hero-action-btn"
style={{
backgroundColor: dominantColor,
borderColor: dominantColor,
}}
>
<ColumnsIcon size={16} />
{t("pause")}
</Button>
) : (
<Button
theme="primary"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__hero-action-btn"
style={{
backgroundColor: dominantColor,
borderColor: dominantColor,
}}
>
<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: getProgressGradient(
dominantColor,
game.download?.status === "paused"
),
}}
/>
</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 className="download-group__progress-row download-group__progress-row--bar">
<div className="download-group__progress-wrapper">
<div className="download-group__progress-info-row">
{lastPacket?.isCheckingFiles ? (
<span className="download-group__progress-status">
{t("checking_files")}
</span>
) : (
<span className="download-group__progress-size">
<DownloadIcon size={14} />
{isGameDownloading && lastPacket
? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}`
: `0 B / ${finalDownloadSize}`}
</span>
)}
<span></span>
</div>
<div className="download-group__progress-info-row">
{!lastPacket?.isCheckingFiles && (
<span className="download-group__progress-time">
{isGameDownloading &&
lastPacket?.timeRemaining &&
lastPacket.timeRemaining > 0 && (
<>
<ClockIcon size={14} />
{calculateETA()}
</>
)}
</span>
)}
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
</div>
<div className="download-group__progress-bar">
<div
className="download-group__progress-fill"
style={{
width: `${currentProgress * 100}%`,
}}
/>
</div>
</div>
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<ColumnsIcon size={14} />
{t("pause")}
</button>
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
</button>
</div>
</div>
</div>
@@ -328,6 +397,14 @@ function HeroDownloadView({
</div>
</div>
)}
{game.download?.downloader && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
</div>
</div>
)}
</div>
<div className="download-group__speed-chart">
@@ -368,19 +445,64 @@ export function DownloadGroup({
const {
lastPacket,
pauseDownload,
resumeDownload,
pauseDownload: pauseDownloadOriginal,
resumeDownload: resumeDownloadOriginal,
cancelDownload,
isGameDeleting,
pauseSeeding,
resumeSeeding,
} = useDownload();
const peakSpeedsRef = useRef<Record<string, number>>({});
// Wrap resumeDownload with optimistic update
const resumeDownload = useCallback(
async (shop: GameShop, objectId: string) => {
const gameId = `${shop}:${objectId}`;
// Optimistically mark as downloading
setOptimisticallyResumed((prev) => ({ ...prev, [gameId]: true }));
try {
await resumeDownloadOriginal(shop, objectId);
} catch (error) {
// If resume fails, remove optimistic state
setOptimisticallyResumed((prev) => {
const next = { ...prev };
delete next[gameId];
return next;
});
throw error;
}
},
[resumeDownloadOriginal]
);
// Wrap pauseDownload to clear optimistic state
const pauseDownload = useCallback(
async (shop: GameShop, objectId: string) => {
const gameId = `${shop}:${objectId}`;
// Clear optimistic state when pausing
setOptimisticallyResumed((prev) => {
const next = { ...prev };
delete next[gameId];
return next;
});
await pauseDownloadOriginal(shop, objectId);
},
[pauseDownloadOriginal]
);
const { formatDistance } = useDate();
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
const speedHistoryRef = useRef<Record<string, number[]>>({});
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
{}
);
const [optimisticallyResumed, setOptimisticallyResumed] = useState<
Record<string, boolean>
>({});
const extractDominantColor = useCallback(
async (imageUrl: string, gameId: string) => {
@@ -398,13 +520,55 @@ export function DownloadGroup({
[dominantColors]
);
// Clear optimistic state when actual download starts or library updates
useEffect(() => {
if (lastPacket?.gameId) {
const gameId = lastPacket.gameId;
// Clear optimistic state when actual download starts
setOptimisticallyResumed((prev) => {
const next = { ...prev };
delete next[gameId];
return next;
});
}
}, [lastPacket?.gameId]);
// Clear optimistic state for games that are no longer active after library update
useEffect(() => {
setOptimisticallyResumed((prev) => {
const next = { ...prev };
let changed = false;
for (const gameId in next) {
if (next[gameId]) {
const game = library.find((g) => g.id === gameId);
// Clear if game doesn't exist or download status is not active
if (
!game ||
game.download?.status !== "active" ||
lastPacket?.gameId === gameId
) {
delete next[gameId];
changed = true;
}
}
}
return changed ? next : prev;
});
}, [library, lastPacket?.gameId]);
useEffect(() => {
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
const gameId = lastPacket.gameId;
const currentPeak = peakSpeedsRef.current[gameId] || 0;
const currentPeak = peakSpeeds[gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
peakSpeedsRef.current[gameId] = lastPacket.downloadSpeed;
setPeakSpeeds((prev) => ({
...prev,
[gameId]: lastPacket.downloadSpeed,
}));
}
if (!speedHistoryRef.current[gameId]) {
@@ -417,7 +581,7 @@ export function DownloadGroup({
speedHistoryRef.current[gameId].shift();
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed]);
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
useEffect(() => {
for (const game of library) {
@@ -429,7 +593,7 @@ export function DownloadGroup({
// Fresh download - clear any old data
if (speedHistoryRef.current[game.id]?.length > 0) {
speedHistoryRef.current[game.id] = [];
peakSpeedsRef.current[game.id] = 0;
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}
}
}
@@ -445,7 +609,7 @@ export function DownloadGroup({
) {
const timeout = setTimeout(() => {
speedHistoryRef.current[game.id] = [];
peakSpeedsRef.current[game.id] = 0;
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}, 10_000);
timeouts.push(timeout);
}
@@ -478,10 +642,12 @@ export function DownloadGroup({
const isGameDownloadingMap = useMemo(() => {
const map: Record<string, boolean> = {};
for (const game of library) {
map[game.id] = lastPacket?.gameId === game.id;
map[game.id] =
lastPacket?.gameId === game.id ||
optimisticallyResumed[game.id] === true;
}
return map;
}, [library, lastPacket?.gameId]);
}, [library, lastPacket?.gameId, optimisticallyResumed]);
const getFinalDownloadSize = (game: LibraryGame) => {
const download = game.download!;
@@ -517,53 +683,6 @@ export function DownloadGroup({
);
};
const getCompletedStatusText = (game: LibraryGame) => {
const isTorrent = game.download?.downloader === Downloader.Torrent;
if (isTorrent) {
return isGameSeeding(game)
? `${t("completed")} (${t("seeding")})`
: `${t("completed")} (${t("paused")})`;
}
return t("completed");
};
const getStatusText = (game: LibraryGame) => {
const isGameDownloading = isGameDownloadingMap[game.id];
const status = game.download?.status;
if (game.download?.extracting) {
return t("extracting");
}
if (isGameDeleting(game.id)) {
return t("deleting");
}
if (game.download?.progress === 1) {
return getCompletedStatusText(game);
}
if (isGameDownloading && lastPacket) {
if (lastPacket.isDownloadingMetadata) {
return t("downloading_metadata");
}
if (lastPacket.isCheckingFiles) {
return t("checking_files");
}
return t("download_in_progress");
}
switch (status) {
case "paused":
case "error":
return t("paused");
case "waiting":
return t("calculating_eta");
default:
return t("paused");
}
};
const extractGameDownload = useCallback(
async (shop: GameShop, objectId: string) => {
await window.electron.extractGameDownload(shop, objectId);
@@ -682,7 +801,13 @@ export function DownloadGroup({
progress: game.download?.progress || 0,
isSeeding: isGameSeeding(game),
})),
[library, lastPacket?.gameId]
[
library,
lastPacket?.gameId,
lastPacket?.download.fileSize,
isGameDownloadingMap,
seedingStatus,
]
);
if (!library.length) return null;
@@ -698,7 +823,7 @@ export function DownloadGroup({
? (lastPacket?.downloadSpeed ?? 0)
: 0;
const finalDownloadSize = getFinalDownloadSize(game);
const peakSpeed = peakSpeedsRef.current[game.id] || 0;
const peakSpeed = peakSpeeds[game.id] || 0;
const currentProgress =
isGameDownloading && lastPacket
? lastPacket.progress
@@ -717,12 +842,11 @@ export function DownloadGroup({
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={speedHistoryRef.current[game.id] || []}
getGameActions={getGameActions}
getStatusText={getStatusText}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
resumeDownload={resumeDownload}
cancelDownload={cancelDownload}
t={t}
/>
);
@@ -733,9 +857,10 @@ export function DownloadGroup({
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
>
<div className="download-group__header">
<h2>{title}</h2>
<div className="download-group__header-divider" />
<h3 className="download-group__header-count">{library.length}</h3>
<div className="download-group__header-title-group">
<h2>{title}</h2>
<h3 className="download-group__header-count">{library.length}</h3>
</div>
</div>
<ul className="download-group__simple-list">
@@ -749,13 +874,26 @@ export function DownloadGroup({
<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">{size}</span>
{game.download?.progress === 1 && seeding && (
<span className="download-group__simple-seeding">
{t("seeding")}
</span>
)}
<div className="download-group__simple-meta-row">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
</div>
<div className="download-group__simple-meta-row">
{game.download?.extracting ? (
<span className="download-group__simple-extracting">
{t("extracting")}
</span>
) : (
<span className="download-group__simple-size">
<DownloadIcon size={14} />
{size}
</span>
)}
{game.download?.progress === 1 && seeding && (
<span className="download-group__simple-seeding">
{t("seeding")}
</span>
)}
</div>
</div>
</div>
@@ -784,7 +922,7 @@ export function DownloadGroup({
disabled={isGameDeleting(game.id)}
className="download-group__simple-menu-btn"
>
<DownloadIcon size={16} />
<PlayIcon size={16} />
</Button>
)}
{isQueuedGroup && game.download?.progress !== 1 && (
@@ -792,8 +930,9 @@ export function DownloadGroup({
theme="primary"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__simple-menu-btn"
tooltip={t("resume")}
>
<PlayIcon size={16} />
<DownloadIcon size={16} />
</Button>
)}
<DropdownMenu align="end" items={getGameActions(game)}>

View File

@@ -231,9 +231,19 @@ export function RepacksModal({
return false;
}
const lastCheckUtc = new Date(lastCheckTimestamp).toISOString();
try {
const lastCheckDate = new Date(lastCheckTimestamp);
return repack.createdAt > lastCheckUtc;
if (isNaN(lastCheckDate.getTime())) {
return false;
}
const lastCheckUtc = lastCheckDate.toISOString();
return repack.createdAt > lastCheckUtc;
} catch {
return false;
}
};
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);