Compare commits

..

17 Commits

Author SHA1 Message Date
Chubby Granny Chaser
e578047929 feat: add translation key for Hydra Wrapped 2025 in multiple languages and implement sidebar route with marquee effect
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-12-08 15:22:05 +00:00
Chubby Granny Chaser
e49d885b30 chore: update package.json to use yarn commands for type checking and building
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-30 15:21:50 +00:00
Chubby Granny Chaser
cb01301a0d feat: add new translation keys for network statistics in multiple languages 2025-11-30 15:07:32 +00:00
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
19 changed files with 872 additions and 389 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",
@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "yarn run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:unpack": "yarn run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",

View File

@@ -16,6 +16,7 @@
"library": "Library",
"downloads": "Downloads",
"settings": "Settings",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Available",
"my_library": "My library",
"downloading_metadata": "{{title}} (Downloading metadata…)",
"paused": "{{title}} (Paused)",
@@ -414,7 +415,11 @@
"resume_seeding": "Resume seeding",
"options": "Manage",
"extract": "Extract files",
"extracting": "Extracting files…"
"extracting": "Extracting files…",
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Downloads path",

View File

@@ -16,6 +16,7 @@
"library": "Librería",
"downloads": "Descargas",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Disponible",
"my_library": "Mi Librería",
"downloading_metadata": "{{title}} (Descargando metadatos…)",
"paused": "{{title}} (Pausado)",
@@ -414,7 +415,11 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…"
"extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Ruta de descarga",

View File

@@ -16,6 +16,7 @@
"library": "Biblioteca",
"downloads": "Downloads",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)",
@@ -402,7 +403,11 @@
"resume_seeding": "Semear",
"options": "Gerenciar",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…"
"extracting": "Extraindo arquivos…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@@ -15,6 +15,7 @@
"catalogue": "Catálogo",
"downloads": "Transferências",
"settings": "Definições",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (A transferir metadados…)",
"paused": "{{title}} (Em pausa)",
@@ -229,7 +230,13 @@
"seeding": "A semear",
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Opções"
"options": "Opções",
"extract": "Extrair ficheiros",
"extracting": "A extrair ficheiros…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Local das transferências",

View File

@@ -16,6 +16,7 @@
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Доступно",
"my_library": "Библиотека",
"downloading_metadata": "{{title}} (Загрузка метаданных…)",
"paused": "{{title}} (Приостановлено)",
@@ -414,7 +415,11 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Путь загрузок",

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

@@ -354,6 +354,8 @@ export class WindowManager {
public static async createNotificationWindow() {
if (this.notificationWindow) return;
if (process.platform === "darwin") return;
const userPreferences = await db.get<string, UserPreferences | undefined>(
levelKeys.userPreferences,
{

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

@@ -32,4 +32,15 @@ export const routes = [
nameKey: "settings",
render: () => <GearIcon />,
},
{
path: "https://hydrawrapped.com",
nameKey: "hydra_2025_wrapped",
render: () => (
<img
src="https://cdn.losbroxas.org/thumbnail_hydra_badge2_fb01af31e3.png"
alt="Hydra 2025 Wrapped"
style={{ width: 16, height: 16 }}
/>
),
},
];

View File

@@ -88,6 +88,34 @@
);
}
}
&--wrapped {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.25) 0%,
rgba(123, 104, 238, 0.2) 25%,
rgba(59, 130, 246, 0.25) 50%,
rgba(96, 165, 250, 0.2) 75%,
rgba(74, 144, 226, 0.25) 100%
);
background-size: 200% 200%;
animation: wrapped-gradient-flow 8s ease infinite;
color: globals.$muted-color;
position: relative;
overflow: hidden;
&:hover {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.35) 0%,
rgba(123, 104, 238, 0.3) 25%,
rgba(59, 130, 246, 0.35) 50%,
rgba(96, 165, 250, 0.3) 75%,
rgba(74, 144, 226, 0.35) 100%
);
background-size: 200% 200%;
}
}
}
&__menu-item-button {
@@ -106,6 +134,21 @@
overflow: hidden;
}
&__menu-item-marquee {
overflow: hidden;
flex: 1;
min-width: 0;
}
&__menu-item-marquee-content {
display: inline-flex;
}
&__menu-item-marquee-content span {
display: inline-block;
flex-shrink: 0;
}
&__game-icon {
width: 20px;
height: 20px;
@@ -228,3 +271,24 @@
}
}
}
@keyframes wrapped-gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-50% - 1em));
}
}

View File

@@ -22,6 +22,8 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { logger } from "@renderer/logger";
import { motion } from "framer-motion";
import {
CommentDiscussionIcon,
PlayIcon,
@@ -238,8 +240,32 @@ export function Sidebar() {
return game.title;
};
const handleSidebarItemClick = (path: string) => {
if (path !== location.pathname) {
const handleSidebarItemClick = async (path: string) => {
if (path.startsWith("http")) {
if (path === "https://hydrawrapped.com") {
try {
const auth = await window.electron.getAuth();
if (auth) {
const payload = {
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
expiresIn: 3600,
};
const base64Payload = btoa(JSON.stringify(payload));
window.electron.openExternal(
`${path}?payload=${encodeURIComponent(base64Payload)}`
);
} else {
window.electron.openExternal(path);
}
} catch (error) {
logger.error("Failed to get auth for wrapped:", error);
window.electron.openExternal(path);
}
} else {
window.electron.openExternal(path);
}
} else if (path !== location.pathname) {
navigate(path);
}
};
@@ -297,7 +323,10 @@ export function Sidebar() {
<li
key={nameKey}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active": location.pathname === path,
"sidebar__menu-item--active":
!path.startsWith("http") && location.pathname === path,
"sidebar__menu-item--wrapped":
nameKey === "hydra_2025_wrapped",
})}
>
<button
@@ -306,7 +335,33 @@ export function Sidebar() {
onClick={() => handleSidebarItemClick(path)}
>
{render()}
<span>{t(nameKey)}</span>
{nameKey === "hydra_2025_wrapped" ? (
<div className="sidebar__menu-item-marquee">
<motion.div
className="sidebar__menu-item-marquee-content"
animate={{
x: ["0%", "-50%"],
}}
transition={{
x: {
repeat: Infinity,
repeatType: "loop",
duration: 8,
ease: "linear",
},
}}
>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
</motion.div>
</div>
) : (
<span>{t(nameKey)}</span>
)}
</button>
</li>
))}

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,103 @@ 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>
)}
{(!lastPacket?.isCheckingFiles || currentProgress > 0) && (
<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>
@@ -289,7 +360,7 @@ function HeroDownloadView({
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">
{t("network")}:
{t("network")}
</span>
<span className="download-group__stat-value">
{isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"}
@@ -302,32 +373,41 @@ function HeroDownloadView({
<GraphIcon size={16} />
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">{t("peak")}:</span>
<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}
{game.download?.downloader && (
<div className="download-group__stat-item">
<div
className="download-group__stat-content"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<span className="download-group__stat-label">
{t("seeds")}{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, {t("peers")}{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
, Peers:{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
</div>
)}
</div>
)}
</div>
)}
</div>
<div className="download-group__speed-chart">
@@ -359,6 +439,7 @@ export function DownloadGroup({
seedingStatus,
}: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads");
const navigate = useNavigate();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
@@ -368,19 +449,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 +524,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 +585,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 +597,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 +613,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 +646,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 +687,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 +805,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 +827,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 +846,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,29 +861,67 @@ 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">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return (
<li key={game.id} className="download-group__simple-card">
<div className="download-group__simple-thumbnail">
<button
type="button"
className="download-group__simple-thumbnail"
onClick={() => navigate(buildGameDetailsPath(game))}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
}}
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</div>
</button>
<div className="download-group__simple-info">
<h3 className="download-group__simple-title">{game.title}</h3>
<button
type="button"
className="download-group__simple-title"
onClick={() => navigate(buildGameDetailsPath(game))}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
textAlign: "left",
width: "100%",
}}
>
{game.title}
</button>
<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 +950,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 +958,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);

View File

@@ -87,12 +87,16 @@ export function LibraryTab({
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<li
key={game.objectId}
style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</li>
@@ -134,6 +138,9 @@ export function LibraryTab({
<motion.li
key={`${sortBy}-${game.objectId}`}
style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
initial={
isNewGame
? { opacity: 0.5, y: 15, scale: 0.96 }
@@ -160,8 +167,6 @@ export function LibraryTab({
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</motion.li>

View File

@@ -25,16 +25,12 @@ import "./user-library-game-card.scss";
interface UserLibraryGameCardProps {
game: UserGame;
statIndex: number;
onMouseEnter: () => void;
onMouseLeave: () => void;
sortBy?: string;
}
export function UserLibraryGameCard({
game,
statIndex,
onMouseEnter,
onMouseLeave,
sortBy,
}: UserLibraryGameCardProps) {
const { userProfile, isMe, getUserLibraryGames } =
@@ -130,129 +126,119 @@ export function UserLibraryGameCard({
return (
<>
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
title={isTooltipHovered ? undefined : game.title}
>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
{isMe && (
<div className="user-library-game__actions-container">
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
<div className="user-library-game__overlay">
{isMe && (
<div className="user-library-game__actions-container">
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
</div>
{userProfile?.hasActiveSubscription &&
game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
{game.unlockedAchievementCount} / {game.achievementCount}
</span>
</div>
<progress
max={1}
value={
game.unlockedAchievementCount / game.achievementCount
}
className="user-library-game__achievements-progress"
/>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
)}
</div>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className="user-library-game__achievements-progress"
/>
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button>
</li>
</div>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button>
<Tooltip
id={game.objectId}
style={{