Compare commits

...

59 Commits

Author SHA1 Message Date
Chubby Granny Chaser
47bfc1648f Merge branch 'main' into fix/fixing-level-events 2025-12-10 17:34:01 +00:00
Chubby Granny Chaser
a69a6ec510 Merge pull request #1889 from Lianela/main
feat: new strings
2025-12-10 17:15:45 +00:00
Chubby Granny Chaser
fada6507c3 Merge branch 'main' into main 2025-12-10 17:15:21 +00:00
Chubby Granny Chaser
0479f1347b Merge pull request #1887 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-a3f223628e
chore(deps): bump jws from 3.2.2 to 3.2.3 in the npm_and_yarn group across 1 directory
2025-12-10 17:14:44 +00:00
dependabot[bot]
f44d5c8b49 chore(deps): bump jws in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [jws](https://github.com/brianloveswords/node-jws).


Updates `jws` from 3.2.2 to 3.2.3
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 01:04:55 +00:00
Zamitto
c36109c092 chore: bump version
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-12-07 22:03:02 -03:00
Zamitto
b59fb7dc36 feat: support workwonders 2025-12-07 20:38:53 -03:00
Kyatto
214a7af408 Fix JSON formatting in translation file 2025-12-07 13:14:50 -06:00
Kyatto
14679fc31e Add new translation strings in Spanish 2025-12-07 13:05:59 -06:00
Chubby Granny Chaser
b5445b3dfa chore: updating lock 2025-12-01 06:51:08 +00:00
Chubby Granny Chaser
1ccf70af12 chore: updating lock 2025-11-30 23:57:13 +00:00
Chubby Granny Chaser
bb45b95820 chore: updating lock 2025-11-30 23:39:47 +00:00
Chubby Granny Chaser
361c158a44 fix: fixing level events 2025-11-30 23:17:56 +00:00
Chubby Granny Chaser
1f5e84b32c fix: fixing level events 2025-11-30 23:17:09 +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
60 changed files with 1886 additions and 836 deletions

View File

@@ -28,6 +28,19 @@
- Use async/await instead of promises when possible - Use async/await instead of promises when possible
- Prefer named exports over default exports for utilities and services - 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 ## TypeScript Array Syntax
- **Always use `T[]` syntax instead of `Array<T>`** for array types - **Always use `T[]` syntax instead of `Array<T>`** for array types

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.7.4", "version": "3.7.6",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.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", "start": "electron-vite preview",
"dev": "electron-vite dev", "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", "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:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac", "build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux", "build:linux": "electron-vite build && electron-builder --linux",
@@ -63,6 +63,7 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0", "file-type": "^20.5.0",
"framer-motion": "^12.15.0", "framer-motion": "^12.15.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2", "i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1", "i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
@@ -84,7 +85,7 @@
"sound-play": "^1.1.0", "sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor", "steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"tar": "^7.4.3", "tar": "^7.5.2",
"tough-cookie": "^5.1.1", "tough-cookie": "^5.1.1",
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View File

@@ -96,7 +96,7 @@
"search_library": "Search library", "search_library": "Search library",
"recent_searches": "Recent Searches", "recent_searches": "Recent Searches",
"suggestions": "Suggestions", "suggestions": "Suggestions",
"clear_history": "Clear history", "clear_history": "Clear",
"remove_from_history": "Remove from history", "remove_from_history": "Remove from history",
"loading": "Loading...", "loading": "Loading...",
"no_results": "No results", "no_results": "No results",
@@ -414,7 +414,11 @@
"resume_seeding": "Resume seeding", "resume_seeding": "Resume seeding",
"options": "Manage", "options": "Manage",
"extract": "Extract files", "extract": "Extract files",
"extracting": "Extracting files…" "extracting": "Extracting files…",
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Downloads path", "downloads_path": "Downloads path",

View File

@@ -93,8 +93,16 @@
}, },
"header": { "header": {
"search": "Buscar juegos", "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", "home": "Inicio",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas", "downloads": "Descargas",
"search_results": "Resultados de búsqueda", "search_results": "Resultados de búsqueda",
"settings": "Ajustes", "settings": "Ajustes",
@@ -406,7 +414,11 @@
"resume_seeding": "Continuar sembrando", "resume_seeding": "Continuar sembrando",
"options": "Administrar", "options": "Administrar",
"extract": "Extraer archivos", "extract": "Extraer archivos",
"extracting": "Extrayendo archivos…" "extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Ruta de descarga", "downloads_path": "Ruta de descarga",
@@ -450,6 +462,7 @@
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas", "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
"button_delete_all_sources": "Eliminar todo", "button_delete_all_sources": "Eliminar todo",
"added_download_source": "Añadir fuente de descarga", "added_download_source": "Añadir fuente de descarga",
"adding": "Añadiendo…",
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas", "download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
"insert_valid_json_url": "Introducí una URL de json válida", "insert_valid_json_url": "Introducí una URL de json válida",
"found_download_option_zero": "Sin opciones de descargas encontrada", "found_download_option_zero": "Sin opciones de descargas encontrada",
@@ -555,6 +568,19 @@
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"change_achievement_sound": "Cambiar sonido de logro",
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
"download_source_failed": "Error",
"download_source_matched": "Actualizado",
"download_source_matching": "Actualizando",
"download_source_no_information": "Sin información disponible",
"download_source_pending_matching": "Actualizando pronto",
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
"hydra_cloud": "Hydra Cloud",
"preview_sound": "Vista previa de sonido",
"remove_achievement_sound": "Eliminar sonido de logros",
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego" "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
}, },
"notifications": { "notifications": {

View File

@@ -93,11 +93,19 @@
}, },
"header": { "header": {
"search": "Buscar jogos", "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", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads", "downloads": "Downloads",
"search_results": "Resultados da busca", "search_results": "Resultados da busca",
"settings": "Ajustes", "settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.", "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." "version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
}, },
@@ -394,7 +402,11 @@
"resume_seeding": "Semear", "resume_seeding": "Semear",
"options": "Gerenciar", "options": "Gerenciar",
"extract": "Extrair arquivos", "extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…" "extracting": "Extraindo arquivos…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",

