17 Commits

Author SHA1 Message Date
83076d3dfc chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-20 12:34:06 +05:00
04aaf68e36 fix: Allow context menu for PortProton games without valid exe
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-20 12:31:36 +05:00
e91037708a fix(main_window): prevent RuntimeError when modifying deleted QVBoxLayout in HLTB callback
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-18 20:54:45 +05:00
1b743026c2 chore(build): clean appimage more agressive
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-18 15:26:51 +05:00
30b4cec4d1 chore(todo): fix typos
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-18 00:02:11 +05:00
db68c9050c chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 20:16:02 +05:00
1a93d5b82c chore(build): rework appimage dependency list
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 20:04:56 +05:00
cc0690cf9e fix: added perllib to appimage for fix exiftool work
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 19:58:56 +05:00
809ba2c976 chore(readme): mention all licences
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 19:42:22 +05:00
68c9636e10 chore(todo): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-16 16:56:18 +05:00
f0df1f89be chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 20:04:34 +05:00
f25224b668 refactor(cli): remove unused --session flag
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 20:00:43 +05:00
0cda47fdfd fix(input_manager): disable fullscreen toggle from keyboard/gamepad in gamescope session
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 19:58:05 +05:00
1a8c733580 chore(todo): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:23:44 +05:00
2476bea32a chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:19:36 +05:00
1bbc95a5c1 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:18:40 +05:00
d12b801191 feat: added data from How Long To Beat to GameCard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-14 13:15:17 +05:00
20 changed files with 336 additions and 291 deletions

View File

@@ -9,15 +9,21 @@
- Переводы в переопределениях (за подробностями в документацию) - Переводы в переопределениях (за подробностями в документацию)
- Обложки и описания для всех автоинсталлов - Обложки и описания для всех автоинсталлов
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры - Возможность указать ссылку для скачивания обложки в диалоге добавления игры
- Интеграция с howlongtobeat.com
### Changed ### Changed
- Оптимизированны обложки автоинсталлов - Оптимизированны обложки автоинсталлов
- Папка custom_data исключена из сборки модуля для уменьшение его размера - Папка custom_data исключена из сборки модуля для уменьшение его размера
- Бейдж PortProton теперь открывает PortProtonDB - Бейдж PortProton теперь открывает PortProtonDB
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии
- Удалён аргумент `--session` так как тестирование gamescope сессии завершено
- В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton"
### Fixed ### Fixed
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси - Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
- Путь к portprotonqt-session-select в оверлее - Путь к portprotonqt-session-select в оверлее
- Работа exiftool в AppImage
- Открытие контекстного меню у игр без exe
### Contributors ### Contributors
- @Vector_null - @Vector_null

View File

@@ -51,11 +51,11 @@ pre-commit run --all-files
PortProtonQt использует код и зависимости от следующих проектов: PortProtonQt использует код и зависимости от следующих проектов:
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html). - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://opensource.org/licenses/MIT). - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT). - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
Полный текст лицензий см. в файлах [LICENSE](LICENSE), [LICENSE-icoextract](documentation/licenses/icoextract), [LICENSE-portproton](documentation/licenses/portproton), [LICENSE-legendary](documentation/licenses/legendary). Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING] > [!WARNING]
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована > Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована

View File

@@ -17,7 +17,6 @@
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Избавиться от вызовов yad - [X] Избавиться от вызовов yad
- [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0))
- [X] Реализовать собственный системный трей вместо использования трея PortProton - [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
@@ -42,6 +41,7 @@
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql) - [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam - [X] Добавить на карточку бейдж, указывающий, что игра из Steam
- [X] Добавить поддержку версий Steam для Flatpak и Snap - [X] Добавить поддержку версий Steam для Flatpak и Snap
- [ ] Реализовать добавление игры как сторонней в Steam без перезапуска
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся - [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад» - [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide) - [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
@@ -57,13 +57,12 @@
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме - [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
- [ ] Добавить поддержку GOG (?) - [ ] Добавить поддержку GOG (?)
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант) - [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?) - [X] Добавить данные с HowLongToBeat на страницу с деталями игры
- [X] Добавить виброотдачу на геймпаде при запуске игры - [X] Добавить виброотдачу на геймпаде при запуске игры
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры - [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [ ] Доделать светлую тему - [ ] Доделать светлую тему
- [ ] Добавить подсказки к управлению с геймпада - [ ] Добавить подсказки к управлению с геймпада
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры - [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры - [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры

View File

@@ -1,5 +1,4 @@
version: 1 version: 1
script: script:
# 1) чистим старый AppDir # 1) чистим старый AppDir
- rm -rf AppDir || true - rm -rf AppDir || true
@@ -17,10 +16,31 @@ script:
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
- shopt -s extglob - shopt -s extglob
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*) - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
AppDir: AppDir:
path: ./AppDir path: ./AppDir
after_bundle:
# Документация, справка, примеры
- rm -rf $TARGET_APPDIR/usr/share/man || true
- rm -rf $TARGET_APPDIR/usr/share/doc || true
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true
- rm -rf $TARGET_APPDIR/usr/share/info || true
- rm -rf $TARGET_APPDIR/usr/share/help || true
- rm -rf $TARGET_APPDIR/usr/share/gtk-doc || true
- rm -rf $TARGET_APPDIR/usr/share/devhelp || true
- rm -rf $TARGET_APPDIR/usr/share/examples || true
- rm -rf $TARGET_APPDIR/usr/share/pkgconfig || true
- rm -rf $TARGET_APPDIR/usr/share/bash-completion || true
- rm -rf $TARGET_APPDIR/usr/share/pixmaps || true
- rm -rf $TARGET_APPDIR/usr/share/mime || true
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true
- rm -rf $TARGET_APPDIR/usr/include || true
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
# Статика и отладка
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
# Strip ELF бинарников (исключая Python extensions)
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
# Удаление пустых папок
- find $TARGET_APPDIR -type d -empty -delete || true
app_info: app_info:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
@@ -28,15 +48,13 @@ AppDir:
version: 0.1.3 version: 0.1.3
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:
arch: amd64 arch: amd64
sources: sources:
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse' - sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c' key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
include: include:
- python3 - python3-minimal
- python3-pkg-resources - python3-pkg-resources
- libopengl0 - libopengl0
- libk5crypto3 - libk5crypto3
@@ -45,13 +63,23 @@ AppDir:
- libxcb-cursor0 - libxcb-cursor0
- libimage-exiftool-perl - libimage-exiftool-perl
- xdg-utils - xdg-utils
exclude: [] exclude:
# Документация и man-страницы
- "*-doc"
- "*-man"
- manpages
- mandb
# Статические библиотеки
- "*-dev"
- "*-static"
# Дебаг-символы
- "*-dbg"
- "*-dbgsym"
runtime: runtime:
env: env:
PYTHONHOME: '${APPDIR}/usr' PYTHONHOME: '${APPDIR}/usr'
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages' PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
AppImage: AppImage:
sign-key: None sign-key: None
arch: x86_64 arch: x86_64

View File

@@ -9,7 +9,7 @@ _portprotonqt() {
esac esac
if [[ "$cur" == -* ]]; then if [[ "$cur" == -* ]]; then
COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) ) COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
return 0 return 0
fi fi

View File

@@ -20,9 +20,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 194 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 197 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 194 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 197 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 of 194 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 of 197 |
--- ---

View File

@@ -20,9 +20,9 @@
| Локаль | Прогресс | Переведено | | Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 194 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 194 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 из 194 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 из 197 |
--- ---

View File

@@ -1,6 +1,4 @@
import sys import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
@@ -35,13 +33,6 @@ def main():
window = MainWindow() window = MainWindow()
if args.session:
gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
cmd = f"{gamescope_cmd} -- portprotonqt"
logger.info(f"Executing: {cmd}")
subprocess.Popen(cmd, shell=True)
sys.exit(0)
if args.fullscreen: if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag") logger.info("Launching in fullscreen mode due to --fullscreen flag")
save_fullscreen_config(True) save_fullscreen_config(True)

View File

@@ -13,9 +13,4 @@ def parse_args():
action="store_true", action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку" help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
) )
parser.add_argument(
"--session",
action="store_true",
help="Запустить приложение с использованием gamescope"
)
return parser.parse_args() return parser.parse_args()

View File

@@ -148,10 +148,7 @@ class ContextMenuManager:
return False return False
current_exe = os.path.basename(exe_path) current_exe = os.path.basename(exe_path)
# Check if the current_exe matches the target_exe in MainWindow return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe:
return True
return False
def show_context_menu(self, game_card, pos: QPoint): def show_context_menu(self, game_card, pos: QPoint):
""" """
@@ -161,7 +158,6 @@ class ContextMenuManager:
game_card: The GameCard instance requesting the context menu. game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear. pos: The position (in widget coordinates) where the menu should appear.
""" """
def get_safe_icon(icon_name: str) -> QIcon: def get_safe_icon(icon_name: str) -> QIcon:
icon = self.theme_manager.get_icon(icon_name) icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon): if isinstance(icon, QIcon):
@@ -173,7 +169,18 @@ class ContextMenuManager:
menu = QMenu(self.parent) menu = QMenu(self.parent)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
# Check if the game is running # For non-Steam and non-Epic games, check if exe exists
if game_card.game_source not in ("steam", "epic"):
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
if not exe_path:
# Show only "Delete from PortProton" if no valid exe
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
menu.exec(game_card.mapToGlobal(pos))
return
# Normal menu for games with valid exe or from Steam/Epic
is_running = self._is_game_running(game_card) is_running = self._is_game_running(game_card)
action_text = _("Stop Game") if is_running else _("Launch Game") action_text = _("Stop Game") if is_running else _("Launch Game")
action_icon = "stop" if is_running else "play" action_icon = "stop" if is_running else "play"
@@ -697,15 +704,12 @@ Icon={icon_path}
return None return None
return exec_line return exec_line
def _parse_exe_path(self, exec_line, game_name): def _parse_exe_path(self, exec_line: str, game_name: str) -> str | None:
"""Parse the executable path from exec_line.""" """Parse the executable path from exec_line."""
try: try:
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
self.signals.show_warning_dialog.emit( logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
_("Error"),
_("Invalid executable command: {exec_line}").format(exec_line=exec_line)
)
return None return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2] exe_path = entry_exec_split[2]
@@ -714,17 +718,11 @@ Icon={icon_path}
else: else:
exe_path = entry_exec_split[-1] exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path): if not exe_path or not os.path.exists(exe_path):
self.signals.show_warning_dialog.emit( logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
_("Error"),
_("Executable not found: {path}").format(path=exe_path or "None")
)
return None return None
return exe_path return exe_path
except Exception as e: except Exception as e:
self.signals.show_warning_dialog.emit( logger.debug("Failed to parse executable for '%s': %s", game_name, e)
_("Error"),
_("Failed to parse executable: {error}").format(error=str(e))
)
return None return None
def _remove_file(self, file_path, error_message, success_message, game_name, location=""): def _remove_file(self, file_path, error_message, success_message, game_name, location=""):

View File

@@ -1,71 +1,37 @@
import orjson import orjson
import re import re
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum
from typing import Any from typing import Any
from difflib import SequenceMatcher from difflib import SequenceMatcher
from threading import Thread
import requests import requests
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from portprotonqt.config_utils import read_proxy_config from portprotonqt.config_utils import read_proxy_config
from portprotonqt.time_utils import format_playtime
from PySide6.QtCore import QObject, Signal
class SearchModifiers(Enum):
"""Модификаторы поиска для фильтрации результатов."""
NONE = ""
ONLY_DLC = "only_dlc"
ONLY_MODS = "only_mods"
ONLY_HACKS = "only_hacks"
HIDE_DLC = "hide_dlc"
@dataclass @dataclass
class GameEntry: class GameEntry:
"""Информация об игре из HowLongToBeat.""" """Информация об игре из HowLongToBeat."""
# Основная информация
game_id: int = -1 game_id: int = -1
game_name: str | None = None game_name: str | None = None
game_alias: str | None = None
game_type: str | None = None
game_image_url: str | None = None
game_web_link: str | None = None
review_score: float | None = None
developer: str | None = None
platforms: list[str] = field(default_factory=list)
release_year: int | None = None
similarity: float = -1.0
# Времена прохождения (в часах)
main_story: float | None = None main_story: float | None = None
main_extra: float | None = None main_extra: float | None = None
completionist: float | None = None completionist: float | None = None
all_styles: float | None = None similarity: float = -1.0
coop_time: float | None = None
multiplayer_time: float | None = None
# Флаги сложности
has_single_player: bool = False
has_coop: bool = False
has_multiplayer: bool = False
has_combined_complexity: bool = False
# Исходные данные JSON
raw_data: dict[str, Any] = field(default_factory=dict) raw_data: dict[str, Any] = field(default_factory=dict)
@dataclass @dataclass
class SearchConfig: class SearchConfig:
"""Конфигурация для поиска.""" """Конфигурация для поиска."""
api_key: str | None = None api_key: str | None = None
search_url: str | None = None search_url: str | None = None
class APIKeyExtractor: class APIKeyExtractor:
"""Извлекает API ключ и URL поиска из скриптов сайта.""" """Извлекает API ключ и URL поиска из скриптов сайта."""
@staticmethod @staticmethod
def extract_from_script(script_content: str) -> SearchConfig: def extract_from_script(script_content: str) -> SearchConfig:
"""Извлекает конфигурацию из содержимого скрипта."""
config = SearchConfig() config = SearchConfig()
config.api_key = APIKeyExtractor._extract_api_key(script_content) config.api_key = APIKeyExtractor._extract_api_key(script_content)
config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key) config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key)
@@ -73,53 +39,40 @@ class APIKeyExtractor:
@staticmethod @staticmethod
def _extract_api_key(script_content: str) -> str | None: def _extract_api_key(script_content: str) -> str | None:
"""Извлекает API ключ из скрипта."""
# Паттерн для поиска user ID
user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"' user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
matches = re.findall(user_id_pattern, script_content) matches = re.findall(user_id_pattern, script_content)
if matches: if matches:
return ''.join(matches) return ''.join(matches)
# Паттерн для поиска конкатенированного API ключа
concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+' concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+'
matches = re.findall(concat_pattern, script_content) matches = re.findall(concat_pattern, script_content)
if matches: if matches:
parts = str(matches).split('.concat') parts = str(matches).split('.concat')
cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]] cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]]
return ''.join(cleaned_parts) return ''.join(cleaned_parts)
return None return None
@staticmethod @staticmethod
def _extract_search_url(script_content: str, api_key: str | None) -> str | None: def _extract_search_url(script_content: str, api_key: str | None) -> str | None:
"""Извлекает URL поиска из скрипта."""
if not api_key: if not api_key:
return None return None
pattern = re.compile( pattern = re.compile(
r'fetch\(\s*["\'](\/api\/[^"\']*)["\']' r'fetch\(\s*["\'](\/api\/[^"\']*)["\']'
r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)' r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)'
r'\s*,', r'\s*,',
re.DOTALL re.DOTALL
) )
for match in pattern.finditer(script_content): for match in pattern.finditer(script_content):
endpoint = match.group(1) endpoint = match.group(1)
concat_calls = match.group(2) concat_calls = match.group(2)
concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls) concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls)
concatenated_str = ''.join(concat_strings) concatenated_str = ''.join(concat_strings)
if concatenated_str == api_key: if concatenated_str == api_key:
return endpoint return endpoint
return None return None
class HTTPClient: class HTTPClient:
"""HTTP клиент для работы с API HowLongToBeat.""" """HTTP клиент для работы с API HowLongToBeat."""
BASE_URL = 'https://howlongtobeat.com/' BASE_URL = 'https://howlongtobeat.com/'
GAME_URL = BASE_URL + "game"
SEARCH_URL = BASE_URL + "api/s/" SEARCH_URL = BASE_URL + "api/s/"
def __init__(self, timeout: int = 60): def __init__(self, timeout: int = 60):
@@ -129,35 +82,23 @@ class HTTPClient:
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'referer': self.BASE_URL 'referer': self.BASE_URL
}) })
# Apply proxy settings from config
proxy_config = read_proxy_config() proxy_config = read_proxy_config()
if proxy_config: if proxy_config:
self.session.proxies.update(proxy_config) self.session.proxies.update(proxy_config)
def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None: def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None:
"""Получает конфигурацию поиска с главной страницы."""
try: try:
response = self.session.get(self.BASE_URL, timeout=self.timeout) response = self.session.get(self.BASE_URL, timeout=self.timeout)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, 'html.parser')
scripts = soup.find_all('script', src=True) scripts = soup.find_all('script', src=True)
# Filter for Tag objects and ensure src is a string
if parse_all_scripts:
script_urls = [] script_urls = []
for script in scripts: for script in scripts:
if isinstance(script, Tag): if isinstance(script, Tag):
src = script.get('src') src = script.get('src')
if src is not None and isinstance(src, str): if src is not None and isinstance(src, str):
if parse_all_scripts or '_app-' in src:
script_urls.append(src) script_urls.append(src)
else:
script_urls = []
for script in scripts:
if isinstance(script, Tag):
src = script.get('src')
if src is not None and isinstance(src, str) and '_app-' in src:
script_urls.append(src)
for script_url in script_urls: for script_url in script_urls:
full_url = self.BASE_URL + script_url full_url = self.BASE_URL + script_url
script_response = self.session.get(full_url, timeout=self.timeout) script_response = self.session.get(full_url, timeout=self.timeout)
@@ -169,28 +110,21 @@ class HTTPClient:
pass pass
return None return None
def search_games(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None:
page: int = 1, config: SearchConfig | None = None) -> str | None:
"""Выполняет поиск игр."""
if not config: if not config:
config = self.get_search_config() config = self.get_search_config()
if not config: if not config:
config = self.get_search_config(parse_all_scripts=True) config = self.get_search_config(parse_all_scripts=True)
if not config or not config.api_key: if not config or not config.api_key:
return None return None
search_url = self.SEARCH_URL search_url = self.SEARCH_URL
if config.search_url: if config.search_url:
search_url = self.BASE_URL + config.search_url.lstrip('/') search_url = self.BASE_URL + config.search_url.lstrip('/')
payload = self._build_search_payload(game_name, page, config)
payload = self._build_search_payload(game_name, search_modifiers, page, config)
headers = { headers = {
'content-type': 'application/json', 'content-type': 'application/json',
'accept': '*/*' 'accept': '*/*'
} }
# Попытка с API ключом в URL
try: try:
response = self.session.post( response = self.session.post(
search_url + config.api_key, search_url + config.api_key,
@@ -202,8 +136,6 @@ class HTTPClient:
return response.text return response.text
except requests.RequestException: except requests.RequestException:
pass pass
# Попытка с API ключом в payload
try: try:
response = self.session.post( response = self.session.post(
search_url, search_url,
@@ -215,37 +147,14 @@ class HTTPClient:
return response.text return response.text
except requests.RequestException: except requests.RequestException:
pass pass
return None return None
def get_game_title(self, game_id: int) -> str | None: def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]:
"""Получает название игры по ID."""
try:
params = {'id': str(game_id)}
response = self.session.get(self.GAME_URL, params=params, timeout=self.timeout)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
title_tag = soup.title
if title_tag and title_tag.string:
# Обрезаем стандартные части заголовка
title = title_tag.string[12:-17].strip()
return title
except requests.RequestException:
pass
return None
def _build_search_payload(self, game_name: str, search_modifiers: SearchModifiers,
page: int, config: SearchConfig) -> dict[str, Any]:
"""Строит payload для поискового запроса."""
payload = { payload = {
'searchType': "games", 'searchType': "games",
'searchTerms': game_name.split(), 'searchTerms': game_name.split(),
'searchPage': page, 'searchPage': page,
'size': 20, 'size': 1, # Limit to 1 result
'searchOptions': { 'searchOptions': {
'games': { 'games': {
'userId': 0, 'userId': 0,
@@ -260,7 +169,7 @@ class HTTPClient:
"difficulty": "" "difficulty": ""
}, },
'rangeYear': {'max': "", 'min': ""}, 'rangeYear': {'max': "", 'min': ""},
'modifier': search_modifiers.value, 'modifier': "" # Hardcoded to empty string for SearchModifiers.NONE
}, },
'users': {'sortCategory': "postcount"}, 'users': {'sortCategory': "postcount"},
'lists': {'sortCategory': "follows"}, 'lists': {'sortCategory': "follows"},
@@ -268,194 +177,195 @@ class HTTPClient:
'sort': 0, 'sort': 0,
'randomizer': 0 'randomizer': 0
}, },
'useCache': True 'useCache': True,
'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"] # Request only needed fields
} }
if config.api_key: if config.api_key:
payload['searchOptions']['users']['id'] = config.api_key payload['searchOptions']['users']['id'] = config.api_key
return payload return payload
class ResultParser: class ResultParser:
"""Парсер результатов поиска.""" """Парсер результатов поиска."""
def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True):
IMAGE_URL_PREFIX = "https://howlongtobeat.com/games/"
GAME_URL_PREFIX = "https://howlongtobeat.com/game/"
def __init__(self, search_query: str, minimum_similarity: float = 0.4,
case_sensitive: bool = True, auto_filter_times: bool = False):
self.search_query = search_query self.search_query = search_query
self.minimum_similarity = minimum_similarity self.minimum_similarity = minimum_similarity
self.case_sensitive = case_sensitive self.case_sensitive = case_sensitive
self.auto_filter_times = auto_filter_times
self.search_numbers = self._extract_numbers(search_query) self.search_numbers = self._extract_numbers(search_query)
def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]: def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
"""Парсит JSON ответ и возвращает список игр."""
try: try:
data = orjson.loads(json_response) data = orjson.loads(json_response)
games = [] games = []
# Only process the first result
for game_data in data.get("data", []): if data.get("data"):
game_data = data["data"][0]
game = self._parse_game_entry(game_data) game = self._parse_game_entry(game_data)
if target_game_id is not None: if target_game_id is not None:
if game.game_id == target_game_id: if game.game_id == target_game_id:
games.append(game) games.append(game)
elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity: elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity:
games.append(game) games.append(game)
return games return games
except (orjson.JSONDecodeError, KeyError, IndexError):
except (orjson.JSONDecodeError, KeyError):
return [] return []
def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry: def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry:
"""Парсит данные одной игры."""
game = GameEntry() game = GameEntry()
# Основная информация
game.game_id = game_data.get("game_id", -1) game.game_id = game_data.get("game_id", -1)
game.game_name = game_data.get("game_name") game.game_name = game_data.get("game_name")
game.game_alias = game_data.get("game_alias")
game.game_type = game_data.get("game_type")
game.review_score = game_data.get("review_score")
game.developer = game_data.get("profile_dev")
game.release_year = game_data.get("release_world")
game.raw_data = game_data game.raw_data = game_data
# URL изображения
if "game_image" in game_data:
game.game_image_url = self.IMAGE_URL_PREFIX + game_data["game_image"]
# Ссылка на игру
game.game_web_link = f"{self.GAME_URL_PREFIX}{game.game_id}"
# Платформы
if "profile_platform" in game_data:
game.platforms = game_data["profile_platform"].split(", ")
# Времена прохождения (конвертация из секунд в часы)
time_fields = [ time_fields = [
("comp_main", "main_story"), ("comp_main", "main_story"),
("comp_plus", "main_extra"), ("comp_plus", "main_extra"),
("comp_100", "completionist"), ("comp_100", "completionist")
("comp_all", "all_styles"),
("invested_co", "coop_time"),
("invested_mp", "multiplayer_time")
] ]
for json_field, attr_name in time_fields: for json_field, attr_name in time_fields:
if json_field in game_data: if json_field in game_data:
time_hours = round(game_data[json_field] / 3600, 2) time_hours = round(game_data[json_field] / 3600, 2)
setattr(game, attr_name, time_hours) setattr(game, attr_name, time_hours)
# Флаги сложности
game.has_combined_complexity = bool(game_data.get("comp_lvl_combine", 0))
game.has_single_player = bool(game_data.get("comp_lvl_sp", 0))
game.has_coop = bool(game_data.get("comp_lvl_co", 0))
game.has_multiplayer = bool(game_data.get("comp_lvl_mp", 0))
# Автофильтрация времен
if self.auto_filter_times:
if not game.has_single_player:
game.main_story = None
game.main_extra = None
game.completionist = None
game.all_styles = None
if not game.has_coop:
game.coop_time = None
if not game.has_multiplayer:
game.multiplayer_time = None
# Вычисление similarity
game.similarity = self._calculate_similarity(game) game.similarity = self._calculate_similarity(game)
return game return game
def _calculate_similarity(self, game: GameEntry) -> float: def _calculate_similarity(self, game: GameEntry) -> float:
"""Вычисляет similarity между поисковым запросом и игрой.""" return self._compare_strings(self.search_query, game.game_name)
name_similarity = self._compare_strings(self.search_query, game.game_name)
alias_similarity = self._compare_strings(self.search_query, game.game_alias)
return max(name_similarity, alias_similarity)
def _compare_strings(self, a: str | None, b: str | None) -> float: def _compare_strings(self, a: str | None, b: str | None) -> float:
"""Сравнивает две строки и возвращает коэффициент similarity."""
if not a or not b: if not a or not b:
return 0.0 return 0.0
if self.case_sensitive: if self.case_sensitive:
similarity = SequenceMatcher(None, a, b).ratio() similarity = SequenceMatcher(None, a, b).ratio()
else: else:
similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio() similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio()
# Штраф за отсутствие чисел из оригинального запроса
if self.search_numbers and not self._contains_numbers(b, self.search_numbers): if self.search_numbers and not self._contains_numbers(b, self.search_numbers):
similarity -= 0.1 similarity -= 0.1
return max(0.0, similarity) return max(0.0, similarity)
@staticmethod @staticmethod
def _extract_numbers(text: str) -> list[str]: def _extract_numbers(text: str) -> list[str]:
"""Извлекает числа из текста."""
return [word for word in text.split() if word.isdigit()] return [word for word in text.split() if word.isdigit()]
@staticmethod @staticmethod
def _contains_numbers(text: str, numbers: list[str]) -> bool: def _contains_numbers(text: str, numbers: list[str]) -> bool:
"""Проверяет, содержит ли текст указанные числа."""
if not numbers: if not numbers:
return True return True
cleaned_text = re.sub(r'([^\s\w]|_)+', '', text) cleaned_text = re.sub(r'([^\s\w]|_)+', '', text)
text_numbers = [word for word in cleaned_text.split() if word.isdigit()] text_numbers = [word for word in cleaned_text.split() if word.isdigit()]
return any(num in text_numbers for num in numbers) return any(num in text_numbers for num in numbers)
def get_cache_dir():
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
class HowLongToBeat: class HowLongToBeat(QObject):
"""Основной класс для работы с API HowLongToBeat.""" """Основной класс для работы с API HowLongToBeat."""
searchCompleted = Signal(list)
def __init__(self, minimum_similarity: float = 0.4, auto_filter_times: bool = False, def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None):
timeout: int = 60): super().__init__(parent)
self.minimum_similarity = minimum_similarity self.minimum_similarity = minimum_similarity
self.auto_filter_times = auto_filter_times
self.http_client = HTTPClient(timeout) self.http_client = HTTPClient(timeout)
self.cache_dir = get_cache_dir()
def search(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, def _get_cache_file_path(self, game_name: str) -> str:
case_sensitive: bool = True) -> list[GameEntry] | None: """Возвращает путь к файлу кэша для заданного имени игры."""
"""Ищет игры по названию.""" safe_game_name = re.sub(r'[^\w\s-]', '', game_name).replace(' ', '_').lower()
cache_file = f"hltb_{safe_game_name}.json"
return os.path.join(self.cache_dir, cache_file)
def _load_from_cache(self, game_name: str) -> str | None:
"""Пытается загрузить данные из кэша, если они существуют."""
cache_file = self._get_cache_file_path(game_name)
try:
if os.path.exists(cache_file):
with open(cache_file, 'rb') as f:
return f.read().decode('utf-8')
except (OSError, UnicodeDecodeError):
pass
return None
def _save_to_cache(self, game_name: str, json_response: str):
"""Сохраняет данные в кэш, храня только первую игру и необходимые поля."""
cache_file = self._get_cache_file_path(game_name)
try:
# Парсим JSON и берем только первую игру
data = orjson.loads(json_response)
if data.get("data"):
first_game = data["data"][0]
simplified_data = {
"data": [{
"game_id": first_game.get("game_id", -1),
"game_name": first_game.get("game_name"),
"comp_main": first_game.get("comp_main", 0),
"comp_plus": first_game.get("comp_plus", 0),
"comp_100": first_game.get("comp_100", 0)
}]
}
with open(cache_file, 'wb') as f:
f.write(orjson.dumps(simplified_data))
except (OSError, orjson.JSONDecodeError, IndexError):
pass
def search(self, game_name: str, case_sensitive: bool = True) -> list[GameEntry] | None:
if not game_name or not game_name.strip(): if not game_name or not game_name.strip():
return None return None
# Проверяем кэш
json_response = self.http_client.search_games(game_name, search_modifiers) cached_response = self._load_from_cache(game_name)
if not json_response: if cached_response:
return None try:
cached_data = orjson.loads(cached_response)
full_json = {
"data": [
{
"game_id": game["game_id"],
"game_name": game["game_name"],
"comp_main": game["comp_main"],
"comp_plus": game["comp_plus"],
"comp_100": game["comp_100"]
}
for game in cached_data.get("data", [])
]
}
parser = ResultParser( parser = ResultParser(
game_name, game_name,
self.minimum_similarity, self.minimum_similarity,
case_sensitive, case_sensitive
self.auto_filter_times
) )
return parser.parse_results(orjson.dumps(full_json).decode('utf-8'))
return parser.parse_results(json_response) except orjson.JSONDecodeError:
pass
def search_by_id(self, game_id: int) -> GameEntry | None: # Если нет в кэше, делаем запрос
"""Ищет игру по ID.""" json_response = self.http_client.search_games(game_name)
if not game_id or game_id <= 0:
return None
game_title = self.http_client.get_game_title(game_id)
if not game_title:
return None
json_response = self.http_client.search_games(game_title)
if not json_response: if not json_response:
return None return None
# Сохраняем в кэш только первую игру
self._save_to_cache(game_name, json_response)
parser = ResultParser(
game_name,
self.minimum_similarity,
case_sensitive
)
return parser.parse_results(json_response)
parser = ResultParser(game_title, 0.0, False, self.auto_filter_times) def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None:
results = parser.parse_results(json_response, target_game_id=game_id) time_value = getattr(game_entry, time_field, None)
if time_value is None:
return None
time_seconds = int(time_value * 3600)
return format_playtime(time_seconds)
return results[0] if results else None def search_with_callback(self, game_name: str, case_sensitive: bool = True):
"""Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами."""
def search_thread():
try:
results = self.search(game_name, case_sensitive)
self.searchCompleted.emit(results if results else [])
except Exception as e:
print(f"Error in search_with_callback: {e}")
self.searchCompleted.emit([])
thread = Thread(target=search_thread)
thread.daemon = True
thread.start()

View File

@@ -111,6 +111,8 @@ class InputManager(QObject):
self.stick_value = 0 # Текущее значение стика (для плавности) self.stick_value = 0 # Текущее значение стика (для плавности)
self.dead_zone = 8000 # Мертвая зона стика self.dead_zone = 8000 # Мертвая зона стика
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
# Add variables for continuous D-pad movement # Add variables for continuous D-pad movement
self.dpad_timer = QTimer(self) self.dpad_timer = QTimer(self)
self.dpad_timer.timeout.connect(self.handle_dpad_repeat) self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
@@ -849,7 +851,7 @@ class InputManager(QObject):
return True return True
# Toggle fullscreen with F11 # Toggle fullscreen with F11
if key == Qt.Key.Key_F11: if key == Qt.Key.Key_F11 and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen) self.toggle_fullscreen.emit(not self._is_fullscreen)
return True return True
@@ -946,7 +948,7 @@ class InputManager(QObject):
continue continue
now = time.time() now = time.time()
if event.type == ecodes.EV_KEY and event.value == 1: if event.type == ecodes.EV_KEY and event.value == 1:
if event.code in BUTTONS['menu']: if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen) self.toggle_fullscreen.emit(not self._is_fullscreen)
else: else:
self.button_pressed.emit(event.code) self.button_pressed.emit(event.code)

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-06 17:56+0500\n" "POT-Creation-Date: 2025-07-14 13:16+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -563,6 +563,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-06 17:56+0500\n" "POT-Creation-Date: 2025-07-14 13:16+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -563,6 +563,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-06 17:56+0500\n" "POT-Creation-Date: 2025-07-14 13:16+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -561,6 +561,15 @@ msgstr ""
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "" msgstr ""
msgid "MAIN STORY"
msgstr ""
msgid "MAIN + SIDES"
msgstr ""
msgid "COMPLETIONIST"
msgstr ""
msgid "full" msgid "full"
msgstr "" msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-06 17:56+0500\n" "POT-Creation-Date: 2025-07-14 13:16+0500\n"
"PO-Revision-Date: 2025-07-06 17:56+0500\n" "PO-Revision-Date: 2025-07-14 13:16+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -572,6 +572,15 @@ msgstr "Последний запуск"
msgid "PLAY TIME" msgid "PLAY TIME"
msgstr "Время игры" msgstr "Время игры"
msgid "MAIN STORY"
msgstr "СЮЖЕТ"
msgid "MAIN + SIDES"
msgstr "СЮЖЕТ + ПОБОЧКИ"
msgid "COMPLETIONIST"
msgstr "100%"
msgid "full" msgid "full"
msgstr "полная" msgstr "полная"

View File

@@ -31,6 +31,7 @@ from portprotonqt.config_utils import (
) )
from portprotonqt.localization import _, get_egs_language, read_metadata_translations from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
@@ -1517,6 +1518,8 @@ class MainWindow(QMainWindow):
self._animations = {} self._animations = {}
imageLabel = QLabel() imageLabel = QLabel()
imageLabel.setFixedSize(300, 400) imageLabel.setFixedSize(300, 400)
self._detail_page_active = True
self._current_detail_page = detailPage
if cover_path: if cover_path:
def on_pixmap_ready(pixmap): def on_pixmap_ready(pixmap):
@@ -1589,7 +1592,7 @@ class MainWindow(QMainWindow):
badge_spacing = 5 badge_spacing = 5
top_y = 10 top_y = 10
badge_y_positions = [] badge_y_positions = []
badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px) badge_width = int(300 * 2/3)
# ProtonDB бейдж # ProtonDB бейдж
protondb_text = GameCard.getProtonDBText(protondb_tier) protondb_text = GameCard.getProtonDBText(protondb_tier)
@@ -1678,11 +1681,6 @@ class MainWindow(QMainWindow):
anticheat_visible = False anticheat_visible = False
# Расположение бейджей # Расположение бейджей
right_margin = 8
badge_spacing = 5
top_y = 10
badge_y_positions = []
badge_width = int(300 * 2/3)
if steam_visible: if steam_visible:
steam_x = 300 - badge_width - right_margin steam_x = 300 - badge_width - right_margin
steamLabel.move(steam_x, top_y) steamLabel.move(steam_x, top_y)
@@ -1736,22 +1734,102 @@ class MainWindow(QMainWindow):
descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE) descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE)
detailsLayout.addWidget(descLabel) detailsLayout.addWidget(descLabel)
infoLayout = QHBoxLayout() # Инициализация HowLongToBeat
infoLayout.setSpacing(10) hltb = HowLongToBeat(parent=self)
# Создаем общий layout для всей игровой информации
gameInfoLayout = QVBoxLayout()
gameInfoLayout.setSpacing(10)
# Первая строка: Last Launch и Play Time
firstRowLayout = QHBoxLayout()
firstRowLayout.setSpacing(10)
# Last Launch
lastLaunchTitle = QLabel(_("LAST LAUNCH")) lastLaunchTitle = QLabel(_("LAST LAUNCH"))
lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
lastLaunchValue = QLabel(last_launch) lastLaunchValue = QLabel(last_launch)
lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
firstRowLayout.addWidget(lastLaunchTitle)
firstRowLayout.addWidget(lastLaunchValue)
firstRowLayout.addSpacing(30)
# Play Time
playTimeTitle = QLabel(_("PLAY TIME")) playTimeTitle = QLabel(_("PLAY TIME"))
playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
playTimeValue = QLabel(formatted_playtime) playTimeValue = QLabel(formatted_playtime)
playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
infoLayout.addWidget(lastLaunchTitle) firstRowLayout.addWidget(playTimeTitle)
infoLayout.addWidget(lastLaunchValue) firstRowLayout.addWidget(playTimeValue)
infoLayout.addSpacing(30)
infoLayout.addWidget(playTimeTitle) gameInfoLayout.addLayout(firstRowLayout)
infoLayout.addWidget(playTimeValue)
detailsLayout.addLayout(infoLayout) # Создаем placeholder для второй строки (HLTB данные)
hltbLayout = QHBoxLayout()
hltbLayout.setSpacing(10)
# Время прохождения (Main Story, Main + Sides, Completionist)
def on_hltb_results(results):
if not hasattr(self, '_detail_page_active') or not self._detail_page_active:
return
if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent():
return
if results:
game = results[0] # Берем первый результат
main_story_time = hltb.format_game_time(game, "main_story")
main_extra_time = hltb.format_game_time(game, "main_extra")
completionist_time = hltb.format_game_time(game, "completionist")
# Очищаем layout перед добавлением новых элементов
while hltbLayout.count():
child = hltbLayout.takeAt(0)
if child.widget():
child.widget().deleteLater()
has_data = False
if main_story_time is not None:
mainStoryTitle = QLabel(_("MAIN STORY"))
mainStoryTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
mainStoryValue = QLabel(main_story_time)
mainStoryValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
hltbLayout.addWidget(mainStoryTitle)
hltbLayout.addWidget(mainStoryValue)
hltbLayout.addSpacing(30)
has_data = True
if main_extra_time is not None:
mainExtraTitle = QLabel(_("MAIN + SIDES"))
mainExtraTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
mainExtraValue = QLabel(main_extra_time)
mainExtraValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
hltbLayout.addWidget(mainExtraTitle)
hltbLayout.addWidget(mainExtraValue)
hltbLayout.addSpacing(30)
has_data = True
if completionist_time is not None:
completionistTitle = QLabel(_("COMPLETIONIST"))
completionistTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
completionistValue = QLabel(completionist_time)
completionistValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
hltbLayout.addWidget(completionistTitle)
hltbLayout.addWidget(completionistValue)
has_data = True
# Если есть данные, добавляем layout во вторую строку
if has_data:
gameInfoLayout.addLayout(hltbLayout)
# Подключаем сигнал searchCompleted к on_hltb_results
hltb.searchCompleted.connect(on_hltb_results)
# Запускаем поиск в фоновом потоке
hltb.search_with_callback(name, case_sensitive=False)
# Добавляем общий layout с игровой информацией
detailsLayout.addLayout(gameInfoLayout)
if controller_support: if controller_support:
cs = controller_support.lower() cs = controller_support.lower()
@@ -1769,7 +1847,7 @@ class MainWindow(QMainWindow):
detailsLayout.addStretch(1) detailsLayout.addStretch(1)
# Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки # Определяем текущий идентификатор игры по exec_line
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
return return
@@ -1870,6 +1948,8 @@ class MainWindow(QMainWindow):
def goBackDetailPage(self, page: QWidget | None) -> None: def goBackDetailPage(self, page: QWidget | None) -> None:
if page is None or page != self.stackedWidget.currentWidget(): if page is None or page != self.stackedWidget.currentWidget():
return return
self._detail_page_active = False
self._current_detail_page = None
self.stackedWidget.setCurrentIndex(0) self.stackedWidget.setCurrentIndex(0)
self.stackedWidget.removeWidget(page) self.stackedWidget.removeWidget(page)
page.deleteLater() page.deleteLater()