Some checks failed
Code check / Check code (push) Failing after 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
879 lines
41 KiB
Python
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)
|