feat(autoinstalls): added detail page
Some checks failed
Code check / Check code (push) Failing after 1m8s

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-12-19 16:28:50 +05:00
parent 6f82068864
commit 7a5b467490
3 changed files with 1061 additions and 576 deletions

View File

@@ -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)