feat(autoinstalls): added detail page
Some checks failed
Code check / Check code (push) Failing after 1m8s
Some checks failed
Code check / Check code (push) Failing after 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
878
portprotonqt/detail_pages.py
Normal file
878
portprotonqt/detail_pages.py
Normal 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)
|
||||
Reference in New Issue
Block a user