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)