|
| 1 | +#include "gamedetailspage.h" |
| 2 | +#include "glassbutton.h" |
| 3 | +#include "utils/colors.h" |
| 4 | + |
| 5 | +#include <QJsonDocument> |
| 6 | +#include <QJsonObject> |
| 7 | +#include <QJsonArray> |
| 8 | +#include <QKeyEvent> |
| 9 | +#include <QPainter> |
| 10 | +#include <QPainterPath> |
| 11 | +#include <QEventLoop> |
| 12 | +#include <QScrollBar> |
| 13 | + |
| 14 | +GameDetailsPage::GameDetailsPage(QNetworkAccessManager* networkManager, QWidget* parent) |
| 15 | + : QWidget(parent), m_networkManager(networkManager) |
| 16 | +{ |
| 17 | + setFocusPolicy(Qt::StrongFocus); // For Escape key |
| 18 | + buildUI(); |
| 19 | +} |
| 20 | + |
| 21 | +void GameDetailsPage::buildUI() { |
| 22 | + QVBoxLayout* mainLayout = new QVBoxLayout(this); |
| 23 | + mainLayout->setContentsMargins(0, 0, 0, 0); |
| 24 | + mainLayout->setSpacing(0); |
| 25 | + |
| 26 | + // Top Bar (Back button) |
| 27 | + QWidget* topBar = new QWidget(); |
| 28 | + topBar->setFixedHeight(60); |
| 29 | + topBar->setStyleSheet("background: rgba(18, 19, 23, 180); border-bottom: 1px solid rgba(255, 255, 255, 10);"); |
| 30 | + QHBoxLayout* topBarLayout = new QHBoxLayout(topBar); |
| 31 | + topBarLayout->setContentsMargins(20, 0, 20, 0); |
| 32 | + |
| 33 | + GlassButton* backBtn = new GlassButton(MaterialIcons::Refresh, "Back", "", Colors::ON_SURFACE_VARIANT, this); |
| 34 | + // Let's just use the button with a smaller size and different style natively, |
| 35 | + // but GlassButton is fine. We will configure it simply. |
| 36 | + // For now, let's just make it a simple text button to be safe if GlassButton is complex |
| 37 | + QPushButton* realBackBtn = new QPushButton("← Back"); |
| 38 | + realBackBtn->setStyleSheet("QPushButton { font-size: 16px; color: white; background: transparent; border: none; font-weight: bold; }" |
| 39 | + "QPushButton:hover { color: #bb86fc; }"); |
| 40 | + realBackBtn->setCursor(Qt::PointingHandCursor); |
| 41 | + connect(realBackBtn, &QPushButton::clicked, this, &GameDetailsPage::backClicked); |
| 42 | + |
| 43 | + topBarLayout->addWidget(realBackBtn); |
| 44 | + topBarLayout->addStretch(1); |
| 45 | + mainLayout->addWidget(topBar); |
| 46 | + |
| 47 | + // Scroll Area |
| 48 | + m_scrollArea = new QScrollArea(); |
| 49 | + m_scrollArea->setWidgetResizable(true); |
| 50 | + m_scrollArea->setStyleSheet("QScrollArea { border: none; background: transparent; } QScrollBar { width: 0px; }"); |
| 51 | + |
| 52 | + m_contentWidget = new QWidget(); |
| 53 | + m_contentWidget->setStyleSheet("background: transparent;"); |
| 54 | + m_contentLayout = new QVBoxLayout(m_contentWidget); |
| 55 | + m_contentLayout->setContentsMargins(0, 0, 0, 40); |
| 56 | + m_contentLayout->setSpacing(30); |
| 57 | + |
| 58 | + // Hero Banner |
| 59 | + m_heroBanner = new QLabel(); |
| 60 | + m_heroBanner->setFixedHeight(250); |
| 61 | + m_heroBanner->setStyleSheet("background: #1e1e1e; border-radius: 12px; margin: 20px 20px 0 20px;"); |
| 62 | + m_heroBanner->setScaledContents(true); |
| 63 | + m_heroBanner->setAlignment(Qt::AlignCenter); |
| 64 | + m_contentLayout->addWidget(m_heroBanner); |
| 65 | + |
| 66 | + // Info Row (Description + Install Context) |
| 67 | + QWidget* infoRow = new QWidget(); |
| 68 | + infoRow->setStyleSheet("margin: 0 20px;"); |
| 69 | + QHBoxLayout* infoLayout = new QHBoxLayout(infoRow); |
| 70 | + infoLayout->setContentsMargins(0, 0, 0, 0); |
| 71 | + infoLayout->setSpacing(40); |
| 72 | + |
| 73 | + m_descriptionLabel = new QLabel("Loading description..."); |
| 74 | + m_descriptionLabel->setWordWrap(true); |
| 75 | + m_descriptionLabel->setStyleSheet("font-size: 14px; color: #dddddd; line-height: 1.5;"); |
| 76 | + m_descriptionLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); |
| 77 | + infoLayout->addWidget(m_descriptionLabel, 3); // Takes 3/4 space |
| 78 | + |
| 79 | + // Install Button Widget |
| 80 | + QWidget* actionWidget = new QWidget(); |
| 81 | + QVBoxLayout* actionLayout = new QVBoxLayout(actionWidget); |
| 82 | + actionLayout->setContentsMargins(0, 0, 0, 0); |
| 83 | + actionLayout->setAlignment(Qt::AlignTop | Qt::AlignRight); |
| 84 | + |
| 85 | + m_installButton = new GlassButton(MaterialIcons::Download, "Add to Library", "Download patch", Colors::PRIMARY); |
| 86 | + // GlassButton has a fixed width in its class usually, but we'll add it |
| 87 | + connect(m_installButton, &QPushButton::clicked, this, [this]() { |
| 88 | + emit addToLibraryClicked(m_appId, m_gameName, m_hasFix); |
| 89 | + }); |
| 90 | + actionLayout->addWidget(m_installButton); |
| 91 | + infoLayout->addWidget(actionWidget, 1); // Takes 1/4 space |
| 92 | + |
| 93 | + m_contentLayout->addWidget(infoRow); |
| 94 | + |
| 95 | + // Screenshots Section |
| 96 | + QLabel* ssTitle = new QLabel("Game Previews"); |
| 97 | + ssTitle->setStyleSheet("font-size: 18px; font-weight: bold; color: white; margin: 10px 20px 0 20px;"); |
| 98 | + m_contentLayout->addWidget(ssTitle); |
| 99 | + |
| 100 | + QScrollArea* ssScroll = new QScrollArea(); |
| 101 | + ssScroll->setWidgetResizable(true); |
| 102 | + ssScroll->setFixedHeight(200); |
| 103 | + ssScroll->setStyleSheet("QScrollArea { border: none; background: transparent; } QScrollBar { height: 0px; }"); |
| 104 | + |
| 105 | + QWidget* ssContainer = new QWidget(); |
| 106 | + ssContainer->setStyleSheet("background: transparent; margin: 0 20px;"); |
| 107 | + m_screenshotLayout = new QHBoxLayout(ssContainer); |
| 108 | + m_screenshotLayout->setContentsMargins(0, 0, 0, 0); |
| 109 | + m_screenshotLayout->setSpacing(15); |
| 110 | + m_screenshotLayout->setAlignment(Qt::AlignLeft); |
| 111 | + |
| 112 | + ssScroll->setWidget(ssContainer); |
| 113 | + m_contentLayout->addWidget(ssScroll); |
| 114 | + |
| 115 | + // Features and Security Row |
| 116 | + QWidget* detailsRow = new QWidget(); |
| 117 | + detailsRow->setStyleSheet("margin: 20px 20px;"); |
| 118 | + QHBoxLayout* detailsLayout = new QHBoxLayout(detailsRow); |
| 119 | + detailsLayout->setContentsMargins(0, 0, 0, 0); |
| 120 | + detailsLayout->setSpacing(40); |
| 121 | + detailsLayout->setAlignment(Qt::AlignTop); |
| 122 | + |
| 123 | + // Features Section |
| 124 | + QWidget* featuresWidget = new QWidget(); |
| 125 | + m_featuresLayout = new QVBoxLayout(featuresWidget); |
| 126 | + m_featuresLayout->setAlignment(Qt::AlignTop); |
| 127 | + m_featuresLayout->setContentsMargins(0, 0, 0, 0); |
| 128 | + |
| 129 | + QLabel* fTitle = new QLabel("Game Features"); |
| 130 | + fTitle->setStyleSheet("font-size: 16px; font-weight: bold; color: white;"); |
| 131 | + m_featuresLayout->addWidget(fTitle); |
| 132 | + |
| 133 | + detailsLayout->addWidget(featuresWidget, 1); |
| 134 | + |
| 135 | + // Security Section |
| 136 | + QWidget* securityWidget = new QWidget(); |
| 137 | + m_securityLayout = new QVBoxLayout(securityWidget); |
| 138 | + m_securityLayout->setAlignment(Qt::AlignTop); |
| 139 | + m_securityLayout->setContentsMargins(0, 0, 0, 0); |
| 140 | + |
| 141 | + QLabel* secTitle = new QLabel("# Security / DRM"); |
| 142 | + secTitle->setStyleSheet("font-size: 16px; font-weight: bold; color: #FFB74D;"); |
| 143 | + m_securityLayout->addWidget(secTitle); |
| 144 | + |
| 145 | + detailsLayout->addWidget(securityWidget, 1); |
| 146 | + |
| 147 | + m_contentLayout->addWidget(detailsRow); |
| 148 | + m_contentLayout->addStretch(1); |
| 149 | + |
| 150 | + m_scrollArea->setWidget(m_contentWidget); |
| 151 | + mainLayout->addWidget(m_scrollArea); |
| 152 | +} |
| 153 | + |
| 154 | +void GameDetailsPage::keyPressEvent(QKeyEvent* event) { |
| 155 | + if (event->key() == Qt::Key_Escape) { |
| 156 | + emit backClicked(); |
| 157 | + } |
| 158 | + QWidget::keyPressEvent(event); |
| 159 | +} |
| 160 | + |
| 161 | +void GameDetailsPage::clear() { |
| 162 | + m_heroBanner->clear(); |
| 163 | + m_heroBanner->setText(""); |
| 164 | + m_descriptionLabel->setText(""); |
| 165 | + |
| 166 | + // Clear screenshots |
| 167 | + QLayoutItem* child; |
| 168 | + while ((child = m_screenshotLayout->takeAt(0)) != nullptr) { |
| 169 | + if (child->widget()) delete child->widget(); |
| 170 | + delete child; |
| 171 | + } |
| 172 | + |
| 173 | + // Clear features (keep title) |
| 174 | + while (m_featuresLayout->count() > 1) { |
| 175 | + child = m_featuresLayout->takeAt(1); |
| 176 | + if (child->widget()) delete child->widget(); |
| 177 | + delete child; |
| 178 | + } |
| 179 | + |
| 180 | + // Clear security (keep title) |
| 181 | + while (m_securityLayout->count() > 1) { |
| 182 | + child = m_securityLayout->takeAt(1); |
| 183 | + if (child->widget()) delete child->widget(); |
| 184 | + delete child; |
| 185 | + } |
| 186 | +} |
| 187 | + |
| 188 | +void GameDetailsPage::showSkeleton() { |
| 189 | + clear(); |
| 190 | + m_descriptionLabel->setText("Loading game details..."); |
| 191 | + m_heroBanner->setStyleSheet("background: #2a2a2a; border-radius: 12px; margin: 20px 20px 0 20px;"); |
| 192 | + |
| 193 | + // Add empty screenshot skeletons |
| 194 | + for (int i = 0; i < 3; ++i) { |
| 195 | + QLabel* emptySs = new QLabel(); |
| 196 | + emptySs->setFixedSize(300, 169); |
| 197 | + emptySs->setStyleSheet("background: #2a2a2a; border-radius: 8px;"); |
| 198 | + m_screenshotLayout->addWidget(emptySs); |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +void GameDetailsPage::loadGame(const QString& appId, const QString& name, bool supported, bool hasFix) { |
| 203 | + m_appId = appId; |
| 204 | + m_gameName = name; |
| 205 | + m_supported = supported; |
| 206 | + m_hasFix = hasFix; |
| 207 | + |
| 208 | + // Reset UI to skeleton state |
| 209 | + showSkeleton(); |
| 210 | + |
| 211 | + // Update Action Button |
| 212 | + m_installButton->setEnabled(supported); |
| 213 | + if (hasFix) { |
| 214 | + m_installButton->setDescription(QString("Download patch for %1").arg(name)); |
| 215 | + m_installButton->setAccentColor(Colors::ACCENT_GREEN); |
| 216 | + } else { |
| 217 | + m_installButton->setDescription(QString("Generate patch for %1").arg(name)); |
| 218 | + m_installButton->setAccentColor(Colors::PRIMARY); |
| 219 | + } |
| 220 | + |
| 221 | + // Set fallback hero image right away |
| 222 | + QString heroUrl = QString("https://cdn.akamai.steamstatic.com/steam/apps/%1/library_hero.jpg").arg(appId); |
| 223 | + QNetworkRequest hReq{QUrl(heroUrl)}; |
| 224 | + hReq.setHeader(QNetworkRequest::UserAgentHeader, "SteamLuaPatcher/2.0"); |
| 225 | + QNetworkReply* heroReply = m_networkManager->get(hReq); |
| 226 | + connect(heroReply, &QNetworkReply::finished, this, [this, heroReply]() { |
| 227 | + heroReply->deleteLater(); |
| 228 | + if (heroReply->error() == QNetworkReply::NoError) { |
| 229 | + QPixmap rawPix; |
| 230 | + if (rawPix.loadFromData(heroReply->readAll())) { |
| 231 | + QPixmap rounded(rawPix.size()); |
| 232 | + rounded.fill(Qt::transparent); |
| 233 | + QPainter p(&rounded); |
| 234 | + p.setRenderHint(QPainter::Antialiasing); |
| 235 | + QPainterPath path; |
| 236 | + path.addRoundedRect(rounded.rect(), 12, 12); |
| 237 | + p.setClipPath(path); |
| 238 | + p.drawPixmap(0, 0, rawPix); |
| 239 | + m_heroBanner->setStyleSheet("margin: 20px 20px 0 20px;"); |
| 240 | + m_heroBanner->setPixmap(rounded); |
| 241 | + } |
| 242 | + } |
| 243 | + }); |
| 244 | + |
| 245 | + // Fetch details from Steam |
| 246 | + QUrl url(QString("https://store.steampowered.com/api/appdetails?appids=%1").arg(appId)); |
| 247 | + QNetworkRequest req(url); |
| 248 | + req.setHeader(QNetworkRequest::UserAgentHeader, "SteamLuaPatcher/2.0"); |
| 249 | + QNetworkReply* reply = m_networkManager->get(req); |
| 250 | + connect(reply, &QNetworkReply::finished, this, [this, reply](){ onDetailsReceived(reply); }); |
| 251 | + |
| 252 | + // Give focus so Escape key works |
| 253 | + setFocus(); |
| 254 | + |
| 255 | + // Reset scroll to top |
| 256 | + m_scrollArea->verticalScrollBar()->setValue(0); |
| 257 | +} |
| 258 | + |
| 259 | +void GameDetailsPage::onDetailsReceived(QNetworkReply* reply) { |
| 260 | + reply->deleteLater(); |
| 261 | + if (reply->error() != QNetworkReply::NoError) { |
| 262 | + m_descriptionLabel->setText("Failed to load details from Steam."); |
| 263 | + return; |
| 264 | + } |
| 265 | + |
| 266 | + QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); |
| 267 | + QJsonObject root = doc.object(); |
| 268 | + |
| 269 | + if (root.contains(m_appId)) { |
| 270 | + QJsonObject appInfo = root[m_appId].toObject(); |
| 271 | + if (appInfo["success"].toBool() && appInfo.contains("data")) { |
| 272 | + populate(appInfo["data"].toObject()); |
| 273 | + return; |
| 274 | + } |
| 275 | + } |
| 276 | + m_descriptionLabel->setText("Game details not found on Steam."); |
| 277 | +} |
| 278 | + |
| 279 | +void GameDetailsPage::populate(const QJsonObject& data) { |
| 280 | + // Description |
| 281 | + QString desc = data["short_description"].toString(); |
| 282 | + // Steam sometimes returns HTML in short_description (e.g., ") |
| 283 | + // We can just set it as rich text, Qt handles basic HTML |
| 284 | + m_descriptionLabel->setText(desc); |
| 285 | + m_descriptionLabel->setTextFormat(Qt::RichText); |
| 286 | + |
| 287 | + // Screenshots |
| 288 | + QJsonArray screenshots = data["screenshots"].toArray(); |
| 289 | + |
| 290 | + // Clear skeletons first |
| 291 | + QLayoutItem* child; |
| 292 | + while ((child = m_screenshotLayout->takeAt(0)) != nullptr) { |
| 293 | + if (child->widget()) delete child->widget(); |
| 294 | + delete child; |
| 295 | + } |
| 296 | + |
| 297 | + int count = qMin(screenshots.size(), 6); // Max 6 screenshots |
| 298 | + for (int i = 0; i < count; ++i) { |
| 299 | + QJsonObject ss = screenshots[i].toObject(); |
| 300 | + QString url = ss["path_thumbnail"].toString(); |
| 301 | + |
| 302 | + QLabel* imgLbl = new QLabel(); |
| 303 | + imgLbl->setFixedSize(300, 169); |
| 304 | + imgLbl->setStyleSheet("background: #2a2a2a; border-radius: 8px;"); |
| 305 | + imgLbl->setScaledContents(true); |
| 306 | + m_screenshotLayout->addWidget(imgLbl); |
| 307 | + |
| 308 | + // Fetch Image |
| 309 | + QNetworkRequest req{QUrl(url)}; |
| 310 | + req.setHeader(QNetworkRequest::UserAgentHeader, "SteamLuaPatcher/2.0"); |
| 311 | + QNetworkReply* imgReply = m_networkManager->get(req); |
| 312 | + connect(imgReply, &QNetworkReply::finished, this, [imgReply, imgLbl]() { |
| 313 | + imgReply->deleteLater(); |
| 314 | + if (imgReply->error() == QNetworkReply::NoError && imgLbl) { |
| 315 | + QPixmap rawPix; |
| 316 | + if (rawPix.loadFromData(imgReply->readAll())) { |
| 317 | + QPixmap rounded(300, 169); |
| 318 | + rounded.fill(Qt::transparent); |
| 319 | + QPainter p(&rounded); |
| 320 | + p.setRenderHint(QPainter::Antialiasing); |
| 321 | + QPainterPath path; |
| 322 | + path.addRoundedRect(rounded.rect(), 8, 8); |
| 323 | + p.setClipPath(path); |
| 324 | + p.drawPixmap(rounded.rect(), rawPix.scaled(300, 169, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); |
| 325 | + if (imgLbl) imgLbl->setPixmap(rounded); |
| 326 | + } |
| 327 | + } |
| 328 | + }); |
| 329 | + } |
| 330 | + |
| 331 | + // Features (Genres + Categories) |
| 332 | + QJsonArray genres = data["genres"].toArray(); |
| 333 | + for (const QJsonValue& val : genres) { |
| 334 | + QString genre = val.toObject()["description"].toString(); |
| 335 | + QLabel* l = new QLabel("• " + genre); |
| 336 | + l->setStyleSheet("color: #aaaaaa; font-size: 14px;"); |
| 337 | + m_featuresLayout->addWidget(l); |
| 338 | + } |
| 339 | + |
| 340 | + QJsonArray cats = data["categories"].toArray(); |
| 341 | + for (const QJsonValue& val : cats) { |
| 342 | + QString cat = val.toObject()["description"].toString(); |
| 343 | + QLabel* l = new QLabel("• " + cat); |
| 344 | + l->setStyleSheet("color: #aaaaaa; font-size: 14px;"); |
| 345 | + m_featuresLayout->addWidget(l); |
| 346 | + } |
| 347 | + |
| 348 | + // Security / DRM (Denuvo, etc.) |
| 349 | + bool hasDrm = false; |
| 350 | + QString drmNotice = data["drm_notice"].toString(); |
| 351 | + QString legalNotice = data["legal_notice"].toString(); |
| 352 | + |
| 353 | + if (!drmNotice.isEmpty()) { |
| 354 | + QLabel* l = new QLabel(drmNotice); |
| 355 | + l->setWordWrap(true); |
| 356 | + l->setStyleSheet("color: #FF5252; font-size: 14px;"); |
| 357 | + m_securityLayout->addWidget(l); |
| 358 | + hasDrm = true; |
| 359 | + } |
| 360 | + if (legalNotice.contains("Denuvo", Qt::CaseInsensitive) || legalNotice.contains("Anti-cheat", Qt::CaseInsensitive)) { |
| 361 | + QLabel* l = new QLabel("Warning: Contains Third-party DRM/Anti-Cheat"); |
| 362 | + l->setWordWrap(true); |
| 363 | + l->setStyleSheet("color: #FF5252; font-size: 14px; font-weight: bold;"); |
| 364 | + m_securityLayout->addWidget(l); |
| 365 | + hasDrm = true; |
| 366 | + } |
| 367 | + |
| 368 | + if (!hasDrm) { |
| 369 | + QLabel* l = new QLabel("✓ No third-party DRM known"); |
| 370 | + l->setStyleSheet("color: #4CAF50; font-size: 14px;"); |
| 371 | + m_securityLayout->addWidget(l); |
| 372 | + } |
| 373 | +} |
0 commit comments