diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index ee6f67ca..0d513bae 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -18,19 +18,26 @@ &__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 { background-color: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.7); - padding: 6px 10px; + padding: 4px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; @@ -91,11 +98,25 @@ &__hero-logo { flex: 1; min-width: 0; + display: flex; + align-items: center; img { 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; @@ -124,6 +145,18 @@ color: #ffffff; text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9); margin: 0; + 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) { font-size: 26px; @@ -217,15 +250,34 @@ font-weight: 700; color: #ffffff; align-self: flex-end; + display: inline-block; + overflow: hidden; + line-height: 1.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; } @@ -312,14 +364,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 { @@ -329,6 +374,7 @@ overflow: hidden; flex-shrink: 0; background-color: rgba(0, 0, 0, 0.3); + border: 1px solid globals.$border-color; img { width: 100%; @@ -356,6 +402,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); @@ -364,9 +416,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; diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 234db7c6..4b783517 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,7 +1,10 @@ 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 { addMilliseconds } from "date-fns"; @@ -16,11 +19,14 @@ import { 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, @@ -34,6 +40,47 @@ import { } from "@primer/octicons-react"; import { average } from "color.js"; +interface AnimatedPercentageProps { + value: number; +} + +function AnimatedPercentage({ value }: Readonly) { + const percentageText = formatDownloadProgress(value); + const prevTextRef = useRef(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 ( + + + {char} + + + ); + })} + + ); +} + interface SpeedChartProps { speeds: number[]; peakSpeed: number; @@ -55,6 +102,7 @@ function SpeedChart({ if (!ctx) return; let animationFrameId: number; + let resizeObserver: ResizeObserver | null = null; const draw = () => { const clientWidth = canvas.clientWidth; @@ -66,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); @@ -126,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]); @@ -173,6 +237,12 @@ function HeroDownloadView({ cancelDownload, t, }: Readonly) { + const navigate = useNavigate(); + + const handleLogoClick = useCallback(() => { + navigate(buildGameDetailsPath(game)); + }, [navigate, game]); + return (
@@ -187,9 +257,33 @@ function HeroDownloadView({
{game.logoImageUrl ? ( - {game.title} + {game.title} { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleLogoClick(); + } + }} + role="button" + tabIndex={0} + /> ) : ( -

{game.title}

+

{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleLogoClick(); + } + }} + role="button" + tabIndex={0} + > + {game.title} +

)}
@@ -198,23 +292,35 @@ function HeroDownloadView({
- - {isGameDownloading && lastPacket - ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` - : `0 B / ${finalDownloadSize}`} - + {lastPacket?.isCheckingFiles ? ( + + {t("checking_files")} + + ) : ( + + + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `0 B / ${finalDownloadSize}`} + + )}
- - {isGameDownloading && - lastPacket?.timeRemaining && - lastPacket.timeRemaining > 0 - ? calculateETA() - : ""} - + {!lastPacket?.isCheckingFiles && ( + + {isGameDownloading && + lastPacket?.timeRemaining && + lastPacket.timeRemaining > 0 && ( + <> + + {calculateETA()} + + )} + + )} - {formatDownloadProgress(currentProgress)} +
@@ -355,7 +461,7 @@ export function DownloadGroup({ const { formatDistance } = useDate(); - const peakSpeedsRef = useRef>({}); + const [peakSpeeds, setPeakSpeeds] = useState>({}); const speedHistoryRef = useRef>({}); const [dominantColors, setDominantColors] = useState>( {} @@ -381,9 +487,12 @@ export function DownloadGroup({ 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]) { @@ -396,7 +505,7 @@ export function DownloadGroup({ speedHistoryRef.current[gameId].shift(); } } - }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); + }, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]); useEffect(() => { for (const game of library) { @@ -408,7 +517,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 })); } } } @@ -424,7 +533,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); } @@ -677,7 +786,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 @@ -712,9 +821,10 @@ export function DownloadGroup({ className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`} >
-

{title}

-
-

{library.length}

+
+

{title}

+

{library.length}

+
    @@ -728,13 +838,26 @@ export function DownloadGroup({

    {game.title}

    - {DOWNLOADER_NAME[game.download!.downloader]} - {size} - {game.download?.progress === 1 && seeding && ( - - {t("seeding")} - - )} +
    + {DOWNLOADER_NAME[game.download!.downloader]} +
    +
    + {game.download?.extracting ? ( + + {t("extracting")} + + ) : ( + + + {size} + + )} + {game.download?.progress === 1 && seeding && ( + + {t("seeding")} + + )} +
    @@ -771,8 +894,9 @@ export function DownloadGroup({ theme="primary" onClick={() => resumeDownload(game.shop, game.objectId)} className="download-group__simple-menu-btn" + tooltip={t("resume")} > - + )}