Compare commits

..

29 Commits

Author SHA1 Message Date
Chubby Granny Chaser
316480930d Merge branch 'main' into fix/fixing-hls-videos 2025-11-30 03:45:00 +00:00
Chubby Granny Chaser
0b5c9acaaa Merge pull request #1861 from iam-sahil/Downloads-UI
feat: enhance download page UI with improved layout and styling for the download cards.
2025-11-30 03:44:33 +00:00
Chubby Granny Chaser
814a2da05c Merge branch 'main' into Downloads-UI 2025-11-30 03:44:10 +00:00
Chubby Granny Chaser
0ad1ebd6a2 fix: fixing hls videos 2025-11-30 03:43:22 +00:00
Chubby Granny Chaser
e9de8264e2 fix: fixing hls videos 2025-11-30 03:41:41 +00:00
Chubby Granny Chaser
b135087ffe fix: fixing hls videos 2025-11-30 03:38:23 +00:00
Chubby Granny Chaser
b4a1af78a6 Merge pull request #1877 from egionCode/main
adding sorting for recently played based on last time the game was op…
2025-11-30 03:21:08 +00:00
Chubby Granny Chaser
ede5bb0c23 Merge branch 'main' into main 2025-11-30 03:20:03 +00:00
Chubby Granny Chaser
9a27875cd8 Merge pull request #1866 from hydralauncher/feat/search-autosuggest
Feat: search history and auto-suggest
2025-11-30 03:19:57 +00:00
Chubby Granny Chaser
cf20a942ae Merge branch 'main' into main 2025-11-30 03:17:07 +00:00
Chubby Granny Chaser
256d829a60 feat: adding translations 2025-11-30 03:15:27 +00:00
Chubby Granny Chaser
8cb18578e0 Merge branch 'main' into feat/search-autosuggest 2025-11-30 03:06:00 +00:00
Chubby Granny Chaser
62950297e0 Merge pull request #1874 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a
chore(deps): bump tar from 7.5.1 to 7.5.2 in the npm_and_yarn group across 1 directory
2025-11-30 03:05:46 +00:00
Chubby Granny Chaser
3eecc42430 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a 2025-11-30 03:04:37 +00:00
Chubby Granny Chaser
f6edb45628 Merge pull request #1875 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd
chore(deps): bump js-yaml from 4.1.0 to 4.1.1 in the npm_and_yarn group across 1 directory
2025-11-30 03:04:30 +00:00
Chubby Granny Chaser
de8797bea6 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd 2025-11-30 03:04:23 +00:00
Chubby Granny Chaser
828f82f647 Merge pull request #1879 from hydralauncher/feat/adding-level-generic-interface
Feat/adding level generic interface
2025-11-30 03:04:12 +00:00
Moyasee
bb22d9c4dd ci: migration of search history from localStorage to LevelDB and highlighting fix 2025-11-29 05:30:10 +02:00
Moyasee
559bb45acc Merge branch 'feat/adding-level-generic-interface' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-29 05:10:54 +02:00
Victor
e176e624be adding sorting for recently played based on last time the game was opened 2025-11-27 11:23:50 -03:00
dependabot[bot]
4e92e794be chore(deps): bump js-yaml in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:31 +00:00
dependabot[bot]
de0dbcac35 chore(deps): bump tar in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.1 to 7.5.2
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.1...v7.5.2)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:16 +00:00
Moyasee
07d5a5b3f3 Merge branch 'feat/search-autosuggest' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-22 07:31:15 +02:00
Moyasee
a1117c8269 feat: improving suggestion dropdown design 2025-11-22 07:26:48 +02:00
ctrlcat0x
5bffaf17fa fix: adjust padding for completed downloads and improve conditional rendering in download actions 2025-11-15 13:47:50 +05:30
ctrlcat0x
cc38be4383 Fixed linter and sonarcloud errors, refactored some functions and fixed UI padding issues with certain themes. 2025-11-15 11:31:39 +05:30
ctrlcat0x
0b70a28c08 feat: enhance download group UI with speed chart improvements and gradient progress bar 2025-11-15 01:16:23 +05:30
ctrlcat0x
3ff50a9932 feat: update download group UI with hero section and speed chart integration 2025-11-15 00:44:54 +05:30
ctrlcat0x
83fbf20383 feat: enhance download page UI with improved layout and styling for cards 2025-11-14 20:02:10 +05:30
24 changed files with 1261 additions and 379 deletions

View File

@@ -28,6 +28,19 @@
- Use async/await instead of promises when possible
- Prefer named exports over default exports for utilities and services
## ESLint Issues
- **Always try to fix ESLint errors properly before disabling rules**
- When encountering ESLint errors, explore these solutions in order:
1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues)
2. **Use minimal markup to satisfy the rule** (e.g., add empty `<track>` elements for videos without captions, add `role` attributes)
3. **Only disable the rule as a last resort** when no reasonable solution exists
- When disabling a rule, always include a comment explaining why it's necessary
- Examples of proper fixes:
- For `jsx-a11y/media-has-caption`: Add `<track kind="captions" />` even if no captions are available
- For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images
- For accessibility rules: Add appropriate ARIA attributes rather than disabling
## TypeScript Array Syntax
- **Always use `T[]` syntax instead of `Array<T>`** for array types

View File

@@ -63,6 +63,7 @@
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0",
"framer-motion": "^12.15.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0",
@@ -84,7 +85,7 @@
"sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1",
"tar": "^7.4.3",
"tar": "^7.5.2",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"uuid": "^13.0.0",

View File

@@ -96,7 +96,7 @@
"search_library": "Search library",
"recent_searches": "Recent Searches",
"suggestions": "Suggestions",
"clear_history": "Clear history",
"clear_history": "Clear",
"remove_from_history": "Remove from history",
"loading": "Loading...",
"no_results": "No results",

View File

@@ -93,8 +93,16 @@
},
"header": {
"search": "Buscar juegos",
"search_library": "Buscar en la librería",
"recent_searches": "Búsquedas Recientes",
"suggestions": "Sugerencias",
"clear_history": "Limpiar",
"remove_from_history": "Eliminar del historial",
"loading": "Cargando...",
"no_results": "Sin resultados",
"home": "Inicio",
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",

View File

@@ -93,11 +93,19 @@
},
"header": {
"search": "Buscar jogos",
"search_library": "Buscar na biblioteca",
"recent_searches": "Buscas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "Carregando...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads",
"search_results": "Resultados da busca",
"settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
},

View File

@@ -30,11 +30,19 @@
},
"header": {
"search": "Procurar jogos",
"search_library": "Procurar na biblioteca",
"recent_searches": "Pesquisas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "A carregar...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Transferências",
"search_results": "Resultados da pesquisa",
"settings": "Definições",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
},

View File

@@ -93,8 +93,16 @@
},
"header": {
"search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная",
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"search_results": "Результаты поиска",
"settings": "Настройки",

View File

@@ -6,7 +6,7 @@
<title>Hydra Launcher</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' * data: local:;"
content="default-src 'self' 'unsafe-inline' * data: local:; media-src 'self' 'unsafe-inline' * data: local: blob:;"
/>
</head>
<body>

View File

@@ -15,6 +15,8 @@ import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames";
import { SearchDropdown } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
const pathTitle: Record<string, string> = {
"/": "home",
@@ -161,11 +163,11 @@ export function Header() {
const handleSelectSuggestion = (suggestion: {
title: string;
objectId: string;
shop: string;
shop: GameShop;
}) => {
setIsDropdownVisible(false);
inputRef.current?.blur();
navigate(`/game/${suggestion.shop}/${suggestion.objectId}`);
navigate(buildGameDetailsPath(suggestion));
};
const handleClearSearch = () => {

View File

@@ -19,24 +19,25 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
return <>{text}</>;
}
const textWords = text.split(/\b/);
const matches: { start: number; end: number; text: string }[] = [];
const matches: { start: number; end: number }[] = [];
const textLower = text.toLowerCase();
let currentIndex = 0;
textWords.forEach((word) => {
const wordLower = word.toLowerCase();
queryWords.forEach((queryWord) => {
const escapedQuery = queryWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(
`(?:^|[\\s])${escapedQuery}(?=[\\s]|$)|^${escapedQuery}$`,
"gi"
);
queryWords.forEach((queryWord) => {
if (wordLower === queryWord) {
matches.push({
start: currentIndex,
end: currentIndex + word.length,
text: word,
});
}
});
let match;
while ((match = regex.exec(textLower)) !== null) {
const matchedText = match[0];
const leadingSpace = matchedText.startsWith(" ") ? 1 : 0;
const start = match.index + leadingSpace;
const end = start + queryWord.length;
currentIndex += word.length;
matches.push({ start, end });
}
});
if (matches.length === 0) {
@@ -46,16 +47,14 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
matches.sort((a, b) => a.start - b.start);
const mergedMatches: { start: number; end: number }[] = [];
if (matches.length === 0) {
return <>{text}</>;
}
let current = matches[0];
for (let i = 1; i < matches.length; i++) {
if (matches[i].start <= current.end) {
current.end = Math.max(current.end, matches[i].end);
current = {
start: current.start,
end: Math.max(current.end, matches[i].end),
};
} else {
mergedMatches.push(current);
current = matches[i];

View File

@@ -24,7 +24,8 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 4px;
padding: 8px 12px 8px;
margin-bottom: 4px;
}
&__section-title {
@@ -35,19 +36,19 @@
letter-spacing: 0.5px;
}
&__clear-button {
&__clear-text-button {
color: globals.$muted-color;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all ease 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
transition: color ease 0.2s;
background: transparent;
border: none;
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.15);
}
}
@@ -74,9 +75,8 @@
transform: translateY(-50%);
color: globals.$muted-color;
padding: 4px;
border-radius: 4px;
opacity: 0;
transition: all ease 0.15s;
transition: opacity ease 0.15s;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -1,11 +1,6 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import {
ClockIcon,
SearchIcon,
TrashIcon,
XIcon,
} from "@primer/octicons-react";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
import { useTranslation } from "react-i18next";
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
@@ -144,11 +139,10 @@ export function SearchDropdown({
</span>
<button
type="button"
className="search-dropdown__clear-button"
className="search-dropdown__clear-text-button"
onClick={onClearHistory}
title={t("clear_history")}
>
<TrashIcon size={14} />
{t("clear_history")}
</button>
</div>
<ul className="search-dropdown__list">

View File

@@ -10,3 +10,4 @@ export * from "./use-download-options-listener";
export * from "./use-game-card";
export * from "./use-search-history";
export * from "./use-search-suggestions";
export * from "./use-hls-video";

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef } from "react";
import Hls from "hls.js";
import { logger } from "@renderer/logger";
interface UseHlsVideoOptions {
videoSrc: string | undefined;
videoType: string | undefined;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
}
export function useHlsVideo(
videoRef: React.RefObject<HTMLVideoElement>,
{ videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions
) {
const hlsRef = useRef<Hls | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video || !videoSrc) return;
const isHls = videoType === "application/x-mpegURL";
if (!isHls) {
return undefined;
}
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
});
hlsRef.current = hls;
hls.loadSource(videoSrc);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoplay) {
video.play().catch((err) => {
logger.warn("Failed to autoplay HLS video:", err);
});
}
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
logger.error("HLS network error, trying to recover");
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
logger.error("HLS media error, trying to recover");
hls.recoverMediaError();
break;
default:
logger.error("HLS fatal error, destroying instance");
hls.destroy();
break;
}
}
});
return () => {
hls.destroy();
hlsRef.current = null;
};
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = videoSrc;
video.load();
if (autoplay) {
video.play().catch((err) => {
logger.warn("Failed to autoplay HLS video:", err);
});
}
return () => {
video.src = "";
};
} else {
logger.warn("HLS playback is not supported in this browser");
return undefined;
}
}, [videoRef, videoSrc, videoType, autoplay, muted, loop]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (muted !== undefined) {
video.muted = muted;
}
if (loop !== undefined) {
video.loop = loop;
}
}, [videoRef, muted, loop]);
return hlsRef.current;
}

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
export interface SearchHistoryEntry {
query: string;
@@ -6,22 +7,32 @@ export interface SearchHistoryEntry {
context: "library" | "catalogue";
}
const STORAGE_KEY = "search-history";
const LEVELDB_KEY = "searchHistory";
const MAX_HISTORY_ENTRIES = 15;
export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
const isInitialized = useRef(false);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const loadHistory = async () => {
if (isInitialized.current) return;
isInitialized.current = true;
try {
const parsed = JSON.parse(stored) as SearchHistoryEntry[];
setHistory(parsed);
const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
| SearchHistoryEntry[]
| null;
if (data) {
setHistory(data);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
setHistory([]);
}
}
};
loadHistory();
}, []);
const addToHistory = useCallback(
@@ -39,7 +50,7 @@ export function useSearchHistory() {
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
);
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated;
});
},
@@ -49,14 +60,14 @@ export function useSearchHistory() {
const removeFromHistory = useCallback((query: string) => {
setHistory((prev) => {
const updated = prev.filter((entry) => entry.query !== query);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated;
});
}, []);
const clearHistory = useCallback(() => {
setHistory([]);
localStorage.removeItem(STORAGE_KEY);
levelDBService.del(LEVELDB_KEY, null);
}, []);
const getRecentHistory = useCallback(

View File

@@ -2,11 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useAppSelector } from "./redux";
import { debounce } from "lodash-es";
import { logger } from "@renderer/logger";
import type { GameShop } from "@types";
export interface SearchSuggestion {
title: string;
objectId: string;
shop: string;
shop: GameShop;
iconUrl: string | null;
source: "library" | "catalogue";
}
@@ -20,6 +21,7 @@ export function useSearchSuggestions(
const [isLoading, setIsLoading] = useState(false);
const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(null);
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
const getLibrarySuggestions = useCallback(
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
@@ -68,6 +70,15 @@ export function useSearchSuggestions(
return;
}
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
const cachedResults = cacheRef.current.get(cacheKey);
if (cachedResults) {
setSuggestions(cachedResults);
setIsLoading(false);
return;
}
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
@@ -79,7 +90,7 @@ export function useSearchSuggestions(
{
title: string;
objectId: string;
shop: string;
shop: GameShop;
iconUrl: string | null;
}[]
>("/catalogue/search/suggestions", {
@@ -99,6 +110,7 @@ export function useSearchSuggestions(
})
);
cacheRef.current.set(cacheKey, catalogueSuggestions);
setSuggestions(catalogueSuggestions);
} catch (error) {
if (!abortController.signal.aborted) {

View File

@@ -4,13 +4,15 @@
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin-inline: calc(globals.$spacing-unit * 3);
padding-block: calc(globals.$spacing-unit * 3);
&__details-with-article {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
align-self: flex-start;
cursor: pointer;
&--queued {
padding-bottom: 0;
}
&--completed {
padding-top: calc(globals.$spacing-unit * 3);
}
&__header {
@@ -29,133 +31,336 @@
font-weight: 400;
}
}
&__title-wrapper {
display: flex;
align-items: center;
margin-bottom: globals.$spacing-unit;
gap: globals.$spacing-unit;
}
&__title {
font-weight: bold;
cursor: pointer;
color: globals.$body-color;
text-align: left;
font-size: 16px;
display: block;
&:hover {
text-decoration: underline;
}
}
&__downloads {
&--hero {
width: 100%;
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
margin: 0;
padding: 0;
margin-top: globals.$spacing-unit;
padding-bottom: calc(globals.$spacing-unit * 3);
}
&__item {
&__hero-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: globals.$background-color;
display: flex;
border-radius: 8px;
border: solid 1px globals.$border-color;
overflow: hidden;
box-shadow: 0px 0px 5px 0px #000000;
transition: all ease 0.2s;
height: 140px;
min-height: 140px;
max-height: 140px;
position: relative;
height: 120%;
z-index: 0;
&--hydra {
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15);
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 20%;
}
}
&__cover {
width: 280px;
min-width: 280px;
height: auto;
border-right: solid 1px globals.$border-color;
// PLEASE FIX THE COLORS
&__hero-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.3) 0%,
rgb(5, 5, 5) 70%,
rgb(26, 26, 26) 100%
);
}
&__hero-content {
position: relative;
z-index: 1;
&-content {
width: 100%;
height: 100%;
padding: globals.$spacing-unit;
display: flex;
align-items: flex-end;
justify-content: flex-end;
}
&-backdrop {
width: 100%;
height: 100%;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.8) 5%,
transparent 100%
);
display: flex;
overflow: hidden;
z-index: 1;
}
&-image {
width: 100%;
height: 100%;
position: absolute;
z-index: -1;
}
}
&__right-content {
display: flex;
padding: calc(globals.$spacing-unit * 2);
flex: 1;
gap: globals.$spacing-unit;
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
}
&__details {
padding: calc(globals.$spacing-unit * 4);
padding-bottom: 0;
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 14px;
gap: calc(globals.$spacing-unit * 2);
}
&__actions {
&__hero-header {
display: flex;
justify-content: flex-end;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__hero-logo {
flex: 1;
img {
max-width: 600px;
max-height: 200px;
object-fit: contain;
}
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 * 1);
min-height: unset;
}
&__hero-menu-btn:hover {
background-color: rgba(0, 0, 0, 0.8);
}
&__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;
border: 1px solid rgba(255, 255, 255, 0.1);
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;
gap: globals.$spacing-unit;
justify-content: center;
overflow: hidden;
}
&__menu-button {
position: absolute;
top: 12px;
right: 12px;
border-radius: 50%;
border: none;
padding: 8px;
&__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 {
opacity: 0.8;
flex-shrink: 0;
}
}
&__stat-content {
display: flex;
gap: 2px;
}
&__stat-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
}
&__stat-value {
color: #ffffff;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
}
&__simple-list {
width: 100%;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin: 0;
padding: 0;
list-style: none;
}
&__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);
}
}
&__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;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__simple-title {
font-size: 16px;
font-weight: 600;
color: #ffffff;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__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;
display: flex;
justify-content: center;
align-items: center;
gap: calc(globals.$spacing-unit);
}
&__simple-menu-btn {
padding: calc(globals.$spacing-unit);
min-height: unset;
}
&__hydra-gradient {
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%);
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15);
&__progress-bar {
width: 100%;
position: absolute;
bottom: 0;
height: 2px;
z-index: 1;
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,
background 0.35s ease;
border-radius: 4px;
}
}

View File

@@ -1,21 +1,16 @@
import { useNavigate } from "react-router-dom";
import cn from "classnames";
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { formatDownloadProgress } from "@renderer/helpers";
import { Downloader, formatBytes } from "@shared";
import { Downloader, formatBytes, formatBytesToMbps } from "@shared";
import { formatDistance, addMilliseconds } from "date-fns";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import "./download-group.scss";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
DropdownMenu,
DropdownMenuItem,
@@ -26,12 +21,327 @@ import {
FileDirectoryIcon,
LinkIcon,
PlayIcon,
QuestionIcon,
ThreeBarsIcon,
TrashIcon,
UnlinkIcon,
XCircleIcon,
GraphIcon,
} 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;
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;
}
};
interface SpeedChartProps {
speeds: number[];
peakSpeed: number;
color?: string;
}
function SpeedChart({
speeds,
peakSpeed,
color = "rgba(255, 255, 255, 1)",
}: Readonly<SpeedChartProps>) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationFrameId: number;
const draw = () => {
const clientWidth = canvas.clientWidth;
const dpr = window.devicePixelRatio || 1;
canvas.width = clientWidth * dpr;
canvas.height = 100 * dpr;
ctx.scale(dpr, dpr);
const width = clientWidth;
const height = 100;
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);
let r = 255,
g = 255,
b = 255;
if (color.startsWith("#")) {
const hex = color.replace("#", "");
r = Number.parseInt(hex.substring(0, 2), 16);
g = Number.parseInt(hex.substring(2, 4), 16);
b = Number.parseInt(hex.substring(4, 6), 16);
} else if (color.startsWith("rgb")) {
const matches = color.match(/\d+/g);
if (matches && matches.length >= 3) {
r = Number.parseInt(matches[0]);
g = Number.parseInt(matches[1]);
b = Number.parseInt(matches[2]);
}
}
const displaySpeeds = speeds.slice(-totalBars);
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 < displaySpeeds.length) {
const speed = displaySpeeds[i] || 0;
const filledHeight = (speed / maxHeight) * height;
if (filledHeight > 0) {
const gradient = ctx.createLinearGradient(
0,
height - filledHeight,
0,
height
);
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();
}
}
}
animationFrameId = requestAnimationFrame(draw);
};
animationFrameId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [speeds, peakSpeed, color]);
return (
<canvas ref={canvasRef} className="download-group__speed-chart-canvas" />
);
}
interface HeroDownloadViewProps {
game: LibraryGame;
isGameDownloading: boolean;
downloadSpeed: number;
finalDownloadSize: string;
peakSpeed: number;
currentProgress: number;
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;
t: (key: string) => string;
}
function HeroDownloadView({
game,
isGameDownloading,
downloadSpeed,
finalDownloadSize,
peakSpeed,
currentProgress,
dominantColor,
lastPacket,
speedHistory,
getGameActions,
getStatusText,
formatSpeed,
calculateETA,
pauseDownload,
resumeDownload,
t,
}: Readonly<HeroDownloadViewProps>) {
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,
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>
</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={speedHistory}
peakSpeed={peakSpeed}
color={dominantColor}
/>
</div>
</div>
</div>
</div>
);
}
export interface DownloadGroupProps {
library: LibraryGame[];
@@ -48,8 +358,6 @@ export function DownloadGroup({
openGameInstaller,
seedingStatus,
}: Readonly<DownloadGroupProps>) {
const navigate = useNavigate();
const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
@@ -60,7 +368,6 @@ export function DownloadGroup({
const {
lastPacket,
progress,
pauseDownload,
resumeDownload,
cancelDownload,
@@ -69,11 +376,118 @@ export function DownloadGroup({
resumeSeeding,
} = 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 !== undefined) {
const gameId = lastPacket.gameId;
const currentPeak = peakSpeedsRef.current[gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
peakSpeedsRef.current[gameId] = lastPacket.downloadSpeed;
}
if (!speedHistoryRef.current[gameId]) {
speedHistoryRef.current[gameId] = [];
}
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
if (speedHistoryRef.current[gameId].length > 120) {
speedHistoryRef.current[gameId].shift();
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed]);
useEffect(() => {
for (const game of library) {
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 timeouts: NodeJS.Timeout[] = [];
for (const game of library) {
if (
game.download?.progress === 1 &&
speedHistoryRef.current[game.id]?.length > 0
) {
const timeout = setTimeout(() => {
speedHistoryRef.current[game.id] = [];
peakSpeedsRef.current[game.id] = 0;
}, 10_000);
timeouts.push(timeout);
}
}
return () => {
for (const timeout of timeouts) {
clearTimeout(timeout);
}
};
}, [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?.status) return entry.status === "seeding";
return game.download?.status === "seeding";
};
const isGameDownloadingMap = useMemo(() => {
const map: Record<string, boolean> = {};
for (const game of library) {
map[game.id] = lastPacket?.gameId === game.id;
}
return map;
}, [library, lastPacket?.gameId]);
const getFinalDownloadSize = (game: LibraryGame) => {
const download = game.download!;
const isGameDownloading = lastPacket?.gameId === game.id;
const isGameDownloading = isGameDownloadingMap[game.id];
if (download.fileSize) return formatBytes(download.fileSize);
if (download.fileSize != null) return formatBytes(download.fileSize);
if (lastPacket?.download.fileSize && isGameDownloading)
return formatBytes(lastPacket.download.fileSize);
@@ -81,15 +495,74 @@ export function DownloadGroup({
return "N/A";
};
const seedingMap = useMemo(() => {
const map = new Map<string, SeedingStatus>();
const formatSpeed = (speed: number): string => {
return userPreferences?.showDownloadSpeedInMegabytes
? `${formatBytes(speed)}/s`
: formatBytesToMbps(speed);
};
seedingStatus.forEach((seed) => {
map.set(seed.gameId, seed);
});
const calculateETA = () => {
if (
!lastPacket ||
lastPacket.timeRemaining < 0 ||
!Number.isFinite(lastPacket.timeRemaining)
) {
return "";
}
return map;
}, [seedingStatus]);
return formatDistance(
addMilliseconds(new Date(), lastPacket.timeRemaining),
new Date(),
{ addSuffix: true }
);
};
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) => {
@@ -99,110 +572,14 @@ export function DownloadGroup({
[updateLibrary]
);
const getGameInfo = (game: LibraryGame) => {
const download = game.download!;
const isGameDownloading = lastPacket?.gameId === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
const seedingStatus = seedingMap.get(game.id);
if (download.extracting) {
return <p>{t("extracting")}</p>;
}
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
}
if (lastPacket?.isCheckingFiles) {
return (
<>
<p>{progress}</p>
<p>{t("checking_files")}</p>
</>
);
}
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket.download.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{download.downloader === Downloader.Torrent && (
<small
className="download-group__details-with-article"
data-open-article="peers-and-seeds"
>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
<QuestionIcon size={12} />
</small>
)}
</>
);
}
if (download.progress === 1) {
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
return download.status === "seeding" &&
download.downloader === Downloader.Torrent ? (
<>
<p
data-open-article="seeding"
className="download-group__details-with-article"
>
{t("seeding")}
<QuestionIcon />
</p>
{uploadSpeed && <p>{uploadSpeed}/s</p>}
</>
) : (
<p>{t("completed")}</p>
);
}
if (download.status === "paused") {
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
<p>{t(download.queued ? "queued" : "paused")}</p>
</>
);
}
if (download.status === "active") {
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
<p>
{formatBytes(download.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(download.status as string)}</p>;
};
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download;
const isGameDownloading = lastPacket?.gameId === game.id;
const isGameDownloading = isGameDownloadingMap[game.id];
const deleting = isGameDeleting(game.id);
if (game.download?.progress === 1) {
return [
const actions = [
{
label: t("install"),
disabled: deleting,
@@ -224,7 +601,7 @@ export function DownloadGroup({
disabled: deleting,
icon: <UnlinkIcon />,
show:
game.download?.status === "seeding" &&
isGameSeeding(game) &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
pauseSeeding(game.shop, game.objectId);
@@ -235,7 +612,7 @@ export function DownloadGroup({
disabled: deleting,
icon: <LinkIcon />,
show:
game.download?.status !== "seeding" &&
!isGameSeeding(game) &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
resumeSeeding(game.shop, game.objectId);
@@ -250,6 +627,7 @@ export function DownloadGroup({
},
},
];
return actions.filter((action) => action.show !== false);
}
if (isGameDownloading) {
@@ -296,80 +674,137 @@ export function DownloadGroup({
];
};
const downloadInfo = useMemo(
() =>
library.map((game) => ({
game,
size: getFinalDownloadSize(game),
progress: game.download?.progress || 0,
isSeeding: isGameSeeding(game),
})),
[library, lastPacket?.gameId]
);
if (!library.length) return null;
const isDownloadingGroup = title === t("download_in_progress");
const isQueuedGroup = title === t("queued_downloads");
const isCompletedGroup = title === t("downloads_completed");
if (isDownloadingGroup && library.length > 0) {
const game = library[0];
const isGameDownloading = isGameDownloadingMap[game.id];
const downloadSpeed = isGameDownloading
? (lastPacket?.downloadSpeed ?? 0)
: 0;
const finalDownloadSize = getFinalDownloadSize(game);
const peakSpeed = peakSpeedsRef.current[game.id] || 0;
const currentProgress =
isGameDownloading && lastPacket
? lastPacket.progress
: game.download?.progress || 0;
const dominantColor = dominantColors[game.id] || "#fff";
return (
<HeroDownloadView
game={game}
isGameDownloading={isGameDownloading}
downloadSpeed={downloadSpeed}
finalDownloadSize={finalDownloadSize}
peakSpeed={peakSpeed}
currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={speedHistoryRef.current[game.id] || []}
getGameActions={getGameActions}
getStatusText={getStatusText}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
resumeDownload={resumeDownload}
t={t}
/>
);
}
return (
<div className="download-group">
<div
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>
<ul className="download-group__downloads">
{library.map((game) => {
<ul className="download-group__simple-list">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return (
<li
key={game.id}
className={cn("download-group__item", {
"download-group__item--hydra":
game.download?.downloader === Downloader.Hydra,
})}
>
<div className="download-group__cover">
<div className="download-group__cover-backdrop">
<img
src={game.libraryImageUrl ?? ""}
className="download-group__cover-image"
alt={game.title}
/>
<div className="download-group__cover-content">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
</div>
</div>
</div>
<div className="download-group__right-content">
<div className="download-group__details">
<div className="download-group__title-wrapper">
<button
type="button"
className="download-group__title"
onClick={() =>
navigate(
buildGameDetailsPath({
...game,
objectId: game.objectId,
})
)
}
>
{game.title}
</button>
</div>
{getGameInfo(game)}
</div>
{getGameActions(game) !== null && (
<DropdownMenu
align="end"
items={getGameActions(game)}
sideOffset={-75}
>
<Button
className="download-group__menu-button"
theme="outline"
>
<ThreeBarsIcon />
</Button>
</DropdownMenu>
)}
<li key={game.id} className="download-group__simple-card">
<div className="download-group__simple-thumbnail">
<img src={game.libraryImageUrl || ""} alt={game.title} />
</div>
{game.download?.downloader === Downloader.Hydra && (
<div className="download-group__hydra-gradient" />
<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>
</div>
{isQueuedGroup && (
<div className="download-group__simple-progress">
<span className="download-group__simple-progress-text">
{formatDownloadProgress(progress)}
</span>
<div className="download-group__progress-bar download-group__progress-bar--small">
<div
className="download-group__progress-fill"
style={{
width: `${progress * 100}%`,
backgroundColor: "#fff",
}}
/>
</div>
</div>
)}
<div className="download-group__simple-actions">
{game.download?.progress === 1 && (
<Button
theme="primary"
onClick={() => openGameInstaller(game.shop, game.objectId)}
disabled={isGameDeleting(game.id)}
className="download-group__simple-menu-btn"
>
<DownloadIcon size={16} />
</Button>
)}
{isQueuedGroup && game.download?.progress !== 1 && (
<Button
theme="primary"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__simple-menu-btn"
>
<PlayIcon size={16} />
</Button>
)}
<DropdownMenu align="end" items={getGameActions(game)}>
<Button
theme="outline"
className="download-group__simple-menu-btn"
>
<ThreeBarsIcon />
</Button>
</DropdownMenu>
</div>
</li>
);
})}

View File

@@ -3,7 +3,6 @@
.downloads {
&__container {
display: flex;
padding: calc(globals.$spacing-unit * 3);
flex-direction: column;
width: 100%;
}

View File

@@ -8,6 +8,7 @@ import {
import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context";
import { useAppSelector } from "@renderer/hooks";
import { VideoPlayer } from "./video-player";
import "./gallery-slider.scss";
export function GallerySlider() {
@@ -106,8 +107,6 @@ export function GallerySlider() {
if (shopDetails?.movies) {
shopDetails.movies.forEach((video, index) => {
// Prefer new formats: HLS (best browser support), then DASH H264, then DASH AV1
// Fallback to old format: mp4/webm if new formats are not available
let videoSrc: string | undefined;
let videoType: string | undefined;
@@ -121,11 +120,9 @@ export function GallerySlider() {
videoSrc = video.dash_av1;
videoType = "application/dash+xml";
} else if (video.mp4?.max) {
// Fallback to old format
videoSrc = video.mp4.max;
videoType = "video/mp4";
} else if (video.webm?.max) {
// Fallback to webm if mp4 is not available
videoSrc = video.webm.max;
videoType = "video/webm";
}
@@ -191,19 +188,17 @@ export function GallerySlider() {
{mediaItems.map((item) => (
<div key={item.id} className="gallery-slider__slide">
{item.type === "video" ? (
<video
controls
className="gallery-slider__media"
<VideoPlayer
videoSrc={item.videoSrc}
videoType={item.videoType}
poster={item.poster}
autoplay={autoplayEnabled}
loop
muted
autoPlay={autoplayEnabled}
controls
className="gallery-slider__media"
tabIndex={-1}
>
{item.videoSrc && (
<source src={item.videoSrc} type={item.videoType} />
)}
</video>
/>
) : (
<img
className="gallery-slider__media"

View File

@@ -0,0 +1,70 @@
import { useRef } from "react";
import { useHlsVideo } from "@renderer/hooks";
interface VideoPlayerProps {
videoSrc?: string;
videoType?: string;
poster?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
tabIndex?: number;
className?: string;
}
export function VideoPlayer({
videoSrc,
videoType,
poster,
autoplay = false,
muted = true,
loop = false,
controls = true,
tabIndex = -1,
className,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const isHls = videoType === "application/x-mpegURL";
useHlsVideo(videoRef, {
videoSrc,
videoType,
autoplay,
muted,
loop,
});
if (isHls) {
return (
<video
ref={videoRef}
controls={controls}
className={className}
poster={poster}
loop={loop}
muted={muted}
autoPlay={autoplay}
tabIndex={tabIndex}
>
<track kind="captions" />
</video>
);
}
return (
<video
ref={videoRef}
controls={controls}
className={className}
poster={poster}
loop={loop}
muted={muted}
autoPlay={autoplay}
tabIndex={tabIndex}
>
{videoSrc && <source src={videoSrc} type={videoType} />}
<track kind="captions" />
</video>
);
}

View File

@@ -76,7 +76,13 @@ export default function Library() {
switch (filterBy) {
case "recently_played":
filtered = library.filter((game) => game.lastTimePlayed !== null);
filtered = library
.filter((game) => game.lastTimePlayed !== null)
.sort(
(a: any, b: any) =>
new Date(b.lastTimePlayed).getTime() -
new Date(a.lastTimePlayed).getTime()
);
break;
case "favorites":
filtered = library.filter((game) => game.favorite);

View File

@@ -14,7 +14,7 @@ export interface SteamVideoSource {
"480": string;
}
export interface SteamMovies {
export interface SteamMovie {
id: number;
dash_av1?: string;
dash_h264?: string;
@@ -34,7 +34,7 @@ export interface SteamAppDetails {
short_description: string;
publishers: string[];
genres: SteamGenre[];
movies?: SteamMovies[];
movies?: SteamMovie[];
supported_languages: string;
screenshots?: SteamScreenshot[];
pc_requirements: {

View File

@@ -5690,6 +5690,11 @@ hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
hls.js@^1.5.12:
version "1.6.15"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.6.15.tgz#9ce13080d143a9bc9b903fb43f081e335b8321e5"
integrity sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==
hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
@@ -6205,9 +6210,9 @@ jiti@^2.6.1:
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies:
argparse "^2.0.1"
@@ -8518,10 +8523,10 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1:
mkdirp "^1.0.3"
yallist "^4.0.0"
tar@^7.4.3:
version "7.5.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.1.tgz#750a8bd63b7c44c1848e7bf982260a083cf747c9"
integrity sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==
tar@^7.5.2:
version "7.5.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc"
integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==
dependencies:
"@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0"