Compare commits

...

46 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
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
35 changed files with 1889 additions and 524 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

@@ -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",
@@ -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

@@ -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)",
@@ -96,7 +97,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",
@@ -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)",
@@ -93,8 +94,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",
@@ -406,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)",
@@ -93,11 +94,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."
},
@@ -394,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)",
@@ -30,11 +31,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."
},
@@ -221,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}} (Приостановлено)",
@@ -93,8 +94,16 @@
},
"header": {
"search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная",
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"search_results": "Результаты поиска",
"settings": "Настройки",
@@ -406,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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,158 +4,512 @@
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 {
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;
}
}
&__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: globals.$spacing-unit;
}
&__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-logo {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
&-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: 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: 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;
&: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;
}
}
}
&__menu-button {
position: absolute;
top: 12px;
right: 12px;
border-radius: 50%;
border: none;
padding: 8px;
&__hero-action-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 3);
margin-top: calc(globals.$spacing-unit * 4);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__hero-buttons {
display: flex;
gap: calc(globals.$spacing-unit);
align-items: center;
flex-shrink: 0;
}
&__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;
}
&__progress-info-row {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
&__progress-row {
display: flex;
align-items: flex-end;
gap: calc(globals.$spacing-unit * 2);
&--bar {
margin-top: calc(globals.$spacing-unit);
}
}
&__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;
align-self: flex-end;
display: inline-block;
overflow: hidden;
line-height: 1.2;
span {
display: inline-block;
}
}
&__progress-size {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
&__progress-status {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
&__progress-time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 13px;
color: globals.$muted-color;
}
&__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);
align-self: flex-start;
}
&__speed-chart {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
&__speed-chart-canvas {
width: 100%;
height: 80px;
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;
justify-content: space-between;
gap: calc(globals.$spacing-unit / 2);
width: 100%;
}
&__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);
border-radius: 8px;
}
&__simple-thumbnail {
width: 200px;
height: 100px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid globals.$border-color;
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 / 1);
}
&__simple-title {
font-size: 16px;
font-weight: 600;
color: #ffffff;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__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);
font-size: 13px;
color: globals.$muted-color;
}
&__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;
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-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__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;
margin-top: calc(globals.$spacing-unit / 2);
&--small {
height: 6px;
}
}
&__progress-fill {
height: 100%;
background-color: #fff;
transition: width 0.3s ease;
border-radius: 4px;
}
}

File diff suppressed because it is too large Load Diff

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

@@ -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

@@ -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

@@ -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={{

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"