Files
PortProtonQt/portprotonqt/detail_pages.py
Boris Yumankulov 7a5b467490
Some checks failed
Code check / Check code (push) Failing after 1m8s
feat(autoinstalls): added detail page
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-19 16:28:50 +05:00

879 lines
41 KiB
Python

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)