View File

@@ -30,11 +30,19 @@
}, },
"header": { "header": {
"search": "Procurar jogos", "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", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Transferências", "downloads": "Transferências",
"search_results": "Resultados da pesquisa", "search_results": "Resultados da pesquisa",
"settings": "Definições", "settings": "Definições",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.", "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." "version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
}, },
@@ -221,7 +229,13 @@
"seeding": "A semear", "seeding": "A semear",
"stop_seeding": "Parar de semear", "stop_seeding": "Parar de semear",
"resume_seeding": "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": { "settings": {
"downloads_path": "Local das transferências", "downloads_path": "Local das transferências",

View File

@@ -93,8 +93,16 @@
}, },
"header": { "header": {
"search": "Поиск", "search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная", "home": "Главная",
"catalogue": "Каталог", "catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки", "downloads": "Загрузки",
"search_results": "Результаты поиска", "search_results": "Результаты поиска",
"settings": "Настройки", "settings": "Настройки",
@@ -406,7 +414,11 @@
"resume_seeding": "Продолжить раздачу", "resume_seeding": "Продолжить раздачу",
"options": "Управлять", "options": "Управлять",
"extract": "Распаковать файлы", "extract": "Распаковать файлы",
"extracting": "Распаковка файлов…" "extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Путь загрузок", "downloads_path": "Путь загрузок",

View File

@@ -1,20 +0,0 @@
import jwt from "jsonwebtoken";
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
if (!auth) return null;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
if (!payload) return null;
return payload.sessionId;
};
registerEvent("getSessionHash", getSessionHash);

View File

@@ -1,3 +1,2 @@
import "./get-session-hash";
import "./open-auth-window"; import "./open-auth-window";
import "./sign-out"; import "./sign-out";

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesCheckBaseline } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesCheckBaselineHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesCheckBaseline();
};
registerEvent(
"getDownloadSourcesCheckBaseline",
getDownloadSourcesCheckBaselineHandler
);

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesSinceValue } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesSinceValueHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesSinceValue();
};
registerEvent(
"getDownloadSourcesSinceValue",
getDownloadSourcesSinceValueHandler
);

View File

@@ -1,10 +0,0 @@
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
import { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
const allSources = await downloadSourcesSublevel.values().all();
return orderBy(allSources, "createdAt", "desc");
};
registerEvent("getDownloadSources", getDownloadSources);

View File

@@ -1,6 +1,3 @@
import "./add-download-source"; import "./add-download-source";
import "./get-download-sources-check-baseline";
import "./get-download-sources-since-value";
import "./get-download-sources";
import "./remove-download-source"; import "./remove-download-source";
import "./sync-download-sources"; import "./sync-download-sources";

View File

@@ -1,27 +0,0 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { logger } from "@main/services";
import type { GameShop } from "@types";
const clearNewDownloadOptions = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
try {
await gamesSublevel.put(gameKey, {
...game,
newDownloadOptionsCount: undefined,
});
logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`);
} catch (error) {
logger.error(`Failed to clear newDownloadOptionsCount: ${error}`);
}
};
registerEvent("clearNewDownloadOptions", clearNewDownloadOptions);

View File

@@ -1,21 +0,0 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const [game, download] = await Promise.all([
gamesSublevel.get(gameKey),
downloadsSublevel.get(gameKey),
]);
if (!game || game.isDeleted) return null;
return { id: gameKey, ...game, download };
};
registerEvent("getGameByObjectId", getGameByObjectId);

View File

@@ -1,49 +0,0 @@
import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event";
import {
downloadsSublevel,
gameAchievementsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => {
return gamesSublevel
.iterator()
.all()
.then((results) => {
return Promise.all(
results
.filter(([_key, game]) => game.isDeleted === false)
.map(async ([key, game]) => {
const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
let unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
if (!game.unlockedAchievementCount) {
const achievements = await gameAchievementsSublevel.get(key);
unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0;
}
return {
id: key,
...game,
download: download ?? null,
unlockedAchievementCount,
achievementCount: game.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
};
})
);
});
};
registerEvent("getLibrary", getLibrary);

View File

@@ -3,7 +3,6 @@ import "./add-game-to-favorites";
import "./add-game-to-library"; import "./add-game-to-library";
import "./change-game-playtime"; import "./change-game-playtime";
import "./cleanup-unused-assets"; import "./cleanup-unused-assets";
import "./clear-new-download-options";
import "./close-game"; import "./close-game";
import "./copy-custom-game-asset"; import "./copy-custom-game-asset";
import "./create-game-shortcut"; import "./create-game-shortcut";
@@ -11,8 +10,6 @@ import "./create-steam-shortcut";
import "./delete-game-folder"; import "./delete-game-folder";
import "./extract-game-download"; import "./extract-game-download";
import "./get-default-wine-prefix-selection-path"; import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id";
import "./get-library";
import "./open-game-executable-path"; import "./open-game-executable-path";
import "./open-game-installer-path"; import "./open-game-installer-path";
import "./open-game-installer"; import "./open-game-installer";

View File

@@ -1,12 +0,0 @@
import { Theme } from "@types";
import { registerEvent } from "../register-event";
import { themesSublevel } from "@main/level";
const addCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
theme: Theme
) => {
await themesSublevel.put(theme.id, theme);
};
registerEvent("addCustomTheme", addCustomTheme);

View File

@@ -1,8 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
await themesSublevel.clear();
};
registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);

View File

@@ -1,11 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
await themesSublevel.del(themeId);
};
registerEvent("deleteCustomTheme", deleteCustomTheme);

View File

@@ -1,9 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getActiveCustomTheme = async () => {
const allThemes = await themesSublevel.values().all();
return allThemes.find((theme) => theme.isActive);
};
registerEvent("getActiveCustomTheme", getActiveCustomTheme);

View File

@@ -1,8 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
return themesSublevel.values().all();
};
registerEvent("getAllCustomThemes", getAllCustomThemes);

View File

@@ -1,11 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getCustomThemeById = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
return themesSublevel.get(themeId);
};
registerEvent("getCustomThemeById", getCustomThemeById);

View File

@@ -1,11 +1,5 @@
import "./add-custom-theme";
import "./close-editor-window"; import "./close-editor-window";
import "./copy-theme-achievement-sound"; import "./copy-theme-achievement-sound";
import "./delete-all-custom-themes";
import "./delete-custom-theme";
import "./get-active-custom-theme";
import "./get-all-custom-themes";
import "./get-custom-theme-by-id";
import "./get-theme-sound-data-url"; import "./get-theme-sound-data-url";
import "./get-theme-sound-path"; import "./get-theme-sound-path";
import "./import-theme-sound-from-store"; import "./import-theme-sound-from-store";

View File

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

View File

@@ -1,10 +0,0 @@
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
const getUserPreferences = async () =>
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
});
registerEvent("getUserPreferences", getUserPreferences);

View File

@@ -1,5 +1,4 @@
import "./authenticate-real-debrid"; import "./authenticate-real-debrid";
import "./authenticate-torbox"; import "./authenticate-torbox";
import "./auto-launch"; import "./auto-launch";
import "./get-user-preferences";
import "./update-user-preferences"; import "./update-user-preferences";

View File

@@ -1,11 +0,0 @@
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
import { registerEvent } from "../register-event";
const getAuth = async (_event: Electron.IpcMainInvokeEvent) =>
db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
registerEvent("getAuth", getAuth);

View File

@@ -1,3 +1,2 @@
import "./get-auth";
import "./get-compared-unlocked-achievements"; import "./get-compared-unlocked-achievements";
import "./get-unlocked-achievements"; import "./get-unlocked-achievements";

View File

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

View File

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

View File

@@ -58,7 +58,13 @@ export class HydraApi {
const decodedBase64 = atob(payload as string); const decodedBase64 = atob(payload as string);
const jsonData = JSON.parse(decodedBase64); const jsonData = JSON.parse(decodedBase64);
const { accessToken, expiresIn, refreshToken } = jsonData; const {
accessToken,
expiresIn,
refreshToken,
featurebaseJwt,
workwondersJwt,
} = jsonData;
const now = new Date(); const now = new Date();
@@ -85,6 +91,8 @@ export class HydraApi {
accessToken, accessToken,
refreshToken, refreshToken,
tokenExpirationTimestamp, tokenExpirationTimestamp,
featurebaseJwt,
workwondersJwt,
}, },
{ valueEncoding: "json" } { valueEncoding: "json" }
); );

View File

@@ -138,7 +138,8 @@ export class WindowManager {
(details, callback) => { (details, callback) => {
if ( if (
details.webContentsId !== this.mainWindow?.webContents.id || details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("chatwoot") details.url.includes("chatwoot") ||
details.url.includes("workwonders")
) { ) {
return callback(details); return callback(details);
} }
@@ -159,7 +160,8 @@ export class WindowManager {
if ( if (
details.webContentsId !== this.mainWindow?.webContents.id || details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("featurebase") || details.url.includes("featurebase") ||
details.url.includes("chatwoot") details.url.includes("chatwoot") ||
details.url.includes("workwonders")
) { ) {
return callback(details); return callback(details);
} }

View File

@@ -13,7 +13,6 @@ import type {
UpdateProfileRequest, UpdateProfileRequest,
SeedingStatus, SeedingStatus,
GameAchievement, GameAchievement,
Theme,
FriendRequestSync, FriendRequestSync,
ShortcutLocation, ShortcutLocation,
AchievementCustomNotificationPosition, AchievementCustomNotificationPosition,
@@ -86,7 +85,8 @@ contextBridge.exposeInMainWorld("electron", {
}, },
/* User preferences */ /* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"), getUserPreferences: () =>
ipcRenderer.invoke("leveldbGet", "userPreferences", null, "json"),
updateUserPreferences: (preferences: UserPreferences) => updateUserPreferences: (preferences: UserPreferences) =>
ipcRenderer.invoke("updateUserPreferences", preferences), ipcRenderer.invoke("updateUserPreferences", preferences),
autoLaunch: (autoLaunchProps: { enabled: boolean; minimized: boolean }) => autoLaunch: (autoLaunchProps: { enabled: boolean; minimized: boolean }) =>
@@ -101,12 +101,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addDownloadSource", url), ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (url: string, removeAll?: boolean) => removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll), ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesCheckBaseline: () =>
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
getDownloadSourcesSinceValue: () =>
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
/* Library */ /* Library */
toggleAutomaticCloudSync: ( toggleAutomaticCloudSync: (
@@ -183,8 +178,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId), ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) => removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: ( updateLaunchOptions: (
@@ -201,7 +194,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath), ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath),
verifyExecutablePathInUse: (executablePath: string) => verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) => openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId), ipcRenderer.invoke("openGameInstaller", shop, objectId),
@@ -230,8 +222,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("removeGame", shop, objectId), ipcRenderer.invoke("removeGame", shop, objectId),
deleteGameFolder: (shop: GameShop, objectId: string) => deleteGameFolder: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("deleteGameFolder", shop, objectId), ipcRenderer.invoke("deleteGameFolder", shop, objectId),
getGameByObjectId: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) => resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId), ipcRenderer.invoke("resetGameAchievements", shop, objectId),
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) => changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
@@ -287,8 +277,6 @@ contextBridge.exposeInMainWorld("electron", {
gameArtifactId: string gameArtifactId: string
) => ) =>
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId), ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
getGameArtifacts: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) => getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop), ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
selectGameBackupPath: ( selectGameBackupPath: (
@@ -503,11 +491,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop), ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
/* Auth */ /* Auth */
getAuth: () => ipcRenderer.invoke("getAuth"), getAuth: () => ipcRenderer.invoke("leveldbGet", "auth", null, "json"),
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),
openAuthWindow: (page: AuthPage) => openAuthWindow: (page: AuthPage) =>
ipcRenderer.invoke("openAuthWindow", page), ipcRenderer.invoke("openAuthWindow", page),
getSessionHash: () => ipcRenderer.invoke("getSessionHash"),
onSignIn: (cb: () => void) => { onSignIn: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb(); const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-signin", listener); ipcRenderer.on("on-signin", listener);
@@ -565,16 +552,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("showAchievementTestNotification"), ipcRenderer.invoke("showAchievementTestNotification"),
/* Themes */ /* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
deleteCustomTheme: (themeId: string) =>
ipcRenderer.invoke("deleteCustomTheme", themeId),
updateCustomTheme: (themeId: string, code: string) => updateCustomTheme: (themeId: string, code: string) =>
ipcRenderer.invoke("updateCustomTheme", themeId, code), ipcRenderer.invoke("updateCustomTheme", themeId, code),
getCustomThemeById: (themeId: string) =>
ipcRenderer.invoke("getCustomThemeById", themeId),
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) => toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
copyThemeAchievementSound: (themeId: string, sourcePath: string) => copyThemeAchievementSound: (themeId: string, sourcePath: string) =>

View File

@@ -6,7 +6,7 @@
<title>Hydra Launcher</title> <title>Hydra Launcher</title>
<meta <meta
http-equiv="Content-Security-Policy" 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> </head>
<body> <body>

View File

@@ -7,11 +7,13 @@ import {
useToast, useToast,
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
import { levelDBService } from "@renderer/services/leveldb.service";
import "./bottom-panel.scss"; import "./bottom-panel.scss";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants"; import { VERSION_CODENAME } from "@renderer/constants";
import type jwt from "jsonwebtoken";
export function BottomPanel() { export function BottomPanel() {
const { t } = useTranslation("bottom_panel"); const { t } = useTranslation("bottom_panel");
@@ -60,7 +62,28 @@ export function BottomPanel() {
}, [t, showSuccessToast]); }, [t, showSuccessToast]);
useEffect(() => { useEffect(() => {
window.electron.getSessionHash().then((result) => setSessionHash(result)); const getSessionHash = async () => {
const auth = (await levelDBService.get("auth", null, "json")) as {
accessToken?: string;
} | null;
if (!auth?.accessToken) {
setSessionHash(null);
return;
}
try {
const jwtModule = await import("jsonwebtoken");
const payload = jwtModule.decode(
auth.accessToken
) as jwt.JwtPayload | null;
setSessionHash(payload?.sessionId ?? null);
} catch {
setSessionHash(null);
}
};
getSessionHash();
}, [userDetails?.id]); }, [userDetails?.id]);
const status = useMemo(() => { const status = useMemo(() => {
@@ -122,10 +145,10 @@ export function BottomPanel() {
</button> </button>
<button <button
data-featurebase-changelog data-open-workwonders-changelog-mini
className="bottom-panel__version-button" className="bottom-panel__version-button"
> >
<small data-featurebase-changelog> <small>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot; {sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot; {VERSION_CODENAME}&quot;
</small> </small>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
} from "@renderer/hooks"; } from "@renderer/hooks";
import type { import type {
Download,
DownloadSource, DownloadSource,
GameRepack, GameRepack,
GameShop, GameShop,
@@ -95,9 +96,19 @@ export function GameDetailsContextProvider({
); );
const updateGame = useCallback(async () => { const updateGame = useCallback(async () => {
return window.electron const gameKey = `${shop}:${objectId}`;
.getGameByObjectId(shop, objectId) const [game, download] = await Promise.all([
.then((result) => setGame(result)); levelDBService.get(gameKey, "games") as Promise<LibraryGame | null>,
levelDBService.get(gameKey, "downloads") as Promise<Download | null>,
]);
if (!game || game.isDeleted) {
setGame(null);
return;
}
const { id: _id, ...gameWithoutId } = game;
setGame({ id: gameKey, ...gameWithoutId, download: download ?? null });
}, [shop, objectId]); }, [shop, objectId]);
const isGameDownloading = const isGameDownloading =

View File

@@ -14,14 +14,11 @@ import type {
GameStats, GameStats,
UserDetails, UserDetails,
FriendRequestSync, FriendRequestSync,
GameArtifact,
LudusaviBackup, LudusaviBackup,
UserAchievement, UserAchievement,
ComparedAchievements, ComparedAchievements,
LibraryGame,
GameRunning, GameRunning,
TorBoxUser, TorBoxUser,
Theme,
Auth, Auth,
ShortcutLocation, ShortcutLocation,
ShopAssets, ShopAssets,
@@ -142,10 +139,6 @@ declare global {
shop: GameShop, shop: GameShop,
objectId: string objectId: string
) => Promise<void>; ) => Promise<void>;
clearNewDownloadOptions: (
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: ( toggleGamePin: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -162,7 +155,6 @@ declare global {
winePrefixPath: string | null winePrefixPath: string | null
) => Promise<void>; ) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>; verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>; refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -177,10 +169,6 @@ declare global {
removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>; removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>;
removeGame: (shop: GameShop, objectId: string) => Promise<void>; removeGame: (shop: GameShop, objectId: string) => Promise<void>;
deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>; deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>;
getGameByObjectId: (
shop: GameShop,
objectId: string
) => Promise<LibraryGame | null>;
onGamesRunning: ( onGamesRunning: (
cb: ( cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[] gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
@@ -194,9 +182,9 @@ declare global {
playtimeInSeconds: number playtimeInSeconds: number
) => Promise<void>; ) => Promise<void>;
/* User preferences */ /* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>; authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>; authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: ( updateUserPreferences: (
preferences: Partial<UserPreferences> preferences: Partial<UserPreferences>
) => Promise<void>; ) => Promise<void>;
@@ -217,10 +205,7 @@ declare global {
removeAll = false, removeAll = false,
downloadSourceId?: string downloadSourceId?: string
) => Promise<void>; ) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>; syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>; getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
@@ -237,10 +222,6 @@ declare global {
shop: GameShop, shop: GameShop,
gameArtifactId: string gameArtifactId: string
) => Promise<void>; ) => Promise<void>;
getGameArtifacts: (
objectId: string,
shop: GameShop
) => Promise<GameArtifact[]>;
getGameBackupPreview: ( getGameBackupPreview: (
objectId: string, objectId: string,
shop: GameShop shop: GameShop
@@ -355,7 +336,6 @@ declare global {
getAuth: () => Promise<Auth | null>; getAuth: () => Promise<Auth | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>; openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>;
onSignIn: (cb: () => void) => () => Electron.IpcRenderer; onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer; onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer;
onSignOut: (cb: () => void) => () => Electron.IpcRenderer; onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
@@ -408,13 +388,7 @@ declare global {
showAchievementTestNotification: () => Promise<void>; showAchievementTestNotification: () => Promise<void>;
/* Themes */ /* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, code: string) => Promise<void>; updateCustomTheme: (themeId: string, code: string) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>; toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
copyThemeAchievementSound: ( copyThemeAchievementSound: (
themeId: string, themeId: string,

View File

@@ -10,3 +10,4 @@ export * from "./use-download-options-listener";
export * from "./use-game-card"; export * from "./use-game-card";
export * from "./use-search-history"; export * from "./use-search-history";
export * from "./use-search-suggestions"; 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,15 +1,65 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux"; import { useAppDispatch, useAppSelector } from "./redux";
import { setLibrary } from "@renderer/features"; import { setLibrary } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import type {
LibraryGame,
Game,
Download,
ShopAssets,
GameAchievement,
} from "@types";
export function useLibrary() { export function useLibrary() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const library = useAppSelector((state) => state.library.value); const library = useAppSelector((state) => state.library.value);
const updateLibrary = useCallback(async () => { const updateLibrary = useCallback(async () => {
return window.electron const results = await levelDBService.iterator("games");
.getLibrary()
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary))); const libraryGames = await Promise.all(
results
.filter(([_key, game]) => (game as Game).isDeleted === false)
.map(async ([key, game]) => {
const gameData = game as Game;
const download = (await levelDBService.get(
key,
"downloads"
)) as Download | null;
const gameAssets = (await levelDBService.get(
key,
"gameShopAssets"
)) as (ShopAssets & { updatedAt: number }) | null;
let unlockedAchievementCount = gameData.unlockedAchievementCount ?? 0;
if (!gameData.unlockedAchievementCount) {
const achievements = (await levelDBService.get(
key,
"gameAchievements"
)) as GameAchievement | null;
unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0;
}
return {
id: key,
...gameData,
download: download ?? null,
unlockedAchievementCount,
achievementCount: gameData.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Preserve custom image URLs from game if they exist
customIconUrl: gameData.customIconUrl,
customLogoImageUrl: gameData.customLogoImageUrl,
customHeroImageUrl: gameData.customHeroImageUrl,
} as LibraryGame;
})
);
dispatch(setLibrary(libraryGames));
}, [dispatch]); }, [dispatch]);
return { library, updateLibrary }; return { library, updateLibrary };

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

View File

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

View File

@@ -4,158 +4,512 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: calc(globals.$spacing-unit * 2); gap: calc(globals.$spacing-unit * 2);
margin-inline: calc(globals.$spacing-unit * 3);
padding-block: calc(globals.$spacing-unit * 3);
&__details-with-article { &--queued {
display: flex; padding-bottom: 0;
align-items: center; }
gap: calc(globals.$spacing-unit / 2);
align-self: flex-start; &--completed {
cursor: pointer; padding-top: calc(globals.$spacing-unit * 3);
} }
&__header { &__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: calc(globals.$spacing-unit);
gap: calc(globals.$spacing-unit * 2);
&-divider { &-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1; 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 { &-count {
font-weight: 400; background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
} }
} }
&--hero {
&__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 {
width: 100%; width: 100%;
gap: calc(globals.$spacing-unit * 2); position: relative;
display: flex; overflow: hidden;
flex-direction: column;
margin: 0; margin: 0;
padding: 0; padding: 0;
margin-top: globals.$spacing-unit; padding-bottom: globals.$spacing-unit;
} }
&__item { &__hero-background {
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
background-color: globals.$background-color; height: 120%;
display: flex; z-index: 0;
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;
&--hydra { img {
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 20%;
} }
} }
&__cover { // PLEASE FIX THE COLORS
width: 280px; &__hero-overlay {
min-width: 280px; position: absolute;
height: auto; top: 0;
border-right: solid 1px globals.$border-color; 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; position: relative;
z-index: 1; z-index: 1;
padding: calc(globals.$spacing-unit * 4);
&-content { padding-bottom: 0;
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; gap: calc(globals.$spacing-unit * 2);
justify-content: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 14px;
} }
&__actions { &__hero-logo {
flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; 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 { &__hero-action-row {
position: absolute; display: flex;
top: 12px; justify-content: space-between;
right: 12px; align-items: flex-start;
border-radius: 50%; gap: calc(globals.$spacing-unit * 3);
border: none; margin-top: calc(globals.$spacing-unit * 4);
padding: 8px; 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; min-height: unset;
} }
&__hydra-gradient { &__progress-wrapper {
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%); flex: 1;
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15); display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__progress-bar {
width: 100%; width: 100%;
position: absolute; height: 8px;
bottom: 0; background-color: rgba(255, 255, 255, 0.08);
height: 2px; border-radius: 4px;
z-index: 1; 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 { .downloads {
&__container { &__container {
display: flex; display: flex;
padding: calc(globals.$spacing-unit * 3);
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
} }

View File

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

View File

@@ -76,7 +76,13 @@ export default function Library() {
switch (filterBy) { switch (filterBy) {
case "recently_played": 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; break;
case "favorites": case "favorites":
filtered = library.filter((game) => game.favorite); filtered = library.filter((game) => game.favorite);

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ export interface Auth {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
tokenExpirationTimestamp: number; tokenExpirationTimestamp: number;
featurebaseJwt: string;
workwondersJwt: string;
} }
export interface User { export interface User {

View File

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

View File

@@ -5690,6 +5690,11 @@ hasown@^2.0.2:
dependencies: dependencies:
function-bind "^1.1.2" 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: hoist-non-react-statics@^3.3.2:
version "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" 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== integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^4.1.0: js-yaml@^4.1.0:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies: dependencies:
argparse "^2.0.1" argparse "^2.0.1"
@@ -6325,7 +6330,7 @@ jsonwebtoken@^9.0.2:
object.assign "^4.1.4" object.assign "^4.1.4"
object.values "^1.1.6" object.values "^1.1.6"
jwa@^1.4.1: jwa@^1.4.2:
version "1.4.2" version "1.4.2"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9"
integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==
@@ -6335,11 +6340,11 @@ jwa@^1.4.1:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
jws@^3.2.2: jws@^3.2.2:
version "3.2.2" version "3.2.3"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==
dependencies: dependencies:
jwa "^1.4.1" jwa "^1.4.2"
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
keyv@^4.0.0, keyv@^4.5.3: keyv@^4.0.0, keyv@^4.5.3:
@@ -8518,10 +8523,10 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1:
mkdirp "^1.0.3" mkdirp "^1.0.3"
yallist "^4.0.0" yallist "^4.0.0"
tar@^7.4.3: tar@^7.5.2:
version "7.5.1" version "7.5.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.1.tgz#750a8bd63b7c44c1848e7bf982260a083cf747c9" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc"
integrity sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g== integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==
dependencies: dependencies:
"@isaacs/fs-minipass" "^4.0.0" "@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0" chownr "^3.0.0"