6 Commits
main ... egs

Author SHA1 Message Date
a6ea998535 feat: added playtime and last launch to EGS
All checks were successful
Code and build check / Check code (pull_request) Successful in 1m50s
Code and build check / Build with uv (pull_request) Successful in 54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-16 23:15:31 +05:00
5a1ac2206b fix: prevent premature game termination detection for EGS games
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-16 22:51:55 +05:00
3f3e2f8d2c feat: added import to context menu
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-16 22:47:24 +05:00
3600499d20 feat: replace steam placeholder icon to real egs icon
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-16 22:47:17 +05:00
5ade36a237 feat: added handle egs games to toggleGame
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-16 22:47:12 +05:00
96386f2815 Revert "feat: hide the games from EGS until after the workout"
This reverts commit a21705da15.

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-16 22:47:06 +05:00
7 changed files with 393 additions and 253 deletions

View File

@ -9,8 +9,6 @@
### Changed
- Удалены сборки для Fedora 40
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
### Fixed
- Дублирование обводки выделения карточек при быстром перемешении мыши

View File

@ -3,7 +3,7 @@ import shlex
import glob
import shutil
import subprocess
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QFileDialog
from PySide6.QtCore import QUrl, QPoint
from PySide6.QtGui import QDesktopServices
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
@ -53,6 +53,12 @@ class ContextMenuManager:
favorite_action = menu.addAction(_("Add to Favorites"))
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True))
if game_card.game_source == "epic":
import_action = menu.addAction(_("Import to Legendary"))
import_action.triggered.connect(
lambda: self.import_to_legendary(game_card.name, game_card.appid)
)
if game_card.game_source not in ("steam", "epic"):
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
@ -92,6 +98,75 @@ class ContextMenuManager:
menu.exec(game_card.mapToGlobal(pos))
def import_to_legendary(self, game_name, app_name):
"""
Imports an installed Epic Games Store game to Legendary using the provided app_name.
Args:
game_name: The display name of the game.
app_name: The Legendary app_name (unique identifier for the game).
"""
if not self._check_portproton():
return
# Открываем диалог для выбора папки с установленной игрой
folder_path = QFileDialog.getExistingDirectory(
self.parent,
_("Select Game Installation Folder"),
os.path.expanduser("~")
)
if not folder_path:
self.parent.statusBar().showMessage(_("No folder selected"), 3000)
return
# Путь к legendary
legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary"
)
if not os.path.exists(legendary_path):
QMessageBox.warning(
self.parent,
_("Error"),
_("Legendary executable not found at {0}").format(legendary_path)
)
return
# Формируем команду для импорта
cmd = [legendary_path, "import", app_name, folder_path]
try:
# Выполняем команду legendary import
subprocess.run(
cmd,
capture_output=True,
text=True,
check=True
)
self.parent.statusBar().showMessage(
_("Successfully imported '{0}' to Legendary").format(game_name), 3000
)
except subprocess.CalledProcessError as e:
QMessageBox.warning(
self.parent,
_("Error"),
_("Failed to import '{0}' to Legendary: {1}").format(game_name, e.stderr)
)
except FileNotFoundError:
QMessageBox.warning(
self.parent,
_("Error"),
_("Legendary executable not found")
)
except Exception as e:
QMessageBox.warning(
self.parent,
_("Error"),
_("Unexpected error during import: {0}").format(str(e))
)
def toggle_favorite(self, game_card, add: bool):
"""
Toggle the favorite status of a game and update its icon.

View File

@ -12,10 +12,36 @@ from collections.abc import Callable
from portprotonqt.localization import get_egs_language, _
from portprotonqt.logger import get_logger
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
from portprotonqt.config_utils import get_portproton_location
from portprotonqt.steam_api import get_weanticheatyet_status_async
from PySide6.QtGui import QPixmap
logger = get_logger(__name__)
def get_egs_executable(app_name: str, legendary_config_path: str) -> str | None:
"""Получает путь к исполняемому файлу EGS-игры из installed.json с использованием orjson."""
installed_json_path = os.path.join(legendary_config_path, "installed.json")
try:
with open(installed_json_path, "rb") as f:
installed_data = orjson.loads(f.read())
if app_name in installed_data:
install_path = installed_data[app_name].get("install_path", "").decode('utf-8') if isinstance(installed_data[app_name].get("install_path"), bytes) else installed_data[app_name].get("install_path", "")
executable = installed_data[app_name].get("executable", "").decode('utf-8') if isinstance(installed_data[app_name].get("executable"), bytes) else installed_data[app_name].get("executable", "")
if install_path and executable:
return os.path.join(install_path, executable)
return None
except FileNotFoundError:
logger.error(f"installed.json not found at {installed_json_path}")
return None
except orjson.JSONDecodeError:
logger.error(f"Invalid JSON in {installed_json_path}")
return None
except Exception as e:
logger.error(f"Error reading installed.json: {e}")
return None
def get_cache_dir() -> Path:
"""Returns the path to the cache directory, creating it if necessary."""
xdg_cache_home = os.getenv(
@ -281,6 +307,7 @@ def get_egs_game_description_async(
thread = threading.Thread(target=fetch_description, daemon=True)
thread.start()
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
"""
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
@ -326,6 +353,7 @@ def run_legendary_list_async(legendary_path: str, callback: Callable[[list | Non
def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]], None], downloader, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
"""
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
Читает статистику времени игры и последнего запуска из файла statistics.
"""
logger.debug("Starting to load Epic Games Store games")
games: list[tuple] = []
@ -334,6 +362,14 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
cache_file = cache_dir / "legendary_games.json"
cache_ttl = 3600 # Cache TTL in seconds (1 hour)
# Путь к файлу statistics
portproton_location = get_portproton_location()
if portproton_location is None:
logger.error("PortProton location is not set, cannot locate statistics file")
statistics_file = ""
else:
statistics_file = os.path.join(portproton_location, "data", "tmp", "statistics")
if not os.path.exists(legendary_path):
logger.info("Legendary binary not found, downloading...")
def on_legendary_downloaded(result):
@ -345,7 +381,7 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
logger.error(f"Failed to make legendary binary executable: {e}")
callback(games) # Return empty games list on failure
return
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message, statistics_file)
else:
logger.error("Failed to download legendary binary")
callback(games) # Return empty games list on failure
@ -356,9 +392,9 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
callback(games)
return
else:
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message, statistics_file)
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None], statistics_file: str):
"""
Продолжает процесс загрузки EGS игр, либо из кэша, либо через legendary CLI.
"""
@ -410,6 +446,33 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
callback(final_games)
return
# Получаем путь к .exe для извлечения имени
game_exe = get_egs_executable(app_name, os.path.dirname(legendary_path))
exe_name = ""
if game_exe:
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
# Читаем статистику из файла statistics
playtime_seconds = 0
formatted_playtime = ""
last_launch = _("Never")
last_launch_timestamp = 0
if exe_name and os.path.exists(statistics_file):
try:
playtime_data = parse_playtime_file(statistics_file)
matching_key = next(
(key for key in playtime_data if os.path.basename(key).split('.')[0] == exe_name),
None
)
if matching_key:
playtime_seconds = playtime_data[matching_key]
formatted_playtime = format_playtime(playtime_seconds)
except Exception as e:
logger.error(f"Failed to parse playtime data for {app_name}: {e}")
if exe_name:
last_launch = get_last_launch(exe_name) or _("Never")
last_launch_timestamp = get_last_launch_timestamp(exe_name)
metadata_file = metadata_dir / f"{app_name}.json"
cover_url = ""
try:
@ -430,7 +493,6 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
final_description = api_description or _("No description available")
def on_cover_loaded(pixmap: QPixmap):
from portprotonqt.steam_api import get_weanticheatyet_status_async
def on_anticheat_status(status: str):
nonlocal pending_images
with results_lock:
@ -441,12 +503,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
app_name,
f"legendary:launch:{app_name}",
"",
_("Never"),
"",
last_launch, # Время последнего запуска
formatted_playtime, # Форматированное время игры
"",
status or "",
0,
0,
last_launch_timestamp, # Временная метка последнего запуска
playtime_seconds, # Время игры в секундах
"epic"
)
pending_images -= 1

View File

@ -26,7 +26,6 @@ class GameCard(QFrame):
removeFromSteamRequested = Signal(str, str) # name, exec_line
openGameFolderRequested = Signal(str, str) # name, exec_line
hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool)
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
@ -68,14 +67,14 @@ class GameCard(QFrame):
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
# Параметры анимации обводки
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._borderWidth = 2
self._gradientAngle = 0.0
self._hovered = False
self._focused = False
# Анимации
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
self.thickness_anim.setDuration(300)
self.gradient_anim = None
self.pulse_anim = None
@ -171,7 +170,7 @@ class GameCard(QFrame):
self.steamLabel.setVisible(self.steam_visible)
# Epic Games Store бейдж
egs_icon = self.theme_manager.get_icon("steam")
egs_icon = self.theme_manager.get_icon("epic_games")
self.egsLabel = ClickableLabel(
"Epic Games",
icon=egs_icon,
@ -449,8 +448,10 @@ class GameCard(QFrame):
if self._hovered or self._focused:
center = self.rect().center()
gradient = QConicalGradient(center, self._gradientAngle)
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
gradient.setColorAt(stop["position"], QColor(stop["color"]))
gradient.setColorAt(0, QColor("#00fff5"))
gradient.setColorAt(0.33, QColor("#FF5733"))
gradient.setColorAt(0.66, QColor("#9B59B6"))
gradient.setColorAt(1, QColor("#00fff5"))
pen.setBrush(QBrush(gradient))
else:
pen.setColor(QColor(0, 0, 0, 0))
@ -467,25 +468,23 @@ class GameCard(QFrame):
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
self.pulse_anim.setDuration(800)
self.pulse_anim.setLoopCount(0)
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0, 8)
self.pulse_anim.setKeyValueAt(0.5, 10)
self.pulse_anim.setKeyValueAt(1, 8)
self.pulse_anim.start()
def enterEvent(self, event):
self._hovered = True
self.hoverChanged.emit(self.name, True)
self.setFocus(Qt.FocusReason.MouseFocusReason)
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
self.thickness_anim.setEndValue(8)
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
@ -493,9 +492,9 @@ class GameCard(QFrame):
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setDuration(3000)
self.gradient_anim.setStartValue(360)
self.gradient_anim.setEndValue(0)
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
@ -516,24 +515,19 @@ class GameCard(QFrame):
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.start()
self.setBorderWidth(2)
self.update()
super().leaveEvent(event)
def focusInEvent(self, event):
if not self._hovered:
self._focused = True
self.focusChanged.emit(self.name, True)
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
self.thickness_anim.setEndValue(12)
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
@ -541,9 +535,9 @@ class GameCard(QFrame):
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setDuration(3000)
self.gradient_anim.setStartValue(360)
self.gradient_anim.setEndValue(0)
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
@ -551,23 +545,22 @@ class GameCard(QFrame):
def focusOutEvent(self, event):
self._focused = False
self.focusChanged.emit(self.name, False)
if not self._hovered:
if not self._hovered: # Сохраняем анимацию, если есть наведение
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.setEndValue(2)
self.thickness_anim.start()
super().focusOutEvent(event)
def mousePressEvent(self, event):

View File

@ -17,7 +17,7 @@ from portprotonqt.system_overlay import SystemOverlay
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
from portprotonqt.egs_api import load_egs_games_async
from portprotonqt.egs_api import load_egs_games_async, get_egs_executable
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
from portprotonqt.config_utils import (
@ -56,7 +56,6 @@ class MainWindow(QMainWindow):
self.current_exec_line = None
self.currentDetailPage = None
self.current_play_button = None
self.current_focused_card = None
self.pending_games = []
self.game_card_cache = {}
self.pending_images = {}
@ -243,39 +242,10 @@ class MainWindow(QMainWindow):
self.updateGameGrid()
self.progress_bar.setVisible(False)
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Обработчик сигнала focusChanged от GameCard."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
# Если карточка получила фокус
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем текущую hovered карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card = None
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
self.current_focused_card = card
else:
# Если карточка потеряла фокус
if self.current_focused_card == card:
self.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Обработчик сигнала hoverChanged от GameCard."""
card_key = None
# Находим ключ карточки по имени игры
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
@ -288,14 +258,10 @@ class MainWindow(QMainWindow):
if is_hovered:
# Если мышь наведена на карточку
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем предыдущую hovered карточку
# Сбрасываем предыдущую выделенную карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card.leaveEvent(None) # Принудительно вызываем leaveEvent
self.current_hovered_card = card
else:
# Если мышь покинула карточку
@ -322,19 +288,25 @@ class MainWindow(QMainWindow):
self.update_status_message.emit
)
elif display_filter == "favorites":
def on_all_games(portproton_games, steam_games):
games = [game for game in portproton_games + steam_games if game[0] in favorites]
def on_all_games(portproton_games, steam_games, epic_games):
games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites]
self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async(
lambda sg: on_all_games(pg, sg)
lambda sg: load_egs_games_async(
self.legendary_path,
lambda eg: on_all_games(pg, sg, eg),
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
)
)
else:
def on_all_games(portproton_games, steam_games):
def on_all_games(portproton_games, steam_games, epic_games):
seen = set()
games = []
for game in portproton_games + steam_games:
for game in portproton_games + steam_games + epic_games:
# Уникальный ключ: имя + exec_line
key = (game[0], game[4])
if key not in seen:
@ -343,7 +315,13 @@ class MainWindow(QMainWindow):
self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async(
lambda sg: on_all_games(pg, sg)
lambda sg: load_egs_games_async(
self.legendary_path,
lambda eg: on_all_games(pg, sg, eg),
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
)
)
return []
@ -743,7 +721,6 @@ class MainWindow(QMainWindow):
context_menu_manager=self.context_menu_manager
)
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
# Подключаем сигналы контекстного меню
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
@ -992,7 +969,7 @@ class MainWindow(QMainWindow):
# 3. Games display_filter
self.filter_keys = ["all", "steam", "portproton", "favorites", "epic"]
self.filter_labels = [_("all"), "steam", "portproton", _("favorites")]
self.filter_labels = [_("all"), "steam", "portproton", _("favorites"), "epic games store"]
self.gamesDisplayCombo = QComboBox()
self.gamesDisplayCombo.addItems(self.filter_labels)
self.gamesDisplayCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
@ -1074,6 +1051,37 @@ class MainWindow(QMainWindow):
self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
# 8. Legendary Authentication
self.legendaryAuthButton = AutoSizeButton(
_("Open Legendary Login"),
icon=self.theme_manager.get_icon("login")
)
self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin)
self.legendaryAuthTitle = QLabel(_("Legendary Authentication:"))
self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton)
self.legendaryCodeEdit = QLineEdit()
self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
self.submitCodeButton = AutoSizeButton(
_("Submit Code"),
icon=self.theme_manager.get_icon("save")
)
self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
formLayout.addRow(QLabel(""), self.submitCodeButton)
layout.addLayout(formLayout)
# Кнопки
@ -1124,6 +1132,37 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to open Legendary login page: {e}")
self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
def submitLegendaryCode(self):
"""Submits the Legendary authorization code using the legendary CLI."""
auth_code = self.legendaryCodeEdit.text().strip()
if not auth_code:
QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
return
try:
# Execute legendary auth command
result = subprocess.run(
[self.legendary_path, "auth", "--code", auth_code],
capture_output=True,
text=True,
check=True
)
logger.info("Legendary authentication successful: %s", result.stdout)
self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
self.legendaryCodeEdit.clear()
# Reload Epic Games Store games after successful authentication
self.games = self.loadGames()
self.updateGameGrid()
except subprocess.CalledProcessError as e:
logger.error("Legendary authentication failed: %s", e.stderr)
self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
except FileNotFoundError:
logger.error("Legendary executable not found at %s", self.legendary_path)
self.statusBar().showMessage(_("Legendary executable not found"), 5000)
except Exception as e:
logger.error("Unexpected error during Legendary authentication: %s", str(e))
self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
def resetSettings(self):
"""Сбрасывает настройки и перезапускает приложение."""
reply = QMessageBox.question(
@ -1519,7 +1558,7 @@ class MainWindow(QMainWindow):
steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}")))
# Epic Games Store бейдж
egs_icon = self.theme_manager.get_icon("steam")
egs_icon = self.theme_manager.get_icon("epic_games")
egsLabel = ClickableLabel(
"Epic Games",
icon=egs_icon,
@ -1816,11 +1855,110 @@ class MainWindow(QMainWindow):
self.target_exe = None
def toggleGame(self, exec_line, button=None):
# Обработка Steam-игр
if exec_line.startswith("steam://"):
url = QUrl(exec_line)
QDesktopServices.openUrl(url)
return
# Обработка EGS-игр
if exec_line.startswith("legendary:launch:"):
app_name = exec_line.split("legendary:launch:")[1]
# Получаем путь к .exe из installed.json
game_exe = get_egs_executable(app_name, self.legendary_config_path)
if not game_exe or not os.path.exists(game_exe):
QMessageBox.warning(self, _("Error"), _("Executable not found for EGS game: {0}").format(app_name))
return
current_exe = os.path.basename(game_exe)
if self.game_processes and self.target_exe is not None and self.target_exe != current_exe:
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
return
# Обновляем кнопку
update_button = button if button is not None else self.current_play_button
self.current_running_button = update_button
self.target_exe = current_exe
exe_name = os.path.splitext(current_exe)[0]
# Проверяем, запущена ли игра
if self.game_processes and self.target_exe == current_exe:
# Останавливаем игру
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
child.kill()
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except psutil.NoSuchProcess:
pass
self.game_processes = []
if update_button:
update_button.setText(_("Play"))
icon = self.theme_manager.get_icon("play")
if isinstance(icon, str):
icon = QIcon(icon)
elif icon is None:
icon = QIcon()
update_button.setIcon(icon)
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
self.current_running_button = None
self.target_exe = None
self._gameLaunched = False
else:
# Запускаем игру через PortProton
env_vars = os.environ.copy()
env_vars['START_FROM_STEAM'] = '1'
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
wrapper = "flatpak run ru.linux_gaming.PortProton"
if self.portproton_location is not None and ".var" not in self.portproton_location:
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
wrapper = start_sh
cmd = [wrapper, game_exe]
try:
process = subprocess.Popen(cmd, env=env_vars, shell=False, preexec_fn=os.setsid)
self.game_processes.append(process)
save_last_launch(exe_name, datetime.now())
if update_button:
update_button.setText(_("Launching"))
icon = self.theme_manager.get_icon("stop")
if isinstance(icon, str):
icon = QIcon(icon)
elif icon is None:
icon = QIcon()
update_button.setIcon(icon)
# Delay disabling gamepad handling
if hasattr(self, 'input_manager'):
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(500)
except Exception as e:
logger.error(f"Failed to launch EGS game {app_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
return
# Обработка PortProton-игр
entry_exec_split = shlex.split(exec_line)
if entry_exec_split[0] == "env":
if len(entry_exec_split) < 3:
@ -1834,18 +1972,20 @@ class MainWindow(QMainWindow):
file_to_check = entry_exec_split[3]
else:
file_to_check = entry_exec_split[0]
if not os.path.exists(file_to_check):
QMessageBox.warning(self, _("Error"), _("File not found: {0}").format(file_to_check))
return
current_exe = os.path.basename(file_to_check)
current_exe = os.path.basename(file_to_check)
if self.game_processes and self.target_exe is not None and self.target_exe != current_exe:
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
return
# Обновляем кнопку
update_button = button if button is not None else self.current_play_button
# Если игра уже запущена для этого exe останавливаем её по нажатию кнопки
# Если игра уже запущена для этого exe останавливаем её
if self.game_processes and self.target_exe == current_exe:
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
@ -1898,6 +2038,15 @@ class MainWindow(QMainWindow):
env_vars['START_FROM_STEAM'] = '1'
elif entry_exec_split[0] == "flatpak":
env_vars['START_FROM_STEAM'] = '1'
return
# Запускаем игру
self.current_running_button = update_button
self.target_exe = current_exe
exe_name = os.path.splitext(current_exe)[0]
env_vars = os.environ.copy()
env_vars['START_FROM_STEAM'] = '1'
try:
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
self.game_processes.append(process)
save_last_launch(exe_name, datetime.now())
@ -1913,6 +2062,9 @@ class MainWindow(QMainWindow):
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(500)
except Exception as e:
logger.error(f"Failed to launch game {exe_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
def closeEvent(self, event):
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""

View File

@ -8,76 +8,6 @@ current_theme_name = read_theme_from_config()
favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60
GAME_CARD_ANIMATION = {
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях.
"default_border_width": 2,
# Ширина обводки при наведении курсора.
# Увеличивает толщину рамки, когда курсор находится над карточкой.
# Значение в пикселях.
"hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
# Увеличивает толщину рамки, когда карточка в фокусе.
# Значение в пикселях.
"focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации.
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
# Значение в пикселях.
"pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации.
# Определяет максимальную толщину рамки при пульсации.
# Значение в пикселях.
"pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
# Влияет на скорость перехода от одной ширины обводки к другой.
# Значение в миллисекундах.
"thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации.
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
# Значение в миллисекундах.
"pulse_anim_duration": 800,
# Длительность анимации вращения градиента.
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
# Значение в миллисекундах.
"gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах).
# Определяет начальную точку вращения градиента при старте анимации.
"gradient_start_angle": 360,
# Конечный угол градиента (в градусах).
# Определяет конечную точку вращения градиента.
# Значение 0 означает полный поворот на 360 градусов.
"gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
"thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
# Влияет на "чувство" возврата к исходной ширине обводки.
"thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки.
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex.
# Влияет на внешний вид обводки при наведении или фокусе.
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
]
}
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
MAIN_WINDOW_HEADER_STYLE = """
QFrame {

View File

@ -8,76 +8,6 @@ current_theme_name = read_theme_from_config()
favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60
GAME_CARD_ANIMATION = {
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях.
"default_border_width": 2,
# Ширина обводки при наведении курсора.
# Увеличивает толщину рамки, когда курсор находится над карточкой.
# Значение в пикселях.
"hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
# Увеличивает толщину рамки, когда карточка в фокусе.
# Значение в пикселях.
"focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации.
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
# Значение в пикселях.
"pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации.
# Определяет максимальную толщину рамки при пульсации.
# Значение в пикселях.
"pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
# Влияет на скорость перехода от одной ширины обводки к другой.
# Значение в миллисекундах.
"thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации.
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
# Значение в миллисекундах.
"pulse_anim_duration": 800,
# Длительность анимации вращения градиента.
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
# Значение в миллисекундах.
"gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах).
# Определяет начальную точку вращения градиента при старте анимации.
"gradient_start_angle": 360,
# Конечный угол градиента (в градусах).
# Определяет конечную точку вращения градиента.
# Значение 0 означает полный поворот на 360 градусов.
"gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
"thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
# Влияет на "чувство" возврата к исходной ширине обводки.
"thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки.
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex.
# Влияет на внешний вид обводки при наведении или фокусе.
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
]
}
CONTEXT_MENU_STYLE = """
QMenu {
background: #282a33;;