From 7a5b467490a219db49e1eb5332d52b1d1d51895f Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Fri, 19 Dec 2025 16:28:50 +0500 Subject: [PATCH] feat(autoinstalls): added detail page Signed-off-by: Boris Yumankulov --- portprotonqt/detail_pages.py | 878 +++++++++++++++++++++++++++++++++ portprotonqt/main_window.py | 628 +++-------------------- portprotonqt/portproton_api.py | 131 ++++- 3 files changed, 1061 insertions(+), 576 deletions(-) create mode 100644 portprotonqt/detail_pages.py diff --git a/portprotonqt/detail_pages.py b/portprotonqt/detail_pages.py new file mode 100644 index 0000000..a31cff2 --- /dev/null +++ b/portprotonqt/detail_pages.py @@ -0,0 +1,878 @@ +import os +import shlex +from PySide6.QtWidgets import (QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QLabel, QHBoxLayout, QWidget) +from PySide6.QtCore import Qt, QUrl, QTimer, QAbstractAnimation +from PySide6.QtGui import QColor, QDesktopServices +from portprotonqt.image_utils import load_pixmap_async, round_corners +from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton +from portprotonqt.game_card import GameCard +from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter +from portprotonqt.localization import _ +from portprotonqt.logger import get_logger +from portprotonqt.portproton_api import PortProtonAPI +from portprotonqt.downloader import Downloader +from portprotonqt.animations import DetailPageAnimations + +logger = get_logger(__name__) + + +class DetailPageManager: + """Manages detail pages for games.""" + + def __init__(self, main_window): + self.main_window = main_window + self._detail_page_active = False + self._current_detail_page = None + self._exit_animation_in_progress = False + self._animations = {} + self.portproton_api = PortProtonAPI(Downloader(max_workers=4)) + + def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", + last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""): + """Open detailed game information page showing all game stats, playtime and settings.""" + detailPage = QWidget() + imageLabel = QLabel() + imageLabel.setFixedSize(300, 450) + self._detail_page_active = True + self._current_detail_page = detailPage + # Store the source tab index (Library is typically index 0) + self._return_to_tab_index = 0 # Library tab + + # Function to load image and restore effect + def load_image_and_restore_effect(): + # Check if detail page still exists and is valid + if not detailPage or detailPage.isHidden() or detailPage.parent() is None: + logger.warning("Detail page is None, hidden, or no longer valid, skipping image load") + return + + try: + detailPage.setWindowOpacity(1.0) + except RuntimeError: + logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set") + return + + if cover_path: + def on_pixmap_ready(pixmap): + # Check if detail page still exists and is valid + if not detailPage or detailPage.isHidden() or detailPage.parent() is None: + logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update") + return + try: + rounded = round_corners(pixmap, 10) + imageLabel.setPixmap(rounded) + logger.debug("Pixmap set for imageLabel") + + def on_palette_ready(palette): + # Check if detail page still exists and is valid + if not detailPage or detailPage.isHidden() or detailPage.parent() is None: + logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update") + return + try: + dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette] + stops = ",\n".join( + [f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))] + ) + detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops)) + detailPage.update() + logger.debug("Stylesheet updated with palette") + except RuntimeError: + logger.warning("Detail page already deleted, skipping palette stylesheet update") + self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready) + except RuntimeError: + logger.warning("Detail page already deleted, skipping pixmap update") + load_pixmap_async(cover_path, 300, 450, on_pixmap_ready) + else: + try: + detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE) + detailPage.update() + except RuntimeError: + logger.warning("Detail page already deleted, skipping no-cover stylesheet update") + + def cleanup_animation(): + if detailPage in self._animations: + del self._animations[detailPage] + + mainLayout = QVBoxLayout(detailPage) + mainLayout.setContentsMargins(30, 30, 30, 30) + mainLayout.setSpacing(20) + + backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back")) + backButton.setFixedWidth(100) + backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE) + backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage)) + mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft) + + contentFrame = QFrame() + contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE) + contentFrameLayout = QHBoxLayout(contentFrame) + contentFrameLayout.setContentsMargins(20, 20, 20, 20) + contentFrameLayout.setSpacing(40) + mainLayout.addWidget(contentFrame) + + # Cover (at left) + coverFrame = QFrame() + coverFrame.setFixedSize(300, 450) + coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE) + shadow = QGraphicsDropShadowEffect(coverFrame) + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 0, 0, 200)) + shadow.setOffset(0, 0) + coverFrame.setGraphicsEffect(shadow) + coverLayout = QVBoxLayout(coverFrame) + coverLayout.setContentsMargins(0, 0, 0, 0) + + coverLayout.addWidget(imageLabel) + + # Favorite icon + favoriteLabelCover = ClickableLabel(coverFrame) + favoriteLabelCover.setFixedSize(*self.main_window.theme.favoriteLabelSize) + favoriteLabelCover.setStyleSheet(self.main_window.theme.FAVORITE_LABEL_STYLE) + favorites = read_favorites() + if name in favorites: + favoriteLabelCover.setText("★") + else: + favoriteLabelCover.setText("☆") + favoriteLabelCover.clicked.connect(lambda: self.toggleFavoriteInDetailPage(name, favoriteLabelCover)) + favoriteLabelCover.move(8, 8) + favoriteLabelCover.raise_() + + # Add badges (ProtonDB, Steam, PortProton, WeAntiCheatYet) + display_filter = read_display_filter() + steam_visible = (str(game_source).lower() == "steam" and display_filter in ("all", "favorites")) + egs_visible = (str(game_source).lower() == "epic" and display_filter in ("all", "favorites")) + portproton_visible = (str(game_source).lower() == "portproton" and display_filter in ("all", "favorites")) + right_margin = 8 + badge_spacing = 5 + top_y = 10 + badge_y_positions = [] + badge_width = int(300 * 2/3) + + # ProtonDB badge + protondb_text = GameCard.getProtonDBText(protondb_tier) + if protondb_text: + icon_filename = GameCard.getProtonDBIconFilename(protondb_tier) + icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name) + protondbLabel = ClickableLabel( + protondb_text, + icon=icon, + parent=coverFrame, + icon_size=16, + icon_space=3, + ) + protondbLabel.setStyleSheet(self.main_window.theme.get_protondb_badge_style(protondb_tier)) + protondbLabel.setFixedWidth(badge_width) + protondbLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://www.protondb.com/app/{appid}"))) + protondb_visible = True + else: + protondbLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3) + protondbLabel.setFixedWidth(badge_width) + protondbLabel.setVisible(False) + protondb_visible = False + + # Steam badge + steam_icon = self.main_window.theme_manager.get_icon("steam") + steamLabel = ClickableLabel( + "Steam", + icon=steam_icon, + parent=coverFrame, + icon_size=16, + icon_space=5, + ) + steamLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE) + steamLabel.setFixedWidth(badge_width) + steamLabel.setVisible(steam_visible) + steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}"))) + + # Epic Games Store badge + egs_icon = self.main_window.theme_manager.get_icon("epic_games") + egsLabel = ClickableLabel( + "Epic Games", + icon=egs_icon, + parent=coverFrame, + icon_size=16, + icon_space=5, + change_cursor=False + ) + egsLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE) + egsLabel.setFixedWidth(badge_width) + egsLabel.setVisible(egs_visible) + + # PortProton badge + portproton_icon = self.main_window.theme_manager.get_icon("portproton") + portprotonLabel = ClickableLabel( + "PortProton", + icon=portproton_icon, + parent=coverFrame, + icon_size=16, + icon_space=5, + ) + portprotonLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE) + portprotonLabel.setFixedWidth(badge_width) + portprotonLabel.setVisible(portproton_visible) + portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name)) + + # WeAntiCheatYet badge + anticheat_text = GameCard.getAntiCheatText(anticheat_status) + if anticheat_text: + icon_filename = GameCard.getAntiCheatIconFilename(anticheat_status) + icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name) + anticheatLabel = ClickableLabel( + anticheat_text, + icon=icon, + parent=coverFrame, + icon_size=16, + icon_space=3, + ) + anticheatLabel.setStyleSheet(self.main_window.theme.get_anticheat_badge_style(anticheat_status)) + anticheatLabel.setFixedWidth(badge_width) + anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}"))) + anticheat_visible = True + else: + anticheatLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3) + anticheatLabel.setFixedWidth(badge_width) + anticheatLabel.setVisible(False) + anticheat_visible = False + + # Position badges + if steam_visible: + steam_x = 300 - badge_width - right_margin + steamLabel.move(steam_x, top_y) + badge_y_positions.append(top_y + steamLabel.height()) + if egs_visible: + egs_x = 300 - badge_width - right_margin + egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y + egsLabel.move(egs_x, egs_y) + badge_y_positions.append(egs_y + egsLabel.height()) + if portproton_visible: + portproton_x = 300 - badge_width - right_margin + portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y + portprotonLabel.move(portproton_x, portproton_y) + badge_y_positions.append(portproton_y + portprotonLabel.height()) + if protondb_visible: + protondb_x = 300 - badge_width - right_margin + protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y + protondbLabel.move(protondb_x, protondb_y) + badge_y_positions.append(protondb_y + protondbLabel.height()) + if anticheat_visible: + anticheat_x = 300 - badge_width - right_margin + anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y + anticheatLabel.move(anticheat_x, anticheat_y) + + anticheatLabel.raise_() + protondbLabel.raise_() + portprotonLabel.raise_() + egsLabel.raise_() + steamLabel.raise_() + + contentFrameLayout.addWidget(coverFrame) + + # Game details (at right) + detailsWidget = QWidget() + detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE) + detailsLayout = QVBoxLayout(detailsWidget) + detailsLayout.setContentsMargins(20, 20, 20, 20) + detailsLayout.setSpacing(15) + + titleLabel = QLabel(name) + titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE) + detailsLayout.addWidget(titleLabel) + + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE) + detailsLayout.addWidget(line) + + descLabel = QLabel(description) + descLabel.setWordWrap(True) + descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE) + detailsLayout.addWidget(descLabel) + + # Initialize HowLongToBeat + hltb = HowLongToBeat(parent=self.main_window) + + # Create layout for all game info + gameInfoLayout = QVBoxLayout() + gameInfoLayout.setSpacing(10) + + # First row: Last Launch and Play Time + firstRowLayout = QHBoxLayout() + firstRowLayout.setSpacing(10) + + # Last Launch + lastLaunchTitle = QLabel(_("LAST LAUNCH")) + lastLaunchTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE) + lastLaunchValue = QLabel(last_launch) + lastLaunchValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE) + firstRowLayout.addWidget(lastLaunchTitle) + firstRowLayout.addWidget(lastLaunchValue) + firstRowLayout.addSpacing(30) + + # Play Time + playTimeTitle = QLabel(_("PLAY TIME")) + playTimeTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE) + playTimeValue = QLabel(formatted_playtime) + playTimeValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE) + firstRowLayout.addWidget(playTimeTitle) + firstRowLayout.addWidget(playTimeValue) + + gameInfoLayout.addLayout(firstRowLayout) + + # Create placeholder for second row (HLTB data) + hltbLayout = QHBoxLayout() + hltbLayout.setSpacing(10) + + # Completion time (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 + # Additional check: make sure the detail page in the stacked widget is still our current detail page + if self.main_window.stackedWidget.currentWidget() != self._current_detail_page and self._current_detail_page not in [self.main_window.stackedWidget.widget(i) for i in range(self.main_window.stackedWidget.count())]: + return + + if results: + game = results[0] # Take first result + 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") + + # Clear layout before adding new elements + def clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + sublayout = item.layout() + if widget: + widget.deleteLater() + elif sublayout: + clear_layout(sublayout) + + clear_layout(hltbLayout) + + has_data = False + + if main_story_time is not None: + try: + mainStoryTitle = QLabel(_("MAIN STORY")) + mainStoryTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE) + mainStoryValue = QLabel(main_story_time) + mainStoryValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE) + hltbLayout.addWidget(mainStoryTitle) + hltbLayout.addWidget(mainStoryValue) + hltbLayout.addSpacing(30) + has_data = True + except RuntimeError: + logger.warning("Detail page already deleted, skipping main story time update") + + if main_extra_time is not None: + try: + mainExtraTitle = QLabel(_("MAIN + SIDES")) + mainExtraTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE) + mainExtraValue = QLabel(main_extra_time) + mainExtraValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE) + hltbLayout.addWidget(mainExtraTitle) + hltbLayout.addWidget(mainExtraValue) + hltbLayout.addSpacing(30) + has_data = True + except RuntimeError: + logger.warning("Detail page already deleted, skipping main extra time update") + + if completionist_time is not None: + try: + completionistTitle = QLabel(_("COMPLETIONIST")) + completionistTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE) + completionistValue = QLabel(completionist_time) + completionistValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE) + hltbLayout.addWidget(completionistTitle) + hltbLayout.addWidget(completionistValue) + has_data = True + except RuntimeError: + logger.warning("Detail page already deleted, skipping completionist time update") + + # If there's data, add the layout to the second row + if has_data: + gameInfoLayout.addLayout(hltbLayout) + + # Connect searchCompleted signal to on_hltb_results + hltb.searchCompleted.connect(on_hltb_results) + + # Start search in background thread + hltb.search_with_callback(name, case_sensitive=False) + + # Add the game info layout + detailsLayout.addLayout(gameInfoLayout) + + if controller_support: + cs = controller_support.lower() + translated_cs = "" + if cs == "full": + translated_cs = _("full") + elif cs == "partial": + translated_cs = _("partial") + elif cs == "none": + translated_cs = _("none") + gamepadSupportLabel = QLabel(_("Gamepad Support: {0}").format(translated_cs)) + gamepadSupportLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + gamepadSupportLabel.setStyleSheet(self.main_window.theme.GAMEPAD_SUPPORT_VALUE_STYLE) + detailsLayout.addWidget(gamepadSupportLabel, alignment=Qt.AlignmentFlag.AlignCenter) + + detailsLayout.addStretch(1) + + # Determine current game ID from exec_line + entry_exec_split = shlex.split(exec_line) + if not entry_exec_split: + return + + if entry_exec_split[0] == "env": + file_to_check = entry_exec_split[2] if len(entry_exec_split) >= 3 else None + elif entry_exec_split[0] == "flatpak": + file_to_check = entry_exec_split[3] if len(entry_exec_split) >= 4 else None + else: + file_to_check = entry_exec_split[0] + current_exe = os.path.basename(file_to_check) if file_to_check else None + + buttons_layout = QHBoxLayout() + if self.main_window.target_exe is not None and current_exe == self.main_window.target_exe: + playButton = AutoSizeButton(_("Stop"), icon=self.main_window.theme_manager.get_icon("stop")) + else: + playButton = AutoSizeButton(_("Play"), icon=self.main_window.theme_manager.get_icon("play")) + + playButton.setFixedSize(120, 40) + playButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE) + playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + playButton.clicked.connect(lambda: self.main_window.toggleGame(exec_line, playButton)) + buttons_layout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft) + + # Settings button + settings_icon = self.main_window.theme_manager.get_icon("settings") + settings_button = AutoSizeButton(_("Settings"), icon=settings_icon) + settings_button.setFixedSize(120, 40) + settings_button.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE) + settings_button.clicked.connect(lambda: self.main_window.open_exe_settings(file_to_check)) + buttons_layout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft) + buttons_layout.addStretch() + detailsLayout.addLayout(buttons_layout) + + contentFrameLayout.addWidget(detailsWidget) + mainLayout.addStretch() + + self.main_window.stackedWidget.addWidget(detailPage) + self.main_window.stackedWidget.setCurrentWidget(detailPage) + self.main_window.currentDetailPage = detailPage + self.main_window.current_exec_line = exec_line + self.main_window.current_play_button = playButton + + # Animation + detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme) + detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation) + + # Update page reference + self.main_window.currentDetailPage = detailPage + + original_load = load_image_and_restore_effect + + def enhanced_load(): + original_load() + QTimer.singleShot(50, try_set_focus) + + def try_set_focus(): + if not (playButton and not playButton.isHidden()): + return + + # Ensure page is active + self.main_window.stackedWidget.setCurrentWidget(detailPage) + detailPage.setFocus(Qt.FocusReason.OtherFocusReason) + playButton.setFocus(Qt.FocusReason.OtherFocusReason) + playButton.update() + detailPage.raise_() + self.main_window.activateWindow() + + if playButton.hasFocus(): + logger.debug("Play button successfully received focus") + else: + logger.debug("Retrying focus...") + QTimer.singleShot(20, retry_focus) + + def retry_focus(): + if not (playButton and not playButton.isHidden() and not playButton.hasFocus()): + return + + # Process events to ensure UI state is updated + self.main_window.processEvents() + self.main_window.activateWindow() + self.main_window.stackedWidget.setCurrentWidget(detailPage) + detailPage.raise_() + playButton.setFocus(Qt.FocusReason.OtherFocusReason) + playButton.update() + + if not playButton.hasFocus(): + logger.debug("Final retry...") + playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + playButton.setFocus(Qt.FocusReason.OtherFocusReason) + self.main_window.processEvents() + + if playButton.hasFocus(): + logger.debug("Play button received focus after final retry") + else: + logger.debug("Play button still doesn't have focus") + + detail_animations.animate_detail_page( + detailPage, + enhanced_load, + cleanup_animation + ) + + def openAutoInstallDetailPage(self, name, description, cover_path=None, exec_line="", game_source=""): + """Open minimal detail page for auto-install games with name, description, cover, and install button.""" + detailPage = QWidget() + imageLabel = QLabel() + imageLabel.setFixedSize(300, 450) + self._detail_page_active = True + self._current_detail_page = detailPage + # Store the source tab index (Auto Install is typically index 1) + self._return_to_tab_index = 1 # Auto Install tab + + # Try to get the description from downloaded metadata for richer content + script_name = "" + if exec_line and exec_line.startswith("autoinstall:"): + script_name = exec_line[11:].lstrip(':').strip() + + if script_name: + # Get localized description based on current UI language + from portprotonqt.localization import _ + # Import locale module to detect current locale + import locale + try: + current_locale = locale.getlocale()[0] or 'en' + except Exception: + current_locale = 'en' + lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en' + + metadata_description = self.portproton_api.get_autoinstall_description(script_name, lang_code) + if metadata_description: + description = metadata_description + + # Function to load image and restore effect + def load_image_and_restore_effect(): + # Check if detail page still exists and is valid + if not detailPage or detailPage.isHidden() or detailPage.parent() is None: + logger.warning("Detail page is None, hidden, or no longer valid, skipping image load") + return + + try: + detailPage.setWindowOpacity(1.0) + except RuntimeError: + logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set") + return + + if cover_path: + def on_pixmap_ready(pixmap): + # Check if detail page still exists and is valid + if not detailPage or detailPage.isHidden() or detailPage.parent() is None: + logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update") + return + try: + rounded = round_corners(pixmap, 10) + imageLabel.setPixmap(rounded) + logger.debug("Pixmap set for imageLabel") + + def on_palette_ready(palette): + # Check if detail page still exists and is valid + if not detailPage or detailPage.isHidden() or detailPage.parent() is None: + logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update") + return + try: + dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette] + stops = ",\n".join( + [f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))] + ) + detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops)) + detailPage.update() + logger.debug("Stylesheet updated with palette") + except RuntimeError: + logger.warning("Detail page already deleted, skipping palette stylesheet update") + self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready) + except RuntimeError: + logger.warning("Detail page already deleted, skipping pixmap update") + load_pixmap_async(cover_path, 300, 450, on_pixmap_ready) + else: + try: + detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE) + detailPage.update() + except RuntimeError: + logger.warning("Detail page already deleted, skipping no-cover stylesheet update") + + def cleanup_animation(): + if detailPage in self._animations: + del self._animations[detailPage] + + mainLayout = QVBoxLayout(detailPage) + mainLayout.setContentsMargins(30, 30, 30, 30) + mainLayout.setSpacing(20) + + backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back")) + backButton.setFixedWidth(100) + backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE) + backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage)) + mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft) + + contentFrame = QFrame() + contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE) + contentFrameLayout = QHBoxLayout(contentFrame) + contentFrameLayout.setContentsMargins(20, 20, 20, 20) + contentFrameLayout.setSpacing(40) + mainLayout.addWidget(contentFrame) + + # Cover (at left) + coverFrame = QFrame() + coverFrame.setFixedSize(300, 450) + coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE) + shadow = QGraphicsDropShadowEffect(coverFrame) + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 0, 0, 200)) + shadow.setOffset(0, 0) + coverFrame.setGraphicsEffect(shadow) + coverLayout = QVBoxLayout(coverFrame) + coverLayout.setContentsMargins(0, 0, 0, 0) + + coverLayout.addWidget(imageLabel) + + # No favorite icon for auto-install games + + # No badges for auto-install detail page + contentFrameLayout.addWidget(coverFrame) + + # Game details (at right) - minimal version without time info + detailsWidget = QWidget() + detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE) + detailsLayout = QVBoxLayout(detailsWidget) + detailsLayout.setContentsMargins(20, 20, 20, 20) + detailsLayout.setSpacing(15) + + titleLabel = QLabel(name) + titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE) + detailsLayout.addWidget(titleLabel) + + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE) + detailsLayout.addWidget(line) + + descLabel = QLabel(description) + descLabel.setWordWrap(True) + descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE) + detailsLayout.addWidget(descLabel) + + # No HLTB data, playtime, or launch info for auto install + detailsLayout.addStretch(1) + + buttons_layout = QHBoxLayout() + + # The script_name was already extracted at the beginning of the function + # Determine if game is already installed based on whether .desktop files exist for the script + game_installed = self.is_autoinstall_game_installed(script_name, name) if script_name else False + + install_button_text = _("Reinstall") if game_installed else _("Install") + # Use update icon for reinstall, save icon for initial install + install_button_icon = self.main_window.theme_manager.get_icon("update" if game_installed else "save") + + installButton = AutoSizeButton(install_button_text, icon=install_button_icon) + installButton.setFixedSize(120, 40) + installButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE) + installButton.clicked.connect(lambda: self.main_window.launch_autoinstall(script_name)) + buttons_layout.addWidget(installButton, alignment=Qt.AlignmentFlag.AlignLeft) + buttons_layout.addStretch() + detailsLayout.addLayout(buttons_layout) + + contentFrameLayout.addWidget(detailsWidget) + mainLayout.addStretch() + + self.main_window.stackedWidget.addWidget(detailPage) + self.main_window.stackedWidget.setCurrentWidget(detailPage) + self.main_window.currentDetailPage = detailPage + + # Animation + detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme) + detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation) + + # Update page reference + self.main_window.currentDetailPage = detailPage + + original_load = load_image_and_restore_effect + + def enhanced_load(): + original_load() + QTimer.singleShot(50, try_set_focus) + + def try_set_focus(): + if not (installButton and not installButton.isHidden()): + return + + # Ensure page is active + self.main_window.stackedWidget.setCurrentWidget(detailPage) + detailPage.setFocus(Qt.FocusReason.OtherFocusReason) + installButton.setFocus(Qt.FocusReason.OtherFocusReason) + installButton.update() + detailPage.raise_() + self.main_window.activateWindow() + + if installButton.hasFocus(): + logger.debug("Install button successfully received focus") + else: + logger.debug("Retrying focus...") + QTimer.singleShot(20, retry_focus) + + def retry_focus(): + if not (installButton and not installButton.isHidden() and not installButton.hasFocus()): + return + + # Process events to ensure UI state is updated + self.main_window.processEvents() + self.main_window.activateWindow() + self.main_window.stackedWidget.setCurrentWidget(detailPage) + detailPage.raise_() + installButton.setFocus(Qt.FocusReason.OtherFocusReason) + installButton.update() + + if not installButton.hasFocus(): + logger.debug("Final retry...") + installButton.setFocusPolicy(Qt.FocusReason.StrongFocus) + installButton.setFocus(Qt.FocusReason.OtherFocusReason) + self.main_window.processEvents() + + if installButton.hasFocus(): + logger.debug("Install button received focus after final retry") + else: + logger.debug("Install button still doesn't have focus") + + detail_animations.animate_detail_page( + detailPage, + enhanced_load, + cleanup_animation + ) + + def is_autoinstall_game_installed(self, script_name, game_name): + """Check if an auto-install game is already installed by looking for .desktop files.""" + if not self.main_window.portproton_location: + return False + + # Look for .desktop files that might match this game/script + try: + desktop_files = os.listdir(self.main_window.portproton_location) + for file in desktop_files: + if file.endswith('.desktop'): + # Check if the desktop file contains references to the script or game name + try: + with open(os.path.join(self.main_window.portproton_location, file), encoding='utf-8') as f: + content = f.read() + if script_name.lower() in content.lower() or game_name.lower() in content.lower(): + return True + except (OSError, UnicodeDecodeError): + continue + except (OSError, AttributeError): + pass + return False + + def toggleFavoriteInDetailPage(self, game_name, label): + favorites = read_favorites() + if game_name in favorites: + favorites.remove(game_name) + label.setText("☆") + else: + favorites.append(game_name) + label.setText("★") + save_favorites(favorites) + self.main_window.game_library_manager.update_game_grid() + + def goBackDetailPage(self, page: QWidget | None) -> None: + if page is None or page != self.main_window.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False): + return + self._exit_animation_in_progress = True + self._detail_page_active = False + self._current_detail_page = None + + def cleanup(): + """Helper function to clean up after animation.""" + try: + # Stop and clean up any existing animations for this page + if hasattr(self, '_animations') and page in self._animations: + try: + animation = self._animations[page] + if isinstance(animation, QAbstractAnimation): + if animation.state() == QAbstractAnimation.State.Running: + animation.stop() + # Since animation is set to delete when stopped, we don't manually delete it + del self._animations[page] + except (KeyError, RuntimeError): + pass # Animation already deleted or not found + + # Ensure page is still valid before trying to remove it + # Check if page is still in the stacked widget by iterating through all widgets + page_found = False + for i in range(self.main_window.stackedWidget.count()): + if self.main_window.stackedWidget.widget(i) is page: + page_found = True + break + + if page_found: + # Remove the detail page widget + self.main_window.stackedWidget.removeWidget(page) + # Go back to the tab where the detail page was opened from + return_tab_index = getattr(self, '_return_to_tab_index', 0) # Default to library tab + self.main_window.stackedWidget.setCurrentIndex(return_tab_index) + + # Ensure proper layout update after returning to the tab + # This is important when a refresh happened while detail page was open + if return_tab_index == 0: # Library tab + if hasattr(self.main_window, 'game_library_manager'): + QTimer.singleShot(10, lambda: self.main_window.game_library_manager.update_game_grid()) + elif return_tab_index == 1: # Auto Install tab + # Force update of the auto install container layout + if hasattr(self.main_window, 'autoInstallContainer'): + QTimer.singleShot(10, lambda: self.main_window.autoInstallContainer.updateGeometry()) + if hasattr(self.main_window, 'autoInstallContainerLayout'): + QTimer.singleShot(15, lambda: self.main_window.autoInstallContainerLayout.update()) + else: + logger.debug("Page not found in stacked widget, may have been removed already") + + # Clear references to avoid dangling references + if hasattr(self.main_window, 'currentDetailPage'): + self.main_window.currentDetailPage = None + if hasattr(self.main_window, 'current_exec_line'): + self.main_window.current_exec_line = None + if hasattr(self.main_window, 'current_play_button'): + self.main_window.current_play_button = None + + self._exit_animation_in_progress = False + except RuntimeError: + # Widget was already deleted, which is expected after deleteLater() + logger.debug("Detail page already deleted during cleanup") + self._exit_animation_in_progress = False + except Exception as e: + logger.error(f"Unexpected error in cleanup: {e}", exc_info=True) + self._exit_animation_in_progress = False + + # Start exit animation + try: + # Check if the page is still valid before starting animation + if page and not page.isHidden() and page.parent() is not None: + detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme) + detail_animations.animate_detail_page_exit(page, cleanup) + else: + logger.warning("Detail page not valid, bypassing animation and cleaning up directly") + self._exit_animation_in_progress = False + cleanup() + except Exception as e: + logger.error(f"Error starting exit animation: {e}", exc_info=True) + self._exit_animation_in_progress = False + cleanup() # Fallback to cleanup if animation fails + + def open_portproton_forum_topic(self, name): + result = self.portproton_api.get_forum_topic_slug(name) + base_url = "https://linux-gaming.ru/" + if result.startswith("search?q="): + url = QUrl(f"{base_url}{result}") + else: + url = QUrl(f"{base_url}t/{result}") + QDesktopServices.openUrl(url) diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index eff7a2f..9cf7100 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -11,6 +11,7 @@ from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, from portprotonqt.game_card import GameCard from portprotonqt.animations import DetailPageAnimations from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout +from portprotonqt.detail_pages import DetailPageManager from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.input_manager import InputManager, MainWindowProtocol from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit @@ -78,6 +79,9 @@ class MainWindow(QMainWindow): self.game_library_manager = GameLibraryManager(self, self.theme, None) + # Initialize detail page manager + self.detail_page_manager = DetailPageManager(self) + self.context_menu_manager = ContextMenuManager( self, self.portproton_location, @@ -1507,6 +1511,7 @@ class MainWindow(QMainWindow): card.cover_path = local_path load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded) + # Загрузка игр def on_autoinstall_games_loaded(games: list[tuple]): self.autoInstallProgress.setVisible(False) @@ -1525,13 +1530,12 @@ class MainWindow(QMainWindow): if not games: return - # Callback для запуска установки + # Callback для открытия детальной страницы автоустановки def select_callback(name, description, cover_path, appid, exec_line, controller_support, *_): if not exec_line or not exec_line.startswith("autoinstall:"): logger.warning(f"Invalid exec_line for autoinstall: {exec_line}") return - script_name = exec_line[11:].lstrip(':').strip() - self.launch_autoinstall(script_name) + self.detail_page_manager.openAutoInstallDetailPage(name, description, cover_path, exec_line, "portproton") # Создаём карточки for game_tuple in games: @@ -1565,15 +1569,41 @@ class MainWindow(QMainWindow): self.allAutoInstallCards.append(card) self.autoInstallContainerLayout.addWidget(card) - # Загружаем недостающие обложки + # Загружаем недостающие обложки и метаданные for game_tuple in games: - name, _, cover_path, *_ , game_source, exe_name = game_tuple + name, description, cover_path, *_ , game_source, exe_name = game_tuple if not cover_path: self.portproton_api.download_autoinstall_cover_async( exe_name, timeout=5, callback=lambda path, ex=exe_name: on_autoinstall_cover_updated(ex, path) ) + # Always try to download metadata for better descriptions + # Update the card when metadata is downloaded + def metadata_callback(path): + if path and os.path.exists(path): # If metadata file was successfully downloaded + try: + # Read the translated metadata using the existing function + language_code = get_egs_language() # Use the same language detection as elsewhere + translations = read_metadata_translations(path, language_code) + + # Update the card with the new name if available + if exe_name in self.autoInstallGameCards: + card = self.autoInstallGameCards[exe_name] + if translations and 'name' in translations and translations['name'] and translations['name'] != _('Unknown Game'): + # Update the card's internal name reference + card.name = translations['name'] + # Update the display label + if hasattr(card, 'nameLabel') and card.nameLabel: + card.nameLabel.setText(translations['name']) + except Exception as e: + logger.error(f"Error updating card metadata for {exe_name}: {e}") + + self.portproton_api.download_autoinstall_metadata_async( + exe_name, timeout=5, + callback=metadata_callback + ) + self.autoInstallContainer.updateGeometry() self.autoInstallScrollArea.updateGeometry() self.filterAutoInstallGames() @@ -2397,7 +2427,7 @@ class MainWindow(QMainWindow): self.stackedWidget.removeWidget(self.currentDetailPage) self.currentDetailPage.deleteLater() self.currentDetailPage = None - self.openGameDetailPage(*current_game) + self.detail_page_manager.openGameDetailPage(*current_game) self.settingsDebounceTimer.start() @@ -2595,507 +2625,12 @@ class MainWindow(QMainWindow): dialog.exec() def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""): - detailPage = QWidget() - if not hasattr(self, '_animations'): - self._animations = {} - imageLabel = QLabel() - imageLabel.setFixedSize(300, 450) - self._detail_page_active = True - self._current_detail_page = detailPage - - # Функция загрузки изображения и обновления стилей - def load_image_and_restore_effect(): - # Check if detail page still exists and is valid - if not detailPage or detailPage.isHidden() or detailPage.parent() is None: - logger.warning("Detail page is None, hidden, or no longer valid, skipping image load") - return - - try: - detailPage.setWindowOpacity(1.0) - except RuntimeError: - logger.warning("Detail page already deleted, skipping opacity set") - return - - if cover_path: - def on_pixmap_ready(pixmap): - # Check if detail page still exists and is valid - if not detailPage or detailPage.isHidden() or detailPage.parent() is None: - logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update") - return - try: - rounded = round_corners(pixmap, 10) - imageLabel.setPixmap(rounded) - logger.debug("Pixmap set for imageLabel") - - def on_palette_ready(palette): - # Check if detail page still exists and is valid - if not detailPage or detailPage.isHidden() or detailPage.parent() is None: - logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update") - return - try: - dark_palette = [self.darkenColor(color, factor=200) for color in palette] - stops = ",\n".join( - [f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))] - ) - detailPage.setStyleSheet(self.theme.detail_page_style(stops)) - detailPage.update() - logger.debug("Stylesheet updated with palette") - except RuntimeError: - logger.warning("Detail page already deleted, skipping palette stylesheet update") - self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready) - except RuntimeError: - logger.warning("Detail page already deleted, skipping pixmap update") - load_pixmap_async(cover_path, 300, 450, on_pixmap_ready) - else: - try: - detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE) - detailPage.update() - except RuntimeError: - logger.warning("Detail page already deleted, skipping no-cover stylesheet update") - - def cleanup_animation(): - if detailPage in self._animations: - del self._animations[detailPage] - - mainLayout = QVBoxLayout(detailPage) - mainLayout.setContentsMargins(30, 30, 30, 30) - mainLayout.setSpacing(20) - - backButton = AutoSizeButton(_("Back"), icon=self.theme_manager.get_icon("back")) - backButton.setFixedWidth(100) - backButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE) - backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage)) - mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft) - - contentFrame = QFrame() - contentFrame.setStyleSheet(self.theme.DETAIL_CONTENT_FRAME_STYLE) - contentFrameLayout = QHBoxLayout(contentFrame) - contentFrameLayout.setContentsMargins(20, 20, 20, 20) - contentFrameLayout.setSpacing(40) - mainLayout.addWidget(contentFrame) - - # Обложка (слева) - coverFrame = QFrame() - coverFrame.setFixedSize(300, 450) - coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE) - shadow = QGraphicsDropShadowEffect(coverFrame) - shadow.setBlurRadius(20) - shadow.setColor(QColor(0, 0, 0, 200)) - shadow.setOffset(0, 0) - coverFrame.setGraphicsEffect(shadow) - coverLayout = QVBoxLayout(coverFrame) - coverLayout.setContentsMargins(0, 0, 0, 0) - - coverLayout.addWidget(imageLabel) - - # Значок избранного - favoriteLabelCover = ClickableLabel(coverFrame) - favoriteLabelCover.setFixedSize(*self.theme.favoriteLabelSize) - favoriteLabelCover.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE) - favorites = read_favorites() - if name in favorites: - favoriteLabelCover.setText("★") - else: - favoriteLabelCover.setText("☆") - favoriteLabelCover.clicked.connect(lambda: self.toggleFavoriteInDetailPage(name, favoriteLabelCover)) - favoriteLabelCover.move(8, 8) - favoriteLabelCover.raise_() - - # Добавляем бейджи (ProtonDB, Steam, PortProton, WeAntiCheatYet) - display_filter = read_display_filter() - steam_visible = (str(game_source).lower() == "steam" and display_filter in ("all", "favorites")) - egs_visible = (str(game_source).lower() == "epic" and display_filter in ("all", "favorites")) - portproton_visible = (str(game_source).lower() == "portproton" and display_filter in ("all", "favorites")) - right_margin = 8 - badge_spacing = 5 - top_y = 10 - badge_y_positions = [] - badge_width = int(300 * 2/3) - - # ProtonDB бейдж - protondb_text = GameCard.getProtonDBText(protondb_tier) - if protondb_text: - icon_filename = GameCard.getProtonDBIconFilename(protondb_tier) - icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name) - protondbLabel = ClickableLabel( - protondb_text, - icon=icon, - parent=coverFrame, - icon_size=16, - icon_space=3, - ) - protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) - protondbLabel.setFixedWidth(badge_width) - protondbLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://www.protondb.com/app/{appid}"))) - protondb_visible = True - else: - protondbLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3) - protondbLabel.setFixedWidth(badge_width) - protondbLabel.setVisible(False) - protondb_visible = False - - # Steam бейдж - steam_icon = self.theme_manager.get_icon("steam") - steamLabel = ClickableLabel( - "Steam", - icon=steam_icon, - parent=coverFrame, - icon_size=16, - icon_space=5, - ) - steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) - steamLabel.setFixedWidth(badge_width) - steamLabel.setVisible(steam_visible) - steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}"))) - - # Epic Games Store бейдж - egs_icon = self.theme_manager.get_icon("epic_games") - egsLabel = ClickableLabel( - "Epic Games", - icon=egs_icon, - parent=coverFrame, - icon_size=16, - icon_space=5, - change_cursor=False - ) - egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) - egsLabel.setFixedWidth(badge_width) - egsLabel.setVisible(egs_visible) - - # PortProton badge - portproton_icon = self.theme_manager.get_icon("portproton") - portprotonLabel = ClickableLabel( - "PortProton", - icon=portproton_icon, - parent=coverFrame, - icon_size=16, - icon_space=5, - ) - portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) - portprotonLabel.setFixedWidth(badge_width) - portprotonLabel.setVisible(portproton_visible) - portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name)) - - # WeAntiCheatYet бейдж - anticheat_text = GameCard.getAntiCheatText(anticheat_status) - if anticheat_text: - icon_filename = GameCard.getAntiCheatIconFilename(anticheat_status) - icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name) - anticheatLabel = ClickableLabel( - anticheat_text, - icon=icon, - parent=coverFrame, - icon_size=16, - icon_space=3, - ) - anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status)) - anticheatLabel.setFixedWidth(badge_width) - anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}"))) - anticheat_visible = True - else: - anticheatLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3) - anticheatLabel.setFixedWidth(badge_width) - anticheatLabel.setVisible(False) - anticheat_visible = False - - # Расположение бейджей - if steam_visible: - steam_x = 300 - badge_width - right_margin - steamLabel.move(steam_x, top_y) - badge_y_positions.append(top_y + steamLabel.height()) - if egs_visible: - egs_x = 300 - badge_width - right_margin - egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y - egsLabel.move(egs_x, egs_y) - badge_y_positions.append(egs_y + egsLabel.height()) - if portproton_visible: - portproton_x = 300 - badge_width - right_margin - portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y - portprotonLabel.move(portproton_x, portproton_y) - badge_y_positions.append(portproton_y + portprotonLabel.height()) - if protondb_visible: - protondb_x = 300 - badge_width - right_margin - protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y - protondbLabel.move(protondb_x, protondb_y) - badge_y_positions.append(protondb_y + protondbLabel.height()) - if anticheat_visible: - anticheat_x = 300 - badge_width - right_margin - anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y - anticheatLabel.move(anticheat_x, anticheat_y) - - anticheatLabel.raise_() - protondbLabel.raise_() - portprotonLabel.raise_() - egsLabel.raise_() - steamLabel.raise_() - - contentFrameLayout.addWidget(coverFrame) - - # Детали игры (справа) - detailsWidget = QWidget() - detailsWidget.setStyleSheet(self.theme.DETAILS_WIDGET_STYLE) - detailsLayout = QVBoxLayout(detailsWidget) - detailsLayout.setContentsMargins(20, 20, 20, 20) - detailsLayout.setSpacing(15) - - titleLabel = QLabel(name) - titleLabel.setStyleSheet(self.theme.DETAIL_PAGE_TITLE_STYLE) - detailsLayout.addWidget(titleLabel) - - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setStyleSheet(self.theme.DETAIL_PAGE_LINE_STYLE) - detailsLayout.addWidget(line) - - descLabel = QLabel(description) - descLabel.setWordWrap(True) - descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE) - detailsLayout.addWidget(descLabel) - - # Инициализация HowLongToBeat - 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.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) - lastLaunchValue = QLabel(last_launch) - lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) - firstRowLayout.addWidget(lastLaunchTitle) - firstRowLayout.addWidget(lastLaunchValue) - firstRowLayout.addSpacing(30) - - # Play Time - playTimeTitle = QLabel(_("PLAY TIME")) - playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) - playTimeValue = QLabel(formatted_playtime) - playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) - firstRowLayout.addWidget(playTimeTitle) - firstRowLayout.addWidget(playTimeValue) - - gameInfoLayout.addLayout(firstRowLayout) - - # Создаем 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 - # Additional check: make sure the detail page in the stacked widget is still our current detail page - if self.stackedWidget.currentWidget() != self._current_detail_page and self._current_detail_page not in [self.stackedWidget.widget(i) for i in range(self.stackedWidget.count())]: - 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 перед добавлением новых элементов - def clear_layout(layout): - while layout.count(): - item = layout.takeAt(0) - widget = item.widget() - sublayout = item.layout() - if widget: - widget.deleteLater() - elif sublayout: - clear_layout(sublayout) - - clear_layout(hltbLayout) - - has_data = False - - if main_story_time is not None: - try: - 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 - except RuntimeError: - logger.warning("Detail page already deleted, skipping main story time update") - - if main_extra_time is not None: - try: - 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 - except RuntimeError: - logger.warning("Detail page already deleted, skipping main extra time update") - - if completionist_time is not None: - try: - 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 - except RuntimeError: - logger.warning("Detail page already deleted, skipping completionist time update") - - # Если есть данные, добавляем 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: - cs = controller_support.lower() - translated_cs = "" - if cs == "full": - translated_cs = _("full") - elif cs == "partial": - translated_cs = _("partial") - elif cs == "none": - translated_cs = _("none") - gamepadSupportLabel = QLabel(_("Gamepad Support: {0}").format(translated_cs)) - gamepadSupportLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) - gamepadSupportLabel.setStyleSheet(self.theme.GAMEPAD_SUPPORT_VALUE_STYLE) - detailsLayout.addWidget(gamepadSupportLabel, alignment=Qt.AlignmentFlag.AlignCenter) - - detailsLayout.addStretch(1) - - # Определяем текущий идентификатор игры по exec_line - entry_exec_split = shlex.split(exec_line) - if not entry_exec_split: - return - - if entry_exec_split[0] == "env": - file_to_check = entry_exec_split[2] if len(entry_exec_split) >= 3 else None - elif entry_exec_split[0] == "flatpak": - file_to_check = entry_exec_split[3] if len(entry_exec_split) >= 4 else None - else: - file_to_check = entry_exec_split[0] - current_exe = os.path.basename(file_to_check) if file_to_check else None - - buttons_layout = QHBoxLayout() - if self.target_exe is not None and current_exe == self.target_exe: - playButton = AutoSizeButton(_("Stop"), icon=self.theme_manager.get_icon("stop")) - else: - playButton = AutoSizeButton(_("Play"), icon=self.theme_manager.get_icon("play")) - - playButton.setFixedSize(120, 40) - playButton.setStyleSheet(self.theme.PLAY_BUTTON_STYLE) - playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton)) - buttons_layout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft) - - # Settings button - settings_icon = self.theme_manager.get_icon("settings") - settings_button = AutoSizeButton(_("Settings"), icon=settings_icon) - settings_button.setFixedSize(120, 40) - settings_button.setStyleSheet(self.theme.PLAY_BUTTON_STYLE) - settings_button.clicked.connect(lambda: self.open_exe_settings(file_to_check)) - buttons_layout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft) - buttons_layout.addStretch() - detailsLayout.addLayout(buttons_layout) - - contentFrameLayout.addWidget(detailsWidget) - mainLayout.addStretch() - - self.stackedWidget.addWidget(detailPage) - self.stackedWidget.setCurrentWidget(detailPage) - self.currentDetailPage = detailPage - self.current_exec_line = exec_line - self.current_play_button = playButton - - # Анимация - self.detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation) - - # Update page reference - self.currentDetailPage = detailPage - - original_load = load_image_and_restore_effect - - def enhanced_load(): - original_load() - QTimer.singleShot(50, try_set_focus) - - def try_set_focus(): - if not (playButton and not playButton.isHidden()): - return - - # Ensure page is active - self.stackedWidget.setCurrentWidget(detailPage) - detailPage.setFocus(Qt.FocusReason.OtherFocusReason) - playButton.setFocus(Qt.FocusReason.OtherFocusReason) - playButton.update() - detailPage.raise_() - self.activateWindow() - - if playButton.hasFocus(): - logger.debug("Play button successfully received focus") - else: - logger.debug("Retrying focus...") - QTimer.singleShot(20, retry_focus) - - def retry_focus(): - if not (playButton and not playButton.isHidden() and not playButton.hasFocus()): - return - - QApplication.processEvents() - self.activateWindow() - self.stackedWidget.setCurrentWidget(detailPage) - detailPage.raise_() - playButton.setFocus(Qt.FocusReason.OtherFocusReason) - playButton.update() - - if not playButton.hasFocus(): - logger.debug("Final retry...") - playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - playButton.setFocus(Qt.FocusReason.OtherFocusReason) - QApplication.processEvents() - - if playButton.hasFocus(): - logger.debug("Play button received focus after final retry") - else: - logger.debug("Play button still doesn't have focus") - - self.detail_animations.animate_detail_page( - detailPage, - enhanced_load, - cleanup_animation + # Delegate to the detail page manager + self.detail_page_manager.openGameDetailPage( + name, description, cover_path, appid, exec_line, controller_support, + last_launch, formatted_playtime, protondb_tier, game_source, anticheat_status ) - def toggleFavoriteInDetailPage(self, game_name, label): - favorites = read_favorites() - if game_name in favorites: - favorites.remove(game_name) - label.setText("☆") - else: - favorites.append(game_name) - label.setText("★") - save_favorites(favorites) - self.game_library_manager.update_game_grid() def activateFocusedWidget(self): """Activate the currently focused widget.""" @@ -3139,73 +2674,6 @@ class MainWindow(QMainWindow): break parent = parent.parent() - def goBackDetailPage(self, page: QWidget | None) -> None: - if page is None or page != self.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False): - return - self._exit_animation_in_progress = True - self._detail_page_active = False - self._current_detail_page = None - - def cleanup(): - """Helper function to clean up after animation.""" - try: - # Stop and clean up any existing animations for this page - if hasattr(self, '_animations') and page in self._animations: - try: - animation = self._animations[page] - if isinstance(animation, QAbstractAnimation): - if animation.state() == QAbstractAnimation.State.Running: - animation.stop() - # Since animation is set to delete when stopped, we don't manually delete it - del self._animations[page] - except (KeyError, RuntimeError): - pass # Animation already deleted or not found - - # Ensure page is still valid before trying to remove it - # Check if page is still in the stacked widget by iterating through all widgets - page_found = False - for i in range(self.stackedWidget.count()): - if self.stackedWidget.widget(i) is page: - page_found = True - break - - if page_found: - self.stackedWidget.setCurrentIndex(0) - self.stackedWidget.removeWidget(page) - page.deleteLater() - else: - logger.debug("Page not found in stacked widget, may have been removed already") - - # Clear references to avoid dangling references - if hasattr(self, 'currentDetailPage'): - self.currentDetailPage = None - if hasattr(self, 'current_exec_line'): - self.current_exec_line = None - if hasattr(self, 'current_play_button'): - self.current_play_button = None - - self._exit_animation_in_progress = False - except RuntimeError: - # Widget was already deleted, which is expected after deleteLater() - logger.debug("Detail page already deleted during cleanup") - self._exit_animation_in_progress = False - except Exception as e: - logger.error(f"Unexpected error in cleanup: {e}", exc_info=True) - self._exit_animation_in_progress = False - - # Start exit animation - try: - # Check if the page is still valid before starting animation - if page and not page.isHidden() and page.parent() is not None: - self.detail_animations.animate_detail_page_exit(page, cleanup) - else: - logger.warning("Detail page not valid, bypassing animation and cleaning up directly") - self._exit_animation_in_progress = False - cleanup() - except Exception as e: - logger.error(f"Error starting exit animation: {e}", exc_info=True) - self._exit_animation_in_progress = False - cleanup() # Fallback to cleanup if animation fails def is_target_exe_running(self): """Проверяет, запущен ли процесс с именем self.target_exe через psutil.""" @@ -3533,3 +3001,19 @@ class MainWindow(QMainWindow): if timer: timer.deleteLater() setattr(self, tname, None) + + def open_portproton_forum_topic(self, name): + """Bridge method to detail page manager.""" + return self.detail_page_manager.open_portproton_forum_topic(name) + + def goBackDetailPage(self, page): + """Bridge method to detail page manager.""" + # Store the current tab index before going back to ensure we return to the same tab + current_tab_index = self.stackedWidget.currentIndex() + result = self.detail_page_manager.goBackDetailPage(page) + # The detail page manager will handle the navigation properly + return result + + def toggleFavoriteInDetailPage(self, game_name, label): + """Bridge method to detail page manager.""" + return self.detail_page_manager.toggleFavoriteInDetailPage(game_name, label) diff --git a/portprotonqt/portproton_api.py b/portprotonqt/portproton_api.py index 5386ff3..1082a9e 100644 --- a/portprotonqt/portproton_api.py +++ b/portprotonqt/portproton_api.py @@ -124,8 +124,12 @@ class PortProtonAPI: ) break - if self._check_file_exists(metadata_url, timeout): - local_metadata_path = os.path.join(game_dir, "metadata.txt") + # Check if metadata already exists locally before attempting download + local_metadata_path = os.path.join(game_dir, "metadata.txt") + if os.path.exists(local_metadata_path): + logger.debug(f"Metadata already exists locally for {exe_name}: {local_metadata_path}") + results["metadata"] = local_metadata_path + elif self._check_file_exists(metadata_url, timeout): pending_downloads += 1 self.downloader.download_async( metadata_url, @@ -152,9 +156,17 @@ class PortProtonAPI: except FileExistsError: pass - cover_url = f"{self.base_url}/{exe_name}/cover.png" local_cover_path = os.path.join(user_game_folder, "cover.png") + # Check if the cover already exists locally before attempting download + if os.path.exists(local_cover_path): + logger.debug(f"Async autoinstall cover already exists locally for {exe_name}: {local_cover_path}") + if callback: + callback(local_cover_path) + return + + cover_url = f"{self.base_url}/{exe_name}/cover.png" + def on_cover_downloaded(local_path: str | None): if local_path: logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}") @@ -175,6 +187,102 @@ class PortProtonAPI: if callback: callback(None) + def download_autoinstall_metadata_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None: + """Download autoinstall metadata.txt file.""" + xdg_data_home = os.getenv("XDG_DATA_HOME", + os.path.join(os.path.expanduser("~"), ".local", "share")) + autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") + user_game_folder = os.path.join(autoinstall_root, exe_name) + + if not os.path.isdir(user_game_folder): + try: + os.makedirs(user_game_folder, exist_ok=True) + except FileExistsError: + pass + + local_metadata_path = os.path.join(user_game_folder, "metadata.txt") + + # Check if the file already exists locally before attempting download + if os.path.exists(local_metadata_path): + logger.debug(f"Async autoinstall metadata already exists locally for {exe_name}: {local_metadata_path}") + if callback: + callback(local_metadata_path) + return + + metadata_url = f"{self.base_url}/{exe_name}/metadata.txt" + + def on_metadata_downloaded(local_path: str | None): + if local_path: + logger.info(f"Async autoinstall metadata downloaded for {exe_name}: {local_path}") + else: + logger.debug(f"No autoinstall metadata downloaded for {exe_name}") + if callback: + callback(local_path) + + if self._check_file_exists(metadata_url, timeout): + self.downloader.download_async( + metadata_url, + local_metadata_path, + timeout=timeout, + callback=on_metadata_downloaded + ) + else: + logger.debug(f"No autoinstall metadata found for {exe_name}") + if callback: + callback(None) + + def get_autoinstall_description(self, exe_name: str, lang_code: str = "en") -> str | None: + """Read description from downloaded metadata.txt file for autoinstall game. + + Args: + exe_name: The executable name/script name + lang_code: Language code ("en" or "ru" for description) + + Returns: + Description string or None if not found + """ + xdg_data_home = os.getenv("XDG_DATA_HOME", + os.path.join(os.path.expanduser("~"), ".local", "share")) + autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") + metadata_path = os.path.join(autoinstall_root, exe_name, "metadata.txt") + + if not os.path.exists(metadata_path): + return None + + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Parse the metadata content to extract description + # Format: description_en=... or description_ru=... + if lang_code == "ru": + pattern = r'^description_ru=(.*)$' + else: + pattern = r'^description_en=(.*)$' + + import re + match = re.search(pattern, content, re.MULTILINE) + if match: + description = match.group(1).strip() + # Handle potential quoted strings + if description.startswith('"') and description.endswith('"'): + description = description[1:-1] + return description + else: + # Try fallback to the other language if the requested one is not found + fallback_lang = "ru" if lang_code == "en" else "en" + fallback_pattern = r'^description_{}=(.*)$'.format(fallback_lang) + fallback_match = re.search(fallback_pattern, content, re.MULTILINE) + if fallback_match: + description = fallback_match.group(1).strip() + if description.startswith('"') and description.endswith('"'): + description = description[1:-1] + return description + except Exception as e: + logger.error(f"Error reading metadata for {exe_name}: {e}") + + return None + def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]: """Extract display_name from # name comment and exe_name from autoinstall bash script.""" try: @@ -362,8 +470,23 @@ class PortProtonAPI: if not cover_path: logger.debug(f"No local cover found for autoinstall {exe_name}") + # Try to get the description from metadata file + description = "" + # Look for metadata in the expected location + try: + import locale + current_locale = locale.getlocale()[0] or 'en' + except: + current_locale = 'en' + lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en' + + # Try to read description from downloaded metadata + metadata_description = self.api.get_autoinstall_description(exe_name, lang_code) + if metadata_description: + description = metadata_description + game_tuple = ( - display_name, "", cover_path, "", f"autoinstall:{script_name}", + display_name, description, cover_path, "", f"autoinstall:{script_name}", "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name ) games.append(game_tuple)