diff --git a/CMakeLists.txt b/CMakeLists.txt index 90b5e991..909e32c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ set_package_properties(PlasmaWaylandProtocols PROPERTIES find_package(LibKWorkspace CONFIG REQUIRED) find_package(Libudev REQUIRED) +find_package(SDL3 REQUIRED) find_package(KWinDBusInterface) set_package_properties(KWinDBusInterface PROPERTIES DESCRIPTION "KWin DBus interface" diff --git a/Nebra.code-workspace b/Nebra.code-workspace new file mode 100644 index 00000000..ef9f5d27 --- /dev/null +++ b/Nebra.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt index f7af544a..54a46bdb 100644 --- a/components/CMakeLists.txt +++ b/components/CMakeLists.txt @@ -14,3 +14,4 @@ add_subdirectory(wallpaperimageplugin) add_subdirectory(dpmsplugin) add_subdirectory(screenbrightnessplugin) add_subdirectory(raiselockscreenplugin) +add_subdirectory(gamingshellplugin) diff --git a/components/gamingshellplugin/CMakeLists.txt b/components/gamingshellplugin/CMakeLists.txt new file mode 100644 index 00000000..d9846ad6 --- /dev/null +++ b/components/gamingshellplugin/CMakeLists.txt @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +find_package(SDL3 REQUIRED) +find_package(Qt6 REQUIRED COMPONENTS Sql DBus) + +ecm_add_qml_module(gamingshellplugin URI org.kde.plasma.private.mobileshell.gamingshellplugin GENERATE_PLUGIN_SOURCE) + +target_sources(gamingshellplugin PRIVATE + gamepadmanager.cpp + gamepaddevice.cpp + gamelauncherprovider.cpp + powerprofilecontrol.cpp + gamemodecontrol.cpp +) + +target_link_libraries(gamingshellplugin PRIVATE + Qt::Core + Qt::DBus + Qt::Qml + Qt::Quick + Qt::Sql + KF6::I18n + KF6::KIOGui + KF6::Service + KF6::CoreAddons + KF6::ConfigCore + SDL3::SDL3 +) + +ecm_finalize_qml_module(gamingshellplugin) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp new file mode 100644 index 00000000..99bfd3ae --- /dev/null +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -0,0 +1,1219 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamelauncherprovider.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); +static const QString s_pinnedGroup = QStringLiteral("GamingPinned"); +static const QString s_perGameGroup = QStringLiteral("GamingPerGame"); +static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming"); +static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages"); + +namespace +{ +struct VdfNode { + QHash values; + QHash> children; +}; + +class VdfTokenizer +{ +public: + enum class TokenType { + End, + String, + OpenBrace, + CloseBrace, + Invalid, + }; + + struct Token { + TokenType type = TokenType::End; + QString text; + }; + + explicit VdfTokenizer(QStringView input) + : m_input(input) + { + } + + Token next() + { + skipWhitespaceAndComments(); + + if (m_pos >= m_input.size()) { + return {}; + } + + const QChar current = m_input.at(m_pos); + if (current == QLatin1Char('{')) { + ++m_pos; + return {TokenType::OpenBrace, {}}; + } + if (current == QLatin1Char('}')) { + ++m_pos; + return {TokenType::CloseBrace, {}}; + } + if (current == QLatin1Char('"')) { + bool terminated = false; + const QString text = readQuotedString(&terminated); + if (!terminated) { + return {TokenType::Invalid, text}; + } + return {TokenType::String, text}; + } + + return {TokenType::String, readBareString()}; + } + + int position() const + { + return m_pos; + } + +private: + void skipWhitespaceAndComments() + { + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos); + if (current.isSpace()) { + ++m_pos; + continue; + } + if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) { + m_pos += 2; + while (m_pos < m_input.size() && m_input.at(m_pos) != QLatin1Char('\n')) { + ++m_pos; + } + continue; + } + break; + } + } + + QString readQuotedString(bool *terminated) + { + QString result; + ++m_pos; + + if (terminated) { + *terminated = false; + } + + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos++); + if (current == QLatin1Char('"')) { + if (terminated) { + *terminated = true; + } + return result; + } + if (current == QLatin1Char('\\') && m_pos < m_input.size()) { + const QChar escaped = m_input.at(m_pos++); + switch (escaped.unicode()) { + case 'n': + result.append(QLatin1Char('\n')); + break; + case 't': + result.append(QLatin1Char('\t')); + break; + case 'r': + result.append(QLatin1Char('\r')); + break; + case '\\': + case '"': + result.append(escaped); + break; + default: + result.append(escaped); + break; + } + continue; + } + result.append(current); + } + + return result; + } + + QString readBareString() + { + const int start = m_pos; + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos); + if (current.isSpace() || current == QLatin1Char('{') || current == QLatin1Char('}') || current == QLatin1Char('"')) { + break; + } + if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) { + break; + } + ++m_pos; + } + return m_input.sliced(start, m_pos - start).toString(); + } + + QStringView m_input; + int m_pos = 0; +}; + +bool parseVdf(const QString &input, VdfNode &root, QString *error) +{ + VdfTokenizer tokenizer(input); + QList stack = {&root}; + + while (true) { + const auto key = tokenizer.next(); + if (key.type == VdfTokenizer::TokenType::End) { + if (stack.size() != 1 && error) { + *error = QStringLiteral("unexpected end of file"); + } + return stack.size() == 1; + } + if (key.type == VdfTokenizer::TokenType::CloseBrace) { + if (stack.size() == 1) { + if (error) { + *error = QStringLiteral("unexpected closing brace at position %1").arg(tokenizer.position()); + } + return false; + } + stack.removeLast(); + continue; + } + if (key.type != VdfTokenizer::TokenType::String || key.text.isEmpty()) { + if (error) { + *error = QStringLiteral("invalid key at position %1").arg(tokenizer.position()); + } + return false; + } + + const auto value = tokenizer.next(); + if (value.type == VdfTokenizer::TokenType::String) { + stack.last()->values.insert(key.text, value.text); + continue; + } + if (value.type == VdfTokenizer::TokenType::OpenBrace) { + auto child = std::make_shared(); + stack.last()->children.insert(key.text, child); + stack.append(child.get()); + continue; + } + + if (error) { + *error = QStringLiteral("expected value for key '%1'").arg(key.text); + } + return false; + } +} + +QString waydroidPackageFromService(const KService::Ptr &service) +{ + static const QRegularExpression execPattern(QStringLiteral("^waydroid\\s+app\\s+launch\\s+([^\\s%]+)")); + const QRegularExpressionMatch execMatch = execPattern.match(service->exec()); + if (execMatch.hasMatch()) { + return execMatch.captured(1); + } + + static const QRegularExpression storageIdPattern(QStringLiteral("^waydroid\\.(.+)\\.desktop$")); + const QRegularExpressionMatch storageIdMatch = storageIdPattern.match(service->storageId()); + if (!storageIdMatch.hasMatch()) { + return {}; + } + + return storageIdMatch.captured(1); +} + +QStringList waydroidGameShellPackages(const KSharedConfigPtr &config) +{ + const KConfigGroup group(config, s_waydroidGamingGroup); + return group.readEntry(s_gameShellPackagesKey, QStringList{}); +} +} // namespace + +GameLauncherProvider::GameLauncherProvider(QObject *parent) + : QAbstractListModel(parent) + , m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))) +{ + connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); + m_configWatcher = KConfigWatcher::create(m_config); + connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) { + if (group.name() == s_waydroidGamingGroup) { + m_config->reparseConfiguration(); + refresh(); + } + }); + m_pendingLaunchTimer.setInterval(15000); + m_pendingLaunchTimer.setSingleShot(true); + connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch); + m_mangohudPath = QStandardPaths::findExecutable(QStringLiteral("mangohud")); + m_mangohudAvailable = !m_mangohudPath.isEmpty(); + loadPinnedGames(); + refresh(); +} + +int GameLauncherProvider::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_games.size(); +} + +QVariant GameLauncherProvider::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_games.size()) { + return {}; + } + const auto &g = m_games.at(index.row()); + switch (role) { + case NameRole: + return g.name; + case IconRole: + return g.icon; + case SourceRole: + return g.source; + case StorageIdRole: + return g.storageId; + case LaunchCommandRole: + return g.launchCommand; + case LaunchMethodRole: + return launchMethodForEntry(g); + case ArtworkRole: + return g.artwork; + case LastPlayedTextRole: + return formatLastPlayed(g.lastPlayed); + case PinnedRole: + return m_pinnedGames.contains(g.storageId); + } + return {}; +} + +QHash GameLauncherProvider::roleNames() const +{ + return { + {NameRole, "name"}, + {IconRole, "icon"}, + {SourceRole, "source"}, + {StorageIdRole, "storageId"}, + {LaunchCommandRole, "launchCommand"}, + {LaunchMethodRole, "launchMethod"}, + {ArtworkRole, "artwork"}, + {LastPlayedTextRole, "lastPlayedText"}, + {PinnedRole, "pinned"}, + }; +} + +int GameLauncherProvider::count() const +{ + return m_games.size(); +} + +bool GameLauncherProvider::loading() const +{ + return m_loading; +} + +bool GameLauncherProvider::launchPending() const +{ + return m_launchPending; +} + +QString GameLauncherProvider::pendingLaunchName() const +{ + return m_pendingLaunchName; +} + +QString GameLauncherProvider::lastLaunchError() const +{ + return m_lastLaunchError; +} + +void GameLauncherProvider::refresh() +{ + m_loading = true; + Q_EMIT loadingChanged(); + + m_allGames.clear(); + + loadDesktopGames(); + loadSteamGames(); + loadLutrisGames(); + loadHeroicGames(); + loadRecentTimestamps(); + + // Deduplicate: when the same game appears from multiple sources, + // prefer Steam (has artwork + Proton handling) over desktop. + deduplicateGames(); + + // Sort alphabetically, case-insensitive + std::sort(m_allGames.begin(), m_allGames.end(), [](const GameEntry &a, const GameEntry &b) { + return a.name.compare(b.name, Qt::CaseInsensitive) < 0; + }); + + applyFilter(); + + m_loading = false; + Q_EMIT loadingChanged(); +} + +void GameLauncherProvider::launch(int index) +{ + if (index < 0 || index >= m_games.size()) { + return; + } + // Find the matching entry in m_allGames so the timestamp update persists + const QString &sid = m_games.at(index).storageId; + for (auto &entry : m_allGames) { + if (entry.storageId == sid) { + launchEntry(entry); + return; + } + } +} + +void GameLauncherProvider::launchByStorageId(const QString &storageId) +{ + for (auto &entry : m_allGames) { + if (entry.storageId == storageId) { + launchEntry(entry); + return; + } + } +} + +QVariantMap GameLauncherProvider::gameDetails(const QString &storageId) const +{ + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex < 0) { + return {}; + } + + const auto &entry = m_allGames.at(entryIndex); + return { + {QStringLiteral("name"), entry.name}, + {QStringLiteral("icon"), entry.icon}, + {QStringLiteral("source"), entry.source}, + {QStringLiteral("storageId"), entry.storageId}, + {QStringLiteral("launchMethod"), launchMethodForEntry(entry)}, + {QStringLiteral("artwork"), entry.artwork}, + {QStringLiteral("lastPlayedText"), formatLastPlayed(entry.lastPlayed)}, + {QStringLiteral("pinned"), m_pinnedGames.contains(entry.storageId)}, + {QStringLiteral("perGameFpsLimit"), perGameFpsLimit(entry.storageId)}, + {QStringLiteral("perGameOverlayState"), perGameOverlayState(entry.storageId)}, + }; +} + +bool GameLauncherProvider::openSourceApp(const QString &source) +{ + QString program; + QString displayName; + + if (source == QLatin1String("steam")) { + program = QStringLiteral("steam"); + displayName = QStringLiteral("Steam"); + } else if (source == QLatin1String("lutris")) { + program = QStringLiteral("lutris"); + displayName = QStringLiteral("Lutris"); + } else if (source == QLatin1String("heroic")) { + program = QStringLiteral("heroic"); + displayName = QStringLiteral("Heroic"); + } else { + return false; + } + + clearLastLaunchError(); + + if (!QProcess::startDetached(program, {})) { + markLaunchFailed(displayName, QStringLiteral("Unable to start %1").arg(program)); + return false; + } + + return true; +} + +void GameLauncherProvider::launchEntry(GameEntry &entry) +{ + clearLastLaunchError(); + + const KConfigGroup pgParent(m_config.data(), s_perGameGroup); + const KConfigGroup pgGroup(&pgParent, entry.storageId); + const int pgFpsLimit = pgGroup.readEntry("fpsLimit", -1); + const int pgOverlayState = pgGroup.readEntry("overlayState", -1); + const int effectiveFps = (pgFpsLimit >= 0) ? pgFpsLimit : m_fpsLimit; + const bool effectiveOverlay = (pgOverlayState >= 0) ? (pgOverlayState == 1) : m_overlayEnabled; + + if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) { + auto service = KService::serviceByStorageId(entry.storageId); + if (!service) { + markLaunchFailed(entry.name, QStringLiteral("Desktop entry is no longer available")); + return; + } + + // Wrap native desktop entries with mangohud when overlay or FPS cap is active. + // Waydroid (Android) games don't benefit from it, so skip them. + if (entry.source == QLatin1String("desktop") && m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) { + QString exec = service->exec(); + // Strip KDE .desktop field-code placeholders (%u, %U, %f, %F, %i, %c, %k …) + exec.remove(QRegularExpression(QStringLiteral("%[a-zA-Z]"))); + KShell::Errors parseError = KShell::NoError; + QStringList parts = KShell::splitArgs(exec.simplified(), KShell::NoOptions, &parseError); + if (parseError == KShell::NoError && !parts.isEmpty()) { + const QString program = parts.takeFirst(); + qint64 pid = 0; + if (launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) { + markLaunchSucceeded(entry.storageId, entry.name); + } else { + markLaunchFailed(entry.name, QStringLiteral("Failed to launch %1 with mangohud").arg(program)); + } + return; + } + // Exec parsing failed — fall through to the standard KIO job without overlay. + } + + auto *job = new KIO::ApplicationLauncherJob(service); + connect(job, &KJob::result, this, [this, job, storageId = entry.storageId, name = entry.name]() { + if (job->error() != 0) { + markLaunchFailed(name, job->errorString()); + return; + } + markLaunchSucceeded(storageId, name); + }); + job->start(); + } else if (entry.launchCommand.contains(QStringLiteral("://"))) { + // Protocol handler (e.g. heroic://launch/...) — open via xdg-open + if (!QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand})) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start xdg-open")); + return; + } + markLaunchSucceeded(entry.storageId, entry.name); + } else { + QStringList parts = KShell::splitArgs(entry.launchCommand); + if (parts.isEmpty()) { + markLaunchFailed(entry.name, QStringLiteral("Launch command is empty")); + return; + } + + QString program = parts.takeFirst(); + if (m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) { + qint64 pid = 0; + if (!launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start %1 with mangohud").arg(program)); + return; + } + } else if (!QProcess::startDetached(program, parts)) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program)); + return; + } + markLaunchSucceeded(entry.storageId, entry.name); + } +} + +void GameLauncherProvider::deduplicateGames() +{ + // Build a set of names from dedicated launcher entries (case-insensitive). + // These have better artwork and metadata, so they win over plain .desktop entries. + QSet launcherNames; + for (const auto &g : std::as_const(m_allGames)) { + if (g.source == QLatin1String("steam") || g.source == QLatin1String("lutris") || g.source == QLatin1String("heroic")) { + launcherNames.insert(g.name.toLower()); + } + } + + // Remove desktop entries whose name matches a launcher entry. + m_allGames.erase(std::remove_if(m_allGames.begin(), + m_allGames.end(), + [&launcherNames](const GameEntry &g) { + return g.source == QLatin1String("desktop") && launcherNames.contains(g.name.toLower()); + }), + m_allGames.end()); +} + +// --- XDG .desktop games --- + +void GameLauncherProvider::loadDesktopGames() +{ + const QStringList allowedWaydroidPackages = waydroidGameShellPackages(m_config); + const QSet enabledWaydroidPackages(allowedWaydroidPackages.cbegin(), allowedWaydroidPackages.cend()); + const auto services = KService::allServices(); + for (const auto &service : services) { + if (service->noDisplay() || service->exec().isEmpty()) { + continue; + } + const QStringList cats = service->categories(); + bool isGame = false; + bool isWaydroidApp = false; + for (const auto &cat : cats) { + if (cat.compare(QLatin1String("Game"), Qt::CaseInsensitive) == 0) { + isGame = true; + } else if (cat.compare(QLatin1String("X-WayDroid-App"), Qt::CaseInsensitive) == 0) { + isWaydroidApp = true; + } + } + if (!isGame) { + if (!isWaydroidApp) { + continue; + } + + const QString packageName = waydroidPackageFromService(service); + if (packageName.isEmpty() || !enabledWaydroidPackages.contains(packageName)) { + continue; + } + } + + GameEntry entry; + entry.name = service->name(); + entry.icon = service->icon(); + entry.source = isWaydroidApp ? QStringLiteral("waydroid") : QStringLiteral("desktop"); + entry.storageId = service->storageId(); + entry.launchCommand = service->exec(); + m_allGames.append(entry); + } +} + +// --- Steam library --- + +void GameLauncherProvider::loadSteamGames() +{ + // Look for Steam library folders + const QStringList steamRoots = { + QDir::homePath() + QStringLiteral("/.steam/steam"), + QDir::homePath() + QStringLiteral("/.local/share/Steam"), + // Flatpak Steam + QDir::homePath() + QStringLiteral("/.var/app/com.valvesoftware.Steam/.steam/steam"), + QDir::homePath() + QStringLiteral("/.var/app/com.valvesoftware.Steam/.local/share/Steam"), + }; + + QStringList libraryPaths; + for (const auto &root : steamRoots) { + const QString vdfPath = root + QStringLiteral("/steamapps/libraryfolders.vdf"); + QFile vdf(vdfPath); + if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) { + continue; + } + const QString content = QString::fromUtf8(vdf.readAll()); + VdfNode document; + QString error; + if (!parseVdf(content, document, &error)) { + qWarning() << "GameLauncherProvider: cannot parse Steam libraryfolders" << vdfPath << error; + continue; + } + + const VdfNode *libraries = nullptr; + if (document.children.contains(QStringLiteral("libraryfolders"))) { + libraries = document.children.value(QStringLiteral("libraryfolders")).get(); + } else { + libraries = &document; + } + + for (auto it = libraries->children.cbegin(); it != libraries->children.cend(); ++it) { + const QString path = it.value()->values.value(QStringLiteral("path")); + if (!path.isEmpty()) { + libraryPaths.append(path); + } + } + } + + libraryPaths.removeDuplicates(); + + for (const auto &libPath : std::as_const(libraryPaths)) { + QDir steamapps(libPath + QStringLiteral("/steamapps")); + if (!steamapps.exists()) { + continue; + } + const auto manifests = steamapps.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files); + for (const auto &manifest : manifests) { + QFile f(steamapps.filePath(manifest)); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + continue; + } + VdfNode manifestData; + QString error; + if (!parseVdf(QString::fromUtf8(f.readAll()), manifestData, &error)) { + qWarning() << "GameLauncherProvider: cannot parse Steam manifest" << manifest << error; + continue; + } + + const VdfNode *appState = + manifestData.children.contains(QStringLiteral("AppState")) ? manifestData.children.value(QStringLiteral("AppState")).get() : &manifestData; + + const QString appName = appState->values.value(QStringLiteral("name")); + const QString appId = appState->values.value(QStringLiteral("appid")); + + if (appName.isEmpty() || appId.isEmpty()) { + continue; + } + + // Skip Steamworks Common Redistributables and Proton/tools + if (appId == QLatin1String("228980")) { + continue; + } + + GameEntry entry; + entry.name = appName; + entry.icon = QStringLiteral("steam"); + entry.source = QStringLiteral("steam"); + entry.storageId = QStringLiteral("steam://rungameid/") + appId; + entry.launchCommand = QStringLiteral("steam steam://rungameid/") + appId; + + // Check for grid artwork + for (const auto &root : steamRoots) { + const QString gridDir = root + QStringLiteral("/appcache/librarycache/") + appId; + const QStringList artSuffixes = { + QStringLiteral("_library_600x900.jpg"), + QStringLiteral("_header.jpg"), + }; + for (const auto &suffix : artSuffixes) { + const QString artPath = gridDir + suffix; + if (QFile::exists(artPath)) { + entry.artwork = artPath; + break; + } + } + if (!entry.artwork.isEmpty()) { + break; + } + } + + m_allGames.append(entry); + } + } +} + +// --- Lutris library (SQLite) --- + +void GameLauncherProvider::loadLutrisGames() +{ + const QString dbPath = QDir::homePath() + QStringLiteral("/.local/share/lutris/pga.db"); + if (!QFile::exists(dbPath)) { + return; + } + + // Use a unique connection name to avoid conflicting with other code. + // RAII guard ensures QSqlDatabase::removeDatabase runs on every exit path. + const QString connName = QStringLiteral("lutris_games_%1").arg(reinterpret_cast(this)); + const auto dbCleanup = qScopeGuard([&connName]() { + QSqlDatabase::removeDatabase(connName); + }); + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); + db.setDatabaseName(dbPath); + db.setConnectOptions(QStringLiteral("QSQLITE_OPEN_READONLY")); + + if (!db.open()) { + qWarning() << "GameLauncherProvider: cannot open Lutris DB" << dbPath; + return; + } + + QSqlQuery query(db); + query.prepare(QStringLiteral("SELECT name, slug, runner, coverart, id FROM games WHERE installed = 1")); + + if (!query.exec()) { + qWarning() << "GameLauncherProvider: Lutris query failed"; + db.close(); + return; + } + + const QString coverBase = QDir::homePath() + QStringLiteral("/.local/share/lutris/coverart/"); + + while (query.next()) { + GameEntry entry; + entry.name = query.value(0).toString(); + const QString slug = query.value(1).toString(); + const QString runner = query.value(2).toString(); + const QString coverart = query.value(3).toString(); + const int gameId = query.value(4).toInt(); + + entry.source = QStringLiteral("lutris"); + entry.storageId = QStringLiteral("lutris:%1").arg(slug); + entry.icon = QStringLiteral("lutris"); + entry.launchCommand = QStringLiteral("lutris lutris:rungameid/%1").arg(gameId); + + // Cover art: Lutris stores covers in ~/.local/share/lutris/coverart/ + if (!coverart.isEmpty()) { + entry.artwork = coverart; + } else { + const QString coverFile = coverBase + slug + QStringLiteral(".jpg"); + if (QFile::exists(coverFile)) { + entry.artwork = coverFile; + } + } + + m_allGames.append(entry); + } + + db.close(); + } + // dbCleanup guard handles QSqlDatabase::removeDatabase(connName) +} + +// --- Heroic Games Launcher (JSON) --- + +void GameLauncherProvider::loadHeroicGames() +{ + // Heroic stores library caches for different stores + const QString heroicBase = QDir::homePath() + QStringLiteral("/.config/heroic"); + if (!QDir(heroicBase).exists()) { + return; + } + + // Check both GOG and Epic (Legendary) library caches + const QStringList libFiles = { + heroicBase + QStringLiteral("/store_cache/gog_library.json"), + heroicBase + QStringLiteral("/store_cache/legendary_library.json"), + heroicBase + QStringLiteral("/store_cache/nile_library.json"), + }; + + for (const auto &libPath : libFiles) { + QFile libFile(libPath); + if (!libFile.open(QIODevice::ReadOnly)) { + continue; + } + + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(libFile.readAll(), &err); + if (err.error != QJsonParseError::NoError) { + qWarning() << "GameLauncherProvider: JSON parse error in" << libPath << err.errorString(); + continue; + } + + // Heroic library JSON: { "library": [ { "app_name": ..., "title": ..., ... } ] } + // or it can be a plain array at the top level + QJsonArray games; + if (doc.isArray()) { + games = doc.array(); + } else if (doc.isObject()) { + games = doc.object().value(QStringLiteral("library")).toArray(); + if (games.isEmpty()) { + games = doc.object().value(QStringLiteral("games")).toArray(); + } + } + + const bool isGog = libPath.contains(QStringLiteral("gog")); + const bool isNile = libPath.contains(QStringLiteral("nile")); + + for (const auto &val : games) { + const QJsonObject obj = val.toObject(); + const QString appName = obj.value(QStringLiteral("app_name")).toString(); + const QString title = obj.value(QStringLiteral("title")).toString(); + + if (title.isEmpty()) { + continue; + } + + // Check if installed + const auto isInstalled = obj.value(QStringLiteral("is_installed")); + if (isInstalled.isBool() && !isInstalled.toBool()) { + continue; + } + + GameEntry entry; + entry.name = title; + entry.source = QStringLiteral("heroic"); + entry.storageId = QStringLiteral("heroic:%1").arg(appName); + entry.icon = QStringLiteral("heroic"); + + // Launch via Heroic protocol handler + if (isGog) { + entry.launchCommand = QStringLiteral("heroic://launch/gog/%1").arg(appName); + } else if (isNile) { + entry.launchCommand = QStringLiteral("heroic://launch/nile/%1").arg(appName); + } else { + entry.launchCommand = QStringLiteral("heroic://launch/legendary/%1").arg(appName); + } + + // Cover art: Heroic caches artwork + const QString artPath = obj.value(QStringLiteral("art_cover")).toString(); + if (!artPath.isEmpty() && QFile::exists(artPath)) { + entry.artwork = artPath; + } else { + // Try Heroic's thumbnail cache + const QString thumbDir = heroicBase + QStringLiteral("/images/") + appName + QStringLiteral("/"); + const QDir thumbs(thumbDir); + if (thumbs.exists()) { + const auto images = thumbs.entryList({QStringLiteral("*.jpg"), QStringLiteral("*.png"), QStringLiteral("*.webp")}, QDir::Files); + if (!images.isEmpty()) { + entry.artwork = thumbDir + images.first(); + } + } + } + + m_allGames.append(entry); + } + } +} + +QString GameLauncherProvider::filterString() const +{ + return m_filterString; +} + +void GameLauncherProvider::setFilterString(const QString &filter) +{ + if (m_filterString == filter) { + return; + } + m_filterString = filter; + Q_EMIT filterStringChanged(); + applyFilter(); +} + +QString GameLauncherProvider::sourceFilter() const +{ + return m_sourceFilter; +} + +void GameLauncherProvider::setSourceFilter(const QString &source) +{ + if (m_sourceFilter == source) { + return; + } + m_sourceFilter = source; + Q_EMIT sourceFilterChanged(); + applyFilter(); +} + +bool GameLauncherProvider::overlayEnabled() const +{ + return m_overlayEnabled; +} + +bool GameLauncherProvider::mangohudAvailable() const +{ + return m_mangohudAvailable; +} + +int GameLauncherProvider::fpsLimit() const +{ + return m_fpsLimit; +} + +void GameLauncherProvider::setFpsLimit(int limit) +{ + if (m_fpsLimit == limit) { + return; + } + m_fpsLimit = limit; + Q_EMIT fpsLimitChanged(); +} + +bool GameLauncherProvider::launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid) +{ + QStringList config; + if (!overlayEnabled) { + config << QStringLiteral("no_display"); + } + if (fpsLimit > 0) { + config << QStringLiteral("fps_limit=%1").arg(fpsLimit); + } + + QProcess proc; + if (!config.isEmpty()) { + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("MANGOHUD_CONFIG"), config.join(QLatin1Char(','))); + proc.setProcessEnvironment(env); + } + proc.setProgram(m_mangohudPath); + proc.setArguments(QStringList{program} + args); + return proc.startDetached(pid); +} + +void GameLauncherProvider::setOverlayEnabled(bool enabled) +{ + if (m_overlayEnabled == enabled) { + return; + } + m_overlayEnabled = enabled; + Q_EMIT overlayEnabledChanged(); +} + +void GameLauncherProvider::applyFilter() +{ + beginResetModel(); + m_games.clear(); + + for (const auto &g : std::as_const(m_allGames)) { + if (!m_sourceFilter.isEmpty() && g.source != m_sourceFilter) { + continue; + } + if (!m_filterString.isEmpty() && !g.name.contains(m_filterString, Qt::CaseInsensitive)) { + continue; + } + m_games.append(g); + } + + std::stable_sort(m_games.begin(), m_games.end(), [this](const GameEntry &a, const GameEntry &b) { + return m_pinnedGames.contains(a.storageId) > m_pinnedGames.contains(b.storageId); + }); + + endResetModel(); + Q_EMIT countChanged(); +} + +void GameLauncherProvider::loadRecentTimestamps() +{ + const KConfigGroup group(m_config, s_recentGroup); + for (auto &entry : m_allGames) { + const QString key = entry.storageId; + if (group.hasKey(key)) { + entry.lastPlayed = group.readEntry(key, QDateTime()); + } + } +} + +void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const QDateTime &when) +{ + KConfigGroup group(m_config, s_recentGroup); + group.writeEntry(storageId, when); + group.sync(); +} + +void GameLauncherProvider::loadPinnedGames() +{ + const KConfigGroup group(m_config, s_pinnedGroup); + const QStringList list = group.readEntry(QStringLiteral("pinned"), QStringList{}); + m_pinnedGames = QSet(list.begin(), list.end()); +} + +void GameLauncherProvider::togglePin(const QString &storageId) +{ + if (storageId.isEmpty()) { + return; + } + if (m_pinnedGames.contains(storageId)) { + m_pinnedGames.remove(storageId); + } else { + m_pinnedGames.insert(storageId); + } + KConfigGroup group(m_config, s_pinnedGroup); + group.writeEntry(QStringLiteral("pinned"), QStringList(m_pinnedGames.begin(), m_pinnedGames.end())); + group.sync(); + applyFilter(); +} + +int GameLauncherProvider::perGameFpsLimit(const QString &storageId) const +{ + const KConfigGroup parent(m_config.data(), s_perGameGroup); + const KConfigGroup group(&parent, storageId); + return group.readEntry("fpsLimit", -1); +} + +void GameLauncherProvider::setPerGameFpsLimit(const QString &storageId, int limit) +{ + if (storageId.isEmpty()) { + return; + } + KConfigGroup parent(m_config.data(), s_perGameGroup); + KConfigGroup group(&parent, storageId); + if (limit < 0) { + group.deleteEntry("fpsLimit"); + } else { + group.writeEntry("fpsLimit", limit); + } + group.sync(); +} + +int GameLauncherProvider::perGameOverlayState(const QString &storageId) const +{ + const KConfigGroup parent(m_config.data(), s_perGameGroup); + const KConfigGroup group(&parent, storageId); + return group.readEntry("overlayState", -1); +} + +void GameLauncherProvider::setPerGameOverlayState(const QString &storageId, int state) +{ + if (storageId.isEmpty()) { + return; + } + KConfigGroup parent(m_config.data(), s_perGameGroup); + KConfigGroup group(&parent, storageId); + if (state < 0) { + group.deleteEntry("overlayState"); + } else { + group.writeEntry("overlayState", state); + } + group.sync(); +} + +void GameLauncherProvider::clearLastPlayed(const QString &storageId) +{ + if (storageId.isEmpty()) { + return; + } + + KConfigGroup group(m_config, s_recentGroup); + if (!group.hasKey(storageId)) { + return; + } + group.deleteEntry(storageId); + group.sync(); + + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex >= 0) { + m_allGames[entryIndex].lastPlayed = QDateTime(); + const int filteredIndex = [&] { + for (int i = 0; i < m_games.size(); ++i) { + if (m_games.at(i).storageId == storageId) { + return i; + } + } + return -1; + }(); + if (filteredIndex >= 0) { + m_games[filteredIndex].lastPlayed = QDateTime(); + const QModelIndex idx = index(filteredIndex); + Q_EMIT dataChanged(idx, idx, {LastPlayedTextRole}); + } + } + + Q_EMIT recentGamesChanged(); +} + +QString GameLauncherProvider::launchMethodForEntry(const GameEntry &entry) const +{ + if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) { + return QStringLiteral("desktop-entry"); + } + + if (entry.launchCommand.contains(QStringLiteral("://"))) { + return QStringLiteral("protocol"); + } + + return QStringLiteral("command"); +} + +QString GameLauncherProvider::formatLastPlayed(const QDateTime &when) const +{ + if (!when.isValid()) { + return {}; + } + + return QLocale().toString(when, QLocale::ShortFormat); +} + +void GameLauncherProvider::clearPendingLaunch() +{ + if (!m_launchPending && m_pendingLaunchName.isEmpty()) { + return; + } + + m_pendingLaunchTimer.stop(); + m_launchPending = false; + m_pendingLaunchName.clear(); + Q_EMIT launchPendingChanged(); +} + +void GameLauncherProvider::clearLastLaunchError() +{ + if (m_lastLaunchError.isEmpty()) { + return; + } + + m_lastLaunchError.clear(); + Q_EMIT lastLaunchErrorChanged(); +} + +int GameLauncherProvider::findEntryIndexByStorageId(const QString &storageId) const +{ + for (int index = 0; index < m_allGames.size(); ++index) { + if (m_allGames.at(index).storageId == storageId) { + return index; + } + } + return -1; +} + +void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const QString &name) +{ + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex >= 0) { + auto &entry = m_allGames[entryIndex]; + const auto now = QDateTime::currentDateTime(); + saveRecentTimestamp(entry.storageId, now); + entry.lastPlayed = now; + } + + setPendingLaunch(name); + Q_EMIT gameLaunched(name); + Q_EMIT recentGamesChanged(); +} + +void GameLauncherProvider::markLaunchFailed(const QString &name, const QString &error) +{ + clearPendingLaunch(); + + const QString message = error.isEmpty() ? tr("Unable to launch %1").arg(name) : tr("Unable to launch %1: %2").arg(name, error); + + if (m_lastLaunchError != message) { + m_lastLaunchError = message; + Q_EMIT lastLaunchErrorChanged(); + } + + Q_EMIT gameLaunchFailed(name, message); +} + +void GameLauncherProvider::setPendingLaunch(const QString &name) +{ + const bool changed = !m_launchPending || m_pendingLaunchName != name; + m_launchPending = true; + m_pendingLaunchName = name; + m_pendingLaunchTimer.start(); + + if (changed) { + Q_EMIT launchPendingChanged(); + } +} + +QVariantList GameLauncherProvider::recentGames(int limit) const +{ + // Gather entries that have been launched at least once + QList recent; + for (const auto &g : m_allGames) { + if (g.lastPlayed.isValid()) { + recent.append(&g); + } + } + + // Most recent first + std::sort(recent.begin(), recent.end(), [](const GameEntry *a, const GameEntry *b) { + return a->lastPlayed > b->lastPlayed; + }); + + if (recent.size() > limit) { + recent.resize(limit); + } + + QVariantList result; + result.reserve(recent.size()); + for (const auto *g : recent) { + QVariantMap map; + map[QStringLiteral("name")] = g->name; + map[QStringLiteral("icon")] = g->icon; + map[QStringLiteral("source")] = g->source; + map[QStringLiteral("storageId")] = g->storageId; + map[QStringLiteral("artwork")] = g->artwork; + result.append(map); + } + return result; +} diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h new file mode 100644 index 00000000..32bf6fcd --- /dev/null +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +class GameLauncherProvider : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged) + Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged) + Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged) + Q_PROPERTY(bool mangohudAvailable READ mangohudAvailable NOTIFY mangohudAvailableChanged) + Q_PROPERTY(int fpsLimit READ fpsLimit WRITE setFpsLimit NOTIFY fpsLimitChanged) + Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged) + Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged) + Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged) + +public: + explicit GameLauncherProvider(QObject *parent = nullptr); + + enum Roles { + NameRole = Qt::UserRole + 1, + IconRole, + SourceRole, // "desktop", "waydroid", "steam", "flatpak" + StorageIdRole, // .desktop file name or launch URI + LaunchCommandRole, + LaunchMethodRole, + ArtworkRole, // path to banner/grid image (empty if none) + LastPlayedTextRole, + PinnedRole, + }; + Q_ENUM(Roles) + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + int count() const; + bool loading() const; + QString filterString() const; + void setFilterString(const QString &filter); + QString sourceFilter() const; + void setSourceFilter(const QString &source); + bool overlayEnabled() const; + void setOverlayEnabled(bool enabled); + bool mangohudAvailable() const; + int fpsLimit() const; + void setFpsLimit(int limit); + bool launchPending() const; + QString pendingLaunchName() const; + QString lastLaunchError() const; + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void launch(int index); + Q_INVOKABLE void launchByStorageId(const QString &storageId); + Q_INVOKABLE QVariantMap gameDetails(const QString &storageId) const; + Q_INVOKABLE bool openSourceApp(const QString &source); + Q_INVOKABLE void clearLastPlayed(const QString &storageId); + Q_INVOKABLE QVariantList recentGames(int limit = 5) const; + Q_INVOKABLE void clearPendingLaunch(); + Q_INVOKABLE void clearLastLaunchError(); + Q_INVOKABLE void togglePin(const QString &storageId); + Q_INVOKABLE int perGameFpsLimit(const QString &storageId) const; + Q_INVOKABLE void setPerGameFpsLimit(const QString &storageId, int limit); + Q_INVOKABLE int perGameOverlayState(const QString &storageId) const; + Q_INVOKABLE void setPerGameOverlayState(const QString &storageId, int state); + +Q_SIGNALS: + void countChanged(); + void loadingChanged(); + void filterStringChanged(); + void sourceFilterChanged(); + void overlayEnabledChanged(); + void mangohudAvailableChanged(); + void fpsLimitChanged(); + void launchPendingChanged(); + void lastLaunchErrorChanged(); + void gameLaunched(const QString &name); + void gameLaunchFailed(const QString &name, const QString &error); + void recentGamesChanged(); + +private: + struct GameEntry { + QString name; + QString icon; + QString source; + QString storageId; + QString launchCommand; + QString artwork; + QDateTime lastPlayed; + }; + + void loadDesktopGames(); + void loadSteamGames(); + void loadLutrisGames(); + void loadHeroicGames(); + void deduplicateGames(); + void loadRecentTimestamps(); + void saveRecentTimestamp(const QString &storageId, const QDateTime &when); + void applyFilter(); + void launchEntry(GameEntry &entry); + bool launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid = nullptr); + QString launchMethodForEntry(const GameEntry &entry) const; + QString formatLastPlayed(const QDateTime &when) const; + // Returns the current m_allGames index for the storage id. + // Callers must re-lookup after any mutation that can rebuild or reorder the list. + int findEntryIndexByStorageId(const QString &storageId) const; + void markLaunchSucceeded(const QString &storageId, const QString &name); + void markLaunchFailed(const QString &name, const QString &error); + void setPendingLaunch(const QString &name); + void loadPinnedGames(); + + QList m_allGames; + QList m_games; // filtered view + QString m_filterString; + QString m_sourceFilter; // empty = all, or a specific source such as "desktop" or "waydroid" + KSharedConfigPtr m_config; + bool m_loading = false; + bool m_overlayEnabled = false; + int m_fpsLimit = 0; + bool m_mangohudAvailable = false; + QString m_mangohudPath; + QSet m_pinnedGames; + bool m_launchPending = false; + QString m_pendingLaunchName; + QString m_lastLaunchError; + QTimer m_pendingLaunchTimer; + KConfigWatcher::Ptr m_configWatcher; +}; diff --git a/components/gamingshellplugin/gamemodecontrol.cpp b/components/gamingshellplugin/gamemodecontrol.cpp new file mode 100644 index 00000000..20778cf2 --- /dev/null +++ b/components/gamingshellplugin/gamemodecontrol.cpp @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamemodecontrol.h" + +#include +#include +#include + +#include // getpid() + +static const QString s_service = QStringLiteral("com.feralinteractive.GameMode"); +static const QString s_path = QStringLiteral("/com/feralinteractive/GameMode"); +static const QString s_iface = QStringLiteral("com.feralinteractive.GameMode"); + +GameModeControl::GameModeControl(QObject *parent) + : QObject(parent) + , m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::sessionBus(), this)) +{ + m_available = m_iface->isValid(); + if (!m_available) { + qDebug() << "GameModeControl: Feral GameMode not available"; + } +} + +bool GameModeControl::available() const +{ + return m_available; +} + +bool GameModeControl::active() const +{ + return m_active; +} + +void GameModeControl::requestStart() +{ + if (!m_available || m_active) { + return; + } + + QDBusReply reply = m_iface->call(QStringLiteral("RegisterGame"), static_cast(getpid())); + if (reply.isValid() && reply.value() == 0) { + m_active = true; + Q_EMIT activeChanged(); + } else { + qWarning() << "GameModeControl: RegisterGame failed:" << reply.error().message(); + } +} + +void GameModeControl::requestEnd() +{ + if (!m_available || !m_active) { + return; + } + + QDBusReply reply = m_iface->call(QStringLiteral("UnregisterGame"), static_cast(getpid())); + if (reply.isValid() && reply.value() == 0) { + m_active = false; + Q_EMIT activeChanged(); + } else { + qWarning() << "GameModeControl: UnregisterGame failed:" << reply.error().message(); + } +} diff --git a/components/gamingshellplugin/gamemodecontrol.h b/components/gamingshellplugin/gamemodecontrol.h new file mode 100644 index 00000000..ff17c716 --- /dev/null +++ b/components/gamingshellplugin/gamemodecontrol.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include + +/** + * D-Bus client for Feral GameMode (com.feralinteractive.GameMode). + * + * Calling requestStart() tells the daemon to apply performance + * optimizations (CPU governor, I/O priority, GPU perf mode, etc.) + * for the calling process. requestEnd() reverses them. + * + * GameMode is optional — if the daemon is not installed the calls + * are silently ignored. + */ +class GameModeControl : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool available READ available NOTIFY availableChanged) + Q_PROPERTY(bool active READ active NOTIFY activeChanged) + +public: + explicit GameModeControl(QObject *parent = nullptr); + + bool available() const; + bool active() const; + + Q_INVOKABLE void requestStart(); + Q_INVOKABLE void requestEnd(); + +Q_SIGNALS: + void availableChanged(); + void activeChanged(); + +private: + QDBusInterface *m_iface = nullptr; + bool m_available = false; + bool m_active = false; +}; diff --git a/components/gamingshellplugin/gamepaddevice.cpp b/components/gamingshellplugin/gamepaddevice.cpp new file mode 100644 index 00000000..4403554a --- /dev/null +++ b/components/gamingshellplugin/gamepaddevice.cpp @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamepaddevice.h" + +#include +#include +#include +#include + +#include + +static QString gamepadButtonLabelToString(SDL_GamepadButtonLabel label) +{ + switch (label) { + case SDL_GAMEPAD_BUTTON_LABEL_A: + return QStringLiteral("A"); + case SDL_GAMEPAD_BUTTON_LABEL_B: + return QStringLiteral("B"); + case SDL_GAMEPAD_BUTTON_LABEL_X: + return QStringLiteral("X"); + case SDL_GAMEPAD_BUTTON_LABEL_Y: + return QStringLiteral("Y"); + case SDL_GAMEPAD_BUTTON_LABEL_CROSS: + return QStringLiteral("Cross"); + case SDL_GAMEPAD_BUTTON_LABEL_CIRCLE: + return QStringLiteral("Circle"); + case SDL_GAMEPAD_BUTTON_LABEL_SQUARE: + return QStringLiteral("Square"); + case SDL_GAMEPAD_BUTTON_LABEL_TRIANGLE: + return QStringLiteral("Triangle"); + default: + return QStringLiteral("?"); + } +} + +GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent) + : QObject(parent) + , m_pad(pad) + , m_id(id) +{ + refreshBattery(); +} + +GamepadDevice::~GamepadDevice() +{ + if (m_pad) { + SDL_CloseGamepad(m_pad); + m_pad = nullptr; + } +} + +int GamepadDevice::deviceId() const +{ + return m_id; +} + +QString GamepadDevice::name() const +{ + if (!m_pad) { + return {}; + } + const char *n = SDL_GetGamepadName(m_pad); + return n ? QString::fromUtf8(n) : QString(); +} + +QString GamepadDevice::type() const +{ + if (!m_pad) { + return QStringLiteral("unknown"); + } + switch (SDL_GetGamepadType(m_pad)) { + case SDL_GAMEPAD_TYPE_XBOX360: + case SDL_GAMEPAD_TYPE_XBOXONE: + return QStringLiteral("xbox"); + case SDL_GAMEPAD_TYPE_PS3: + case SDL_GAMEPAD_TYPE_PS4: + case SDL_GAMEPAD_TYPE_PS5: + return QStringLiteral("playstation"); + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + return QStringLiteral("nintendo"); + default: + return QStringLiteral("generic"); + } +} + +int GamepadDevice::batteryPercent() const +{ + return m_batteryPercent; +} + +bool GamepadDevice::hasRumble() const +{ + if (!m_pad) { + return false; + } + SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad); + return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false); +} + +bool GamepadDevice::hasTriggerRumble() const +{ + if (!m_pad) { + return false; + } + SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad); + return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, false); +} + +bool GamepadDevice::hasLED() const +{ + if (!m_pad) { + return false; + } + SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad); + return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false); +} + +int GamepadDevice::touchpadCount() const +{ + if (!m_pad) { + return 0; + } + return SDL_GetNumGamepadTouchpads(m_pad); +} + +bool GamepadDevice::hasGyro() const +{ + return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_GYRO); +} + +bool GamepadDevice::hasAccelerometer() const +{ + return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_ACCEL); +} + +int GamepadDevice::playerIndex() const +{ + if (!m_pad) { + return -1; + } + return SDL_GetGamepadPlayerIndex(m_pad); +} + +void GamepadDevice::setPlayerIndex(int index) +{ + if (!m_pad) { + return; + } + if (SDL_SetGamepadPlayerIndex(m_pad, index)) { + Q_EMIT playerIndexChanged(); + } +} + +bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs) +{ + if (!m_pad) { + return false; + } + auto lo = static_cast(std::clamp(lowIntensity, 0, 65535)); + auto hi = static_cast(std::clamp(highIntensity, 0, 65535)); + auto dur = static_cast(std::clamp(durationMs, 0, durationMs)); + return SDL_RumbleGamepad(m_pad, lo, hi, dur); +} + +bool GamepadDevice::rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs) +{ + if (!m_pad) { + return false; + } + auto left = static_cast(std::clamp(leftIntensity, 0, 65535)); + auto right = static_cast(std::clamp(rightIntensity, 0, 65535)); + auto dur = static_cast(std::clamp(durationMs, 0, durationMs)); + return SDL_RumbleGamepadTriggers(m_pad, left, right, dur); +} + +bool GamepadDevice::setLED(int r, int g, int b) +{ + if (!m_pad) { + return false; + } + auto cr = static_cast(std::clamp(r, 0, 255)); + auto cg = static_cast(std::clamp(g, 0, 255)); + auto cb = static_cast(std::clamp(b, 0, 255)); + return SDL_SetGamepadLED(m_pad, cr, cg, cb); +} + +QString GamepadDevice::buttonLabel(int button) const +{ + if (!m_pad) { + return QStringLiteral("?"); + } + + switch (button) { + case SDL_GAMEPAD_BUTTON_SOUTH: + case SDL_GAMEPAD_BUTTON_EAST: + case SDL_GAMEPAD_BUTTON_WEST: + case SDL_GAMEPAD_BUTTON_NORTH: + return gamepadButtonLabelToString(SDL_GetGamepadButtonLabel(m_pad, static_cast(button))); + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: + return type() == QLatin1String("playstation") ? QStringLiteral("L1") : type() == QLatin1String("nintendo") ? QStringLiteral("L") : QStringLiteral("LB"); + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: + return type() == QLatin1String("playstation") ? QStringLiteral("R1") : type() == QLatin1String("nintendo") ? QStringLiteral("R") : QStringLiteral("RB"); + case SDL_GAMEPAD_BUTTON_BACK: + return type() == QLatin1String("playstation") ? QStringLiteral("Create") + : type() == QLatin1String("nintendo") ? QStringLiteral("-") + : QStringLiteral("View"); + case SDL_GAMEPAD_BUTTON_START: + return type() == QLatin1String("playstation") ? QStringLiteral("Options") + : type() == QLatin1String("nintendo") ? QStringLiteral("+") + : QStringLiteral("Menu"); + case SDL_GAMEPAD_BUTTON_GUIDE: + return type() == QLatin1String("playstation") ? QStringLiteral("PS") + : type() == QLatin1String("nintendo") ? QStringLiteral("Home") + : QStringLiteral("Guide"); + default: + return QStringLiteral("?"); + } +} + +SDL_Gamepad *GamepadDevice::sdlGamepad() const +{ + return m_pad; +} + +void GamepadDevice::refreshBattery() +{ + if (!m_pad) { + return; + } + int pct = -1; + SDL_GetGamepadPowerInfo(m_pad, &pct); + if (pct != m_batteryPercent) { + m_batteryPercent = pct; + Q_EMIT batteryPercentChanged(); + } +} diff --git a/components/gamingshellplugin/gamepaddevice.h b/components/gamingshellplugin/gamepaddevice.h new file mode 100644 index 00000000..b4e9acfc --- /dev/null +++ b/components/gamingshellplugin/gamepaddevice.h @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include + +struct SDL_Gamepad; + +class GamepadDevice : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + + Q_PROPERTY(int deviceId READ deviceId CONSTANT) + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString type READ type CONSTANT) + Q_PROPERTY(int batteryPercent READ batteryPercent NOTIFY batteryPercentChanged) + Q_PROPERTY(bool hasRumble READ hasRumble CONSTANT) + Q_PROPERTY(bool hasTriggerRumble READ hasTriggerRumble CONSTANT) + Q_PROPERTY(bool hasLED READ hasLED CONSTANT) + Q_PROPERTY(int touchpadCount READ touchpadCount CONSTANT) + Q_PROPERTY(bool hasGyro READ hasGyro CONSTANT) + Q_PROPERTY(bool hasAccelerometer READ hasAccelerometer CONSTANT) + Q_PROPERTY(int playerIndex READ playerIndex WRITE setPlayerIndex NOTIFY playerIndexChanged) + +public: + explicit GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent = nullptr); + ~GamepadDevice() override; + + int deviceId() const; + QString name() const; + QString type() const; + int batteryPercent() const; + bool hasRumble() const; + bool hasTriggerRumble() const; + bool hasLED() const; + int touchpadCount() const; + bool hasGyro() const; + bool hasAccelerometer() const; + int playerIndex() const; + void setPlayerIndex(int index); + + Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs); + Q_INVOKABLE bool rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs); + Q_INVOKABLE bool setLED(int r, int g, int b); + Q_INVOKABLE QString buttonLabel(int button) const; + + SDL_Gamepad *sdlGamepad() const; + void refreshBattery(); + +Q_SIGNALS: + void batteryPercentChanged(); + void playerIndexChanged(); + +private: + SDL_Gamepad *m_pad = nullptr; + int m_id = 0; + int m_batteryPercent = -1; +}; diff --git a/components/gamingshellplugin/gamepadmanager.cpp b/components/gamingshellplugin/gamepadmanager.cpp new file mode 100644 index 00000000..023d8e49 --- /dev/null +++ b/components/gamingshellplugin/gamepadmanager.cpp @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamepadmanager.h" +#include "gamepaddevice.h" + +#include +#include +#include + +#include +#include + +#include + +GamepadManager::GamepadManager(QObject *parent) + : QAbstractListModel(parent) +{ + m_pollTimer.setInterval(16); // ~60 Hz + connect(&m_pollTimer, &QTimer::timeout, this, &GamepadManager::poll); +} + +GamepadManager::~GamepadManager() +{ + stop(); +} + +GamepadManager *GamepadManager::create(QQmlEngine *qmlEngine, QJSEngine *jsEngine) +{ + Q_UNUSED(jsEngine) + static GamepadManager *s_instance = nullptr; + if (!s_instance) { + s_instance = new GamepadManager(qmlEngine); + } + return s_instance; +} + +// --- QAbstractListModel --- + +int GamepadManager::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_gamepads.size(); +} + +QVariant GamepadManager::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_gamepads.size()) { + return {}; + } + auto *dev = m_gamepads.at(index.row()); + switch (role) { + case DeviceRole: + return QVariant::fromValue(dev); + case NameRole: + return dev->name(); + case TypeRole: + return dev->type(); + case BatteryRole: + return dev->batteryPercent(); + } + return {}; +} + +QHash GamepadManager::roleNames() const +{ + return { + {DeviceRole, "device"}, + {NameRole, "name"}, + {TypeRole, "type"}, + {BatteryRole, "battery"}, + }; +} + +// --- Properties --- + +bool GamepadManager::active() const +{ + return m_active; +} + +void GamepadManager::setActive(bool active) +{ + if (m_active == active) { + return; + } + m_active = active; + if (active) { + start(); + } else { + stop(); + } + Q_EMIT activeChanged(); +} + +int GamepadManager::count() const +{ + return m_gamepads.size(); +} + +bool GamepadManager::hasGamepad() const +{ + return !m_gamepads.isEmpty(); +} + +GamepadDevice *GamepadManager::primaryGamepad() const +{ + return m_gamepads.isEmpty() ? nullptr : m_gamepads.first(); +} + +GamepadDevice *GamepadManager::gamepadAt(int index) const +{ + if (index < 0 || index >= m_gamepads.size()) { + return nullptr; + } + return m_gamepads.at(index); +} + +QString GamepadManager::buttonLabel(int button, int gamepadIndex) const +{ + GamepadDevice *device = nullptr; + if (gamepadIndex >= 0 && gamepadIndex < m_gamepads.size()) { + device = m_gamepads.at(gamepadIndex); + } else { + device = primaryGamepad(); + } + + if (!device) { + switch (button) { + case SDL_GAMEPAD_BUTTON_SOUTH: + return QStringLiteral("A"); + case SDL_GAMEPAD_BUTTON_EAST: + return QStringLiteral("B"); + case SDL_GAMEPAD_BUTTON_WEST: + return QStringLiteral("X"); + case SDL_GAMEPAD_BUTTON_NORTH: + return QStringLiteral("Y"); + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: + return QStringLiteral("LB"); + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: + return QStringLiteral("RB"); + case SDL_GAMEPAD_BUTTON_BACK: + return QStringLiteral("Back"); + case SDL_GAMEPAD_BUTTON_START: + return QStringLiteral("Start"); + case SDL_GAMEPAD_BUTTON_GUIDE: + return QStringLiteral("Guide"); + default: + return QStringLiteral("?"); + } + } + + return device->buttonLabel(button); +} + +// --- Lifecycle --- + +void GamepadManager::start() +{ + if (m_sdlInitialized) { + return; + } + + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + if (!SDL_Init(SDL_INIT_GAMEPAD)) { + qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError(); + return; + } + m_sdlInitialized = true; + SDL_SetGamepadEventsEnabled(true); + + // Enumerate already-connected gamepads + int count = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&count); + if (ids) { + for (int i = 0; i < count; ++i) { + addGamepad(ids[i]); + } + SDL_free(ids); + } + + m_pollTimer.start(); +} + +void GamepadManager::stop() +{ + m_pollTimer.stop(); + + if (!m_gamepads.isEmpty()) { + beginResetModel(); + qDeleteAll(m_gamepads); + m_gamepads.clear(); + endResetModel(); + Q_EMIT countChanged(); + Q_EMIT primaryGamepadChanged(); + } + + if (m_sdlInitialized) { + SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + m_sdlInitialized = false; + } +} + +// --- Event polling --- + +void GamepadManager::poll() +{ + SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_EVENT_GAMEPAD_ADDED: + addGamepad(event.gdevice.which); + break; + + case SDL_EVENT_GAMEPAD_REMOVED: + removeGamepad(event.gdevice.which); + break; + + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: { + int idx = indexForInstanceId(event.gbutton.which); + if (idx >= 0) { + Q_EMIT buttonPressed(event.gbutton.button, idx); + } + break; + } + + case SDL_EVENT_GAMEPAD_BUTTON_UP: { + int idx = indexForInstanceId(event.gbutton.which); + if (idx >= 0) { + Q_EMIT buttonReleased(event.gbutton.button, idx); + } + break; + } + + case SDL_EVENT_GAMEPAD_AXIS_MOTION: { + int idx = indexForInstanceId(event.gaxis.which); + if (idx >= 0) { + float normalized = std::clamp(static_cast(event.gaxis.value) / 32767.0f, -1.0f, 1.0f); + Q_EMIT axisChanged(event.gaxis.axis, normalized, idx); + } + break; + } + + default: + break; + } + } + + // Refresh battery state periodically (every ~5 seconds = 300 frames) + if (++m_batteryCounter >= 300) { + m_batteryCounter = 0; + for (auto *dev : std::as_const(m_gamepads)) { + dev->refreshBattery(); + } + } +} + +// --- Hotplug --- + +void GamepadManager::addGamepad(int instanceId) +{ + // Already tracked? + if (indexForInstanceId(instanceId) >= 0) { + return; + } + + SDL_Gamepad *pad = SDL_OpenGamepad(instanceId); + if (!pad) { + qWarning() << "GamepadManager: failed to open gamepad" << instanceId << SDL_GetError(); + return; + } + + auto *device = new GamepadDevice(pad, instanceId, this); + int row = m_gamepads.size(); + beginInsertRows(QModelIndex(), row, row); + m_gamepads.append(device); + endInsertRows(); + + Q_EMIT countChanged(); + if (m_gamepads.size() == 1) { + Q_EMIT primaryGamepadChanged(); + } + + qDebug() << "GamepadManager: connected" << device->name() << "(" << device->type() << ")"; +} + +void GamepadManager::removeGamepad(int instanceId) +{ + int idx = indexForInstanceId(instanceId); + if (idx < 0) { + return; + } + + beginRemoveRows(QModelIndex(), idx, idx); + auto *dev = m_gamepads.takeAt(idx); + endRemoveRows(); + + qDebug() << "GamepadManager: disconnected" << dev->name(); + dev->deleteLater(); + + Q_EMIT countChanged(); + if (idx == 0) { + Q_EMIT primaryGamepadChanged(); + } +} + +int GamepadManager::indexForInstanceId(int instanceId) const +{ + for (int i = 0; i < m_gamepads.size(); ++i) { + if (m_gamepads.at(i)->deviceId() == instanceId) { + return i; + } + } + return -1; +} diff --git a/components/gamingshellplugin/gamepadmanager.h b/components/gamingshellplugin/gamepadmanager.h new file mode 100644 index 00000000..a66ca045 --- /dev/null +++ b/components/gamingshellplugin/gamepadmanager.h @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include + +class QQmlEngine; +class QJSEngine; +class GamepadDevice; + +class GamepadManager : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(GamepadDevice *primaryGamepad READ primaryGamepad NOTIFY primaryGamepadChanged) + Q_PROPERTY(bool hasGamepad READ hasGamepad NOTIFY countChanged) + +public: + explicit GamepadManager(QObject *parent = nullptr); + ~GamepadManager() override; + + static GamepadManager *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine); + + enum Roles { + DeviceRole = Qt::UserRole + 1, + NameRole, + TypeRole, + BatteryRole, + }; + Q_ENUM(Roles) + + // Buttons matching SDL_GamepadButton, re-exported for QML + enum Button { + ButtonA, + ButtonB, + ButtonX, + ButtonY, + ButtonBack, + ButtonGuide, + ButtonStart, + ButtonLeftStick, + ButtonRightStick, + ButtonLeftShoulder, + ButtonRightShoulder, + ButtonDPadUp, + ButtonDPadDown, + ButtonDPadLeft, + ButtonDPadRight, + ButtonMisc1, + }; + Q_ENUM(Button) + + // Axes matching SDL_GamepadAxis + enum Axis { + AxisLeftX, + AxisLeftY, + AxisRightX, + AxisRightY, + AxisLeftTrigger, + AxisRightTrigger, + }; + Q_ENUM(Axis) + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + bool active() const; + void setActive(bool active); + int count() const; + bool hasGamepad() const; + GamepadDevice *primaryGamepad() const; + + GamepadDevice *gamepadAt(int index) const; + Q_INVOKABLE QString buttonLabel(int button, int gamepadIndex = -1) const; + +Q_SIGNALS: + void activeChanged(); + void countChanged(); + void primaryGamepadChanged(); + + void buttonPressed(int button, int gamepadIndex); + void buttonReleased(int button, int gamepadIndex); + void axisChanged(int axis, float value, int gamepadIndex); + +private: + void start(); + void stop(); + void poll(); + void addGamepad(int instanceId); + void removeGamepad(int instanceId); + int indexForInstanceId(int instanceId) const; + + bool m_active = false; + bool m_sdlInitialized = false; + int m_batteryCounter = 0; + QTimer m_pollTimer; + QList m_gamepads; +}; diff --git a/components/gamingshellplugin/powerprofilecontrol.cpp b/components/gamingshellplugin/powerprofilecontrol.cpp new file mode 100644 index 00000000..dc42cdb0 --- /dev/null +++ b/components/gamingshellplugin/powerprofilecontrol.cpp @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "powerprofilecontrol.h" + +#include +#include +#include +#include +#include + +static const QString s_service = QStringLiteral("net.hadess.PowerProfiles"); +static const QString s_path = QStringLiteral("/net/hadess/PowerProfiles"); +static const QString s_iface = QStringLiteral("net.hadess.PowerProfiles"); +static const QString s_propIface = QStringLiteral("org.freedesktop.DBus.Properties"); + +PowerProfileControl::PowerProfileControl(QObject *parent) + : QObject(parent) + , m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::systemBus(), this)) +{ + if (m_iface->isValid()) { + m_available = true; + fetchState(); + + // Subscribe to property changes + QDBusConnection::systemBus() + .connect(s_service, s_path, s_propIface, QStringLiteral("PropertiesChanged"), this, SLOT(onPropertiesChanged(QString, QVariantMap, QStringList))); + } else { + qDebug() << "PowerProfileControl: power-profiles-daemon not available"; + } +} + +QString PowerProfileControl::activeProfile() const +{ + return m_activeProfile; +} + +void PowerProfileControl::setActiveProfile(const QString &profile) +{ + if (!m_available || profile == m_activeProfile) { + return; + } + if (!m_profiles.contains(profile)) { + return; + } + + // Write via org.freedesktop.DBus.Properties.Set + QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus()); + propIface.call(QStringLiteral("Set"), s_iface, QStringLiteral("ActiveProfile"), QVariant::fromValue(QDBusVariant(profile))); +} + +QStringList PowerProfileControl::profiles() const +{ + return m_profiles; +} + +bool PowerProfileControl::available() const +{ + return m_available; +} + +void PowerProfileControl::fetchState() +{ + // Read ActiveProfile + QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus()); + + QDBusReply profileReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("ActiveProfile")); + if (profileReply.isValid()) { + const QString profile = profileReply.value().variant().toString(); + if (profile != m_activeProfile) { + m_activeProfile = profile; + Q_EMIT activeProfileChanged(); + } + } + + // Read Profiles — array of dicts, each with a "Profile" key + QDBusReply profilesReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("Profiles")); + if (profilesReply.isValid()) { + QStringList profiles; + const QVariant profilesVariant = profilesReply.value().variant(); + if (profilesVariant.canConvert()) { + const QVariantList list = profilesVariant.toList(); + for (const QVariant &item : list) { + const QVariantMap map = item.toMap(); + QString profileName; + if (map.contains(QStringLiteral("Profile"))) { + QVariant value = map.value(QStringLiteral("Profile")); + if (value.canConvert()) { + value = value.value().variant(); + } + profileName = value.toString(); + } + if (!profileName.isEmpty()) { + profiles.append(profileName); + } + } + } + if (profiles != m_profiles) { + m_profiles = profiles; + Q_EMIT profilesChanged(); + } + } +} + +void PowerProfileControl::onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated) +{ + Q_UNUSED(invalidated) + if (interface != s_iface) { + return; + } + + if (changed.contains(QStringLiteral("ActiveProfile"))) { + QVariant value = changed.value(QStringLiteral("ActiveProfile")); + if (value.canConvert()) { + value = value.value().variant(); + } + const QString profile = value.toString(); + if (profile != m_activeProfile) { + m_activeProfile = profile; + Q_EMIT activeProfileChanged(); + } + } + + if (changed.contains(QStringLiteral("Profiles"))) { + fetchState(); + } +} diff --git a/components/gamingshellplugin/powerprofilecontrol.h b/components/gamingshellplugin/powerprofilecontrol.h new file mode 100644 index 00000000..42b56c0d --- /dev/null +++ b/components/gamingshellplugin/powerprofilecontrol.h @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include +#include + +class PowerProfileControl : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QString activeProfile READ activeProfile WRITE setActiveProfile NOTIFY activeProfileChanged) + Q_PROPERTY(QStringList profiles READ profiles NOTIFY profilesChanged) + Q_PROPERTY(bool available READ available NOTIFY availableChanged) + +public: + explicit PowerProfileControl(QObject *parent = nullptr); + + QString activeProfile() const; + void setActiveProfile(const QString &profile); + QStringList profiles() const; + bool available() const; + +Q_SIGNALS: + void activeProfileChanged(); + void profilesChanged(); + void availableChanged(); + +private Q_SLOTS: + void onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated); + +private: + void fetchState(); + + QDBusInterface *m_iface = nullptr; + QString m_activeProfile; + QStringList m_profiles; + bool m_available = false; +}; diff --git a/components/mobileshell/components/applethost.cpp b/components/mobileshell/components/applethost.cpp index 9fe7cf16..5968e343 100644 --- a/components/mobileshell/components/applethost.cpp +++ b/components/mobileshell/components/applethost.cpp @@ -86,13 +86,15 @@ QQuickItem *AppletHost::fullRepresentationFor(const QString &pluginId) m_containment->addApplet(applet); auto *item = PlasmaQuick::AppletQuickItem::itemForApplet(applet); - m_items.insert(pluginId, item); if (!item) { qWarning() << "AppletHost: no AppletQuickItem for" << pluginId; + applet->deleteLater(); return nullptr; } + m_items.insert(pluginId, item); + item->setPreloadFullRepresentation(true); auto *fullRepItem = item->fullRepresentationItem(); diff --git a/components/mobileshell/qml/navigationpanel/NavigationPanel.qml b/components/mobileshell/qml/navigationpanel/NavigationPanel.qml index 80e0b13a..758aaff5 100644 --- a/components/mobileshell/qml/navigationpanel/NavigationPanel.qml +++ b/components/mobileshell/qml/navigationpanel/NavigationPanel.qml @@ -165,6 +165,7 @@ Item { shrinkSize: 0 onClicked: { + if (!root.taskModel) return; root.taskModel.requestActivate(root.taskModel.makeModelIndex(taskDelegate.index)); } @@ -180,18 +181,27 @@ Item { Controls.MenuItem { text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize") icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize" - onTriggered: root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index)) + onTriggered: { + if (!root.taskModel) return; + root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index)) + } } Controls.MenuItem { text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize") icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize" - onTriggered: root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index)) + onTriggered: { + if (!root.taskModel) return; + root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index)) + } } Controls.MenuSeparator {} Controls.MenuItem { text: i18n("Close") icon.name: "window-close" - onTriggered: root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index)) + onTriggered: { + if (!root.taskModel) return; + root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index)) + } } } diff --git a/components/quicksettingsplugin/quicksettingsconfig.cpp b/components/quicksettingsplugin/quicksettingsconfig.cpp index e40a378b..eb8bda7b 100644 --- a/components/quicksettingsplugin/quicksettingsconfig.cpp +++ b/components/quicksettingsplugin/quicksettingsconfig.cpp @@ -45,6 +45,8 @@ QList QuickSettingsConfig::enabledQuickSettings() const QStringLiteral("org.kde.plasma.quicksetting.screenshot"), QStringLiteral("org.kde.plasma.quicksetting.powermenu"), QStringLiteral("org.kde.plasma.quicksetting.donotdisturb"), + QStringLiteral("org.kde.plasma.quicksetting.gaming"), + QStringLiteral("org.kde.plasma.quicksetting.gaminghint"), QStringLiteral("org.kde.plasma.quicksetting.caffeine"), QStringLiteral("org.kde.plasma.quicksetting.keyboardtoggle"), QStringLiteral("org.kde.plasma.quicksetting.hotspot")}); diff --git a/components/shellsettingsplugin/mobileshellsettings.cpp b/components/shellsettingsplugin/mobileshellsettings.cpp index 88beb01e..29ff0077 100644 --- a/components/shellsettingsplugin/mobileshellsettings.cpp +++ b/components/shellsettingsplugin/mobileshellsettings.cpp @@ -44,6 +44,8 @@ MobileShellSettings::MobileShellSettings(QObject *parent) Q_EMIT actionDrawerTopRightModeChanged(); Q_EMIT convergenceModeEnabledChanged(); Q_EMIT autoHidePanelsEnabledChanged(); + Q_EMIT gamingModeEnabledChanged(); + Q_EMIT gamingDismissHintEnabledChanged(); Q_EMIT allowLogoutChanged(); } if (group.name() == LOCKSCREEN_CONFIG_GROUP) { @@ -248,6 +250,32 @@ void MobileShellSettings::setAutoHidePanelsEnabled(bool enabled) m_config->sync(); } +bool MobileShellSettings::gamingModeEnabled() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("gamingModeEnabled", false); +} + +void MobileShellSettings::setGamingModeEnabled(bool enabled) +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + group.writeEntry("gamingModeEnabled", enabled, KConfigGroup::Notify); + m_config->sync(); +} + +bool MobileShellSettings::gamingDismissHintEnabled() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("gamingDismissHintEnabled", true); +} + +void MobileShellSettings::setGamingDismissHintEnabled(bool enabled) +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + group.writeEntry("gamingDismissHintEnabled", enabled, KConfigGroup::Notify); + m_config->sync(); +} + void MobileShellSettings::updateNavigationBarsInPlasma() { // Do not update panels when not in Plasma Mobile diff --git a/components/shellsettingsplugin/mobileshellsettings.h b/components/shellsettingsplugin/mobileshellsettings.h index 64be056b..a88d96d3 100644 --- a/components/shellsettingsplugin/mobileshellsettings.h +++ b/components/shellsettingsplugin/mobileshellsettings.h @@ -52,6 +52,10 @@ class MobileShellSettings : public QObject // Auto Hide Panels Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged) + // Gaming mode + Q_PROPERTY(bool gamingModeEnabled READ gamingModeEnabled WRITE setGamingModeEnabled NOTIFY gamingModeEnabledChanged) + Q_PROPERTY(bool gamingDismissHintEnabled READ gamingDismissHintEnabled WRITE setGamingDismissHintEnabled NOTIFY gamingDismissHintEnabledChanged) + // logout dialog Q_PROPERTY(bool allowLogout READ allowLogout READ allowLogout NOTIFY allowLogoutChanged) @@ -264,6 +268,12 @@ public: */ void setAutoHidePanelsEnabled(bool enabled); + bool gamingModeEnabled() const; + void setGamingModeEnabled(bool enabled); + + bool gamingDismissHintEnabled() const; + void setGamingDismissHintEnabled(bool enabled); + /** * Whether logout button is shown in the logout/shutdown dialog. */ @@ -310,6 +320,8 @@ Q_SIGNALS: void quickSettingsColumnsChanged(); void convergenceModeEnabledChanged(); void autoHidePanelsEnabledChanged(); + void gamingModeEnabledChanged(); + void gamingDismissHintEnabledChanged(); void allowLogoutChanged(); void lockscreenLeftButtonActionChanged(); void lockscreenRightButtonActionChanged(); diff --git a/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp index 09acae2a..a203009e 100644 --- a/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp +++ b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp @@ -35,6 +35,17 @@ void WaydroidApplicationListModel::initializeApplications(const QList roleNames() const override; void initializeApplications(const QList &applicationObjectPaths); + void clearApplications(); public Q_SLOTS: void addApplication(const QDBusObjectPath &objectPath); diff --git a/components/waydroidintegrationplugin/waydroiddbusclient.cpp b/components/waydroidintegrationplugin/waydroiddbusclient.cpp index 45f343c4..3dbe1185 100644 --- a/components/waydroidintegrationplugin/waydroiddbusclient.cpp +++ b/components/waydroidintegrationplugin/waydroiddbusclient.cpp @@ -6,24 +6,38 @@ #include "waydroiddbusclient.h" +#include + #include #include +#include #include +#include using namespace Qt::StringLiterals; +static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming"); +static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages"); + WaydroidDBusClient::WaydroidDBusClient(QObject *parent) : QObject{parent} , m_interface{new OrgKdePlasmashellWaydroidInterface{u"org.kde.plasmashell"_s, u"/Waydroid"_s, QDBusConnection::sessionBus(), this}} , m_watcher{new QDBusServiceWatcher{u"org.kde.plasmashell"_s, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange, this}} , m_applicationListModel{new WaydroidApplicationListModel{this}} + , m_config{KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))} { + m_configWatcher = KConfigWatcher::create(m_config); + connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) { + if (group.name() == s_waydroidGamingGroup) { + m_config->reparseConfiguration(); + reloadGameShellPackages(); + } + }); + reloadGameShellPackages(); + // Check if the service is already running if (QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) { - m_connected = true; - if (m_interface->isValid()) { - connectSignals(); - } + checkWaydroidObject(); } connect(m_watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, [this](const QString &service, const QString &oldOwner, const QString &newOwner) { @@ -31,12 +45,11 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent) if (newOwner.isEmpty()) { // Service stopped m_connected = false; + m_connectionCheckPending = false; + resetState(); } else if (oldOwner.isEmpty()) { // Service started - m_connected = true; - if (m_interface->isValid()) { - connectSignals(); - } + checkWaydroidObject(); } } }); @@ -44,26 +57,32 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent) void WaydroidDBusClient::connectSignals() { - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) { - Q_EMIT downloadStatusChanged(downloaded, total, speed); - }); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::sessionStatusChanged, this, &WaydroidDBusClient::updateSessionStatus); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::systemTypeChanged, this, &WaydroidDBusClient::updateSystemType); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ipAddressChanged, this, &WaydroidDBusClient::updateIpAddress); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::androidIdChanged, this, &WaydroidDBusClient::updateAndroidId); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) { - Q_EMIT actionFinished(message); - }); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFailed, this, [this](const QString message) { - Q_EMIT actionFailed(message); - }); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) { - Q_EMIT errorOccurred(title, message); - }); + if (!m_signalsConnected) { + m_signalsConnected = true; + + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) { + Q_EMIT downloadStatusChanged(downloaded, total, speed); + }); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::sessionStatusChanged, this, &WaydroidDBusClient::updateSessionStatus); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::systemTypeChanged, this, &WaydroidDBusClient::updateSystemType); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ipAddressChanged, this, &WaydroidDBusClient::updateIpAddress); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::androidIdChanged, this, &WaydroidDBusClient::updateAndroidId); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeTouchChanged, this, &WaydroidDBusClient::updateFakeTouch); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeWifiChanged, this, &WaydroidDBusClient::updateFakeWifi); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) { + Q_EMIT actionFinished(message); + }); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFailed, this, [this](const QString message) { + Q_EMIT actionFailed(message); + }); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) { + Q_EMIT errorOccurred(title, message); + }); + } initializeApplicationListModel(); updateStatus(); @@ -74,6 +93,118 @@ void WaydroidDBusClient::connectSignals() updateMultiWindows(); updateSuspend(); updateUevent(); + updateFakeTouch(); + updateFakeWifi(); +} + +void WaydroidDBusClient::checkWaydroidObject() +{ + if (m_connectionCheckPending) { + return; + } + + m_connectionCheckPending = true; + + const QDBusMessage message = + QDBusMessage::createMethodCall(u"org.kde.plasmashell"_s, u"/Waydroid"_s, u"org.freedesktop.DBus.Introspectable"_s, u"Introspect"_s); + auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, &WaydroidDBusClient::onWaydroidObjectCheckFinished); +} + +void WaydroidDBusClient::onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher) +{ + m_connectionCheckPending = false; + + QDBusPendingReply reply = *watcher; + if (!reply.isValid()) { + m_connected = false; + watcher->deleteLater(); + resetState(); + scheduleWaydroidObjectCheck(); + return; + } + + m_connected = true; + watcher->deleteLater(); + + if (m_interface->isValid()) { + connectSignals(); + } +} + +void WaydroidDBusClient::handleUnavailableReply() +{ + if (!m_connected) { + return; + } + + m_connected = false; + resetState(); + scheduleWaydroidObjectCheck(); +} + +void WaydroidDBusClient::resetState() +{ + if (m_status != NotSupported) { + m_status = NotSupported; + Q_EMIT statusChanged(); + } + + if (m_sessionStatus != SessionStopped) { + m_sessionStatus = SessionStopped; + Q_EMIT sessionStatusChanged(); + } + + if (m_systemType != UnknownSystemType) { + m_systemType = UnknownSystemType; + Q_EMIT systemTypeChanged(); + } + + if (!m_ipAddress.isEmpty()) { + m_ipAddress.clear(); + Q_EMIT ipAddressChanged(); + } + + if (!m_androidId.isEmpty()) { + m_androidId.clear(); + Q_EMIT androidIdChanged(); + } + + if (m_multiWindows) { + m_multiWindows = false; + Q_EMIT multiWindowsChanged(); + } + + if (m_suspend) { + m_suspend = false; + Q_EMIT suspendChanged(); + } + + if (m_uevent) { + m_uevent = false; + Q_EMIT ueventChanged(); + } + + if (!m_fakeTouch.isEmpty()) { + m_fakeTouch.clear(); + Q_EMIT fakeTouchChanged(); + } + + if (!m_fakeWifi.isEmpty()) { + m_fakeWifi.clear(); + Q_EMIT fakeWifiChanged(); + } + + m_applicationListModel->clearApplications(); +} + +void WaydroidDBusClient::scheduleWaydroidObjectCheck() +{ + if (!QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) { + return; + } + + QTimer::singleShot(1000, this, &WaydroidDBusClient::checkWaydroidObject); } void WaydroidDBusClient::initializeApplicationListModel() @@ -85,6 +216,7 @@ void WaydroidDBusClient::initializeApplicationListModel() QDBusPendingReply> reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch applications:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -133,6 +265,10 @@ WaydroidApplicationListModel *WaydroidDBusClient::applicationListModel() const QCoro::Task WaydroidDBusClient::setMultiWindowsTask(const bool multiWindows) { + if (!m_connected) { + co_return; + } + co_await m_interface->setMultiWindows(multiWindows); } @@ -148,6 +284,10 @@ bool WaydroidDBusClient::multiWindows() const QCoro::Task WaydroidDBusClient::setSuspendTask(const bool suspend) { + if (!m_connected) { + co_return; + } + co_await m_interface->setSuspend(suspend); } @@ -163,6 +303,10 @@ bool WaydroidDBusClient::suspend() const QCoro::Task WaydroidDBusClient::setUeventTask(const bool uevent) { + if (!m_connected) { + co_return; + } + co_await m_interface->setUevent(uevent); } @@ -173,6 +317,10 @@ QCoro::QmlTask WaydroidDBusClient::setUevent(const bool uevent) QCoro::Task WaydroidDBusClient::refreshSessionInfoTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->refreshSessionInfo(); } @@ -183,6 +331,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshSessionInfo() QCoro::Task WaydroidDBusClient::refreshAndroidIdTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->refreshAndroidId(); } @@ -193,6 +345,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshAndroidId() QCoro::Task WaydroidDBusClient::refreshApplicationsTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->refreshApplications(); } @@ -201,13 +357,87 @@ QCoro::QmlTask WaydroidDBusClient::refreshApplications() return refreshApplicationsTask(); } +bool WaydroidDBusClient::gameShellEnabledForPackage(const QString &packageName) const +{ + return m_gameShellPackages.contains(packageName); +} + +void WaydroidDBusClient::setGameShellEnabledForPackage(const QString &packageName, bool enabled) +{ + QStringList packages = m_gameShellPackages; + packages.removeAll(packageName); + if (enabled) { + packages.append(packageName); + } + packages.removeDuplicates(); + packages.sort(); + + if (packages == m_gameShellPackages) { + return; + } + + KConfigGroup group(m_config, s_waydroidGamingGroup); + group.writeEntry(s_gameShellPackagesKey, packages, KConfigGroup::Notify); + m_config->sync(); + + m_gameShellPackages = packages; + Q_EMIT gameShellPackagesChanged(); +} + bool WaydroidDBusClient::uevent() const { return m_uevent; } +QCoro::Task WaydroidDBusClient::setFakeTouchTask(const QString &fakeTouch) +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->setFakeTouch(fakeTouch); +} + +QCoro::QmlTask WaydroidDBusClient::setFakeTouch(const QString &fakeTouch) +{ + return setFakeTouchTask(fakeTouch); +} + +QString WaydroidDBusClient::fakeTouch() const +{ + return m_fakeTouch; +} + +QCoro::Task WaydroidDBusClient::setFakeWifiTask(const QString &fakeWifi) +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->setFakeWifi(fakeWifi); +} + +QCoro::QmlTask WaydroidDBusClient::setFakeWifi(const QString &fakeWifi) +{ + return setFakeWifiTask(fakeWifi); +} + +QString WaydroidDBusClient::fakeWifi() const +{ + return m_fakeWifi; +} + +QStringList WaydroidDBusClient::gameShellPackages() const +{ + return m_gameShellPackages; +} + QCoro::Task WaydroidDBusClient::initializeTask(const SystemType systemType, const RomType romType, const bool forced) { + if (!m_connected) { + co_return; + } + co_await m_interface->initialize(systemType, romType, forced); } @@ -218,6 +448,10 @@ QCoro::QmlTask WaydroidDBusClient::initialize(const SystemType systemType, const QCoro::Task WaydroidDBusClient::startSessionTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->startSession(); } @@ -228,6 +462,10 @@ QCoro::QmlTask WaydroidDBusClient::startSession() QCoro::Task WaydroidDBusClient::stopSessionTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->stopSession(); } @@ -238,6 +476,10 @@ QCoro::QmlTask WaydroidDBusClient::stopSession() QCoro::Task WaydroidDBusClient::resetWaydroidTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->resetWaydroid(); } @@ -248,6 +490,10 @@ QCoro::QmlTask WaydroidDBusClient::resetWaydroid() QCoro::Task WaydroidDBusClient::installApkTask(const QString apkFile) { + if (!m_connected) { + co_return; + } + co_await m_interface->installApk(apkFile); } @@ -256,8 +502,26 @@ QCoro::QmlTask WaydroidDBusClient::installApk(const QString apkFile) return installApkTask(apkFile); } +QCoro::Task WaydroidDBusClient::launchApplicationTask(const QString appId) +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->launchApplication(appId); +} + +QCoro::QmlTask WaydroidDBusClient::launchApplication(const QString appId) +{ + return launchApplicationTask(appId); +} + QCoro::Task WaydroidDBusClient::deleteApplicationTask(const QString appId) { + if (!m_connected) { + co_return; + } + co_await m_interface->deleteApplication(appId); } @@ -266,6 +530,20 @@ QCoro::QmlTask WaydroidDBusClient::deleteApplication(const QString appId) return deleteApplicationTask(appId); } +QCoro::Task WaydroidDBusClient::refreshSupportsInfoTask() +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->refreshSupportsInfo(); +} + +QCoro::QmlTask WaydroidDBusClient::refreshSupportsInfo() +{ + return refreshSupportsInfoTask(); +} + void WaydroidDBusClient::updateStatus() { auto reply = m_interface->status(); @@ -275,6 +553,7 @@ void WaydroidDBusClient::updateStatus() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch status:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -299,6 +578,7 @@ void WaydroidDBusClient::updateSessionStatus() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch sessionStatus:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -323,6 +603,7 @@ void WaydroidDBusClient::updateSystemType() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch systemType:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -347,6 +628,7 @@ void WaydroidDBusClient::updateIpAddress() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch ipAddress:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -371,6 +653,7 @@ void WaydroidDBusClient::updateAndroidId() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch androidId:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -395,6 +678,7 @@ void WaydroidDBusClient::updateMultiWindows() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch multiWindows:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -419,6 +703,7 @@ void WaydroidDBusClient::updateSuspend() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch suspend:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -443,6 +728,7 @@ void WaydroidDBusClient::updateUevent() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch uevent:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -458,7 +744,72 @@ void WaydroidDBusClient::updateUevent() }); } +void WaydroidDBusClient::updateFakeTouch() +{ + auto reply = m_interface->fakeTouch(); + auto watcher = new QDBusPendingCallWatcher(reply, this); + + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) { + QDBusPendingReply reply = *watcher; + if (!reply.isValid()) { + qDebug() << "WaydroidDBusClient: Failed to fetch fakeTouch:" << reply.error().message(); + handleUnavailableReply(); + watcher->deleteLater(); + return; + } + + const QString fakeTouch = reply.argumentAt<0>(); + + if (m_fakeTouch != fakeTouch) { + m_fakeTouch = fakeTouch; + Q_EMIT fakeTouchChanged(); + } + + watcher->deleteLater(); + }); +} + +void WaydroidDBusClient::updateFakeWifi() +{ + auto reply = m_interface->fakeWifi(); + auto watcher = new QDBusPendingCallWatcher(reply, this); + + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) { + QDBusPendingReply reply = *watcher; + if (!reply.isValid()) { + qDebug() << "WaydroidDBusClient: Failed to fetch fakeWifi:" << reply.error().message(); + handleUnavailableReply(); + watcher->deleteLater(); + return; + } + + const QString fakeWifi = reply.argumentAt<0>(); + + if (m_fakeWifi != fakeWifi) { + m_fakeWifi = fakeWifi; + Q_EMIT fakeWifiChanged(); + } + + watcher->deleteLater(); + }); +} + void WaydroidDBusClient::copyToClipboard(const QString text) { qGuiApp->clipboard()->setText(text); } + +void WaydroidDBusClient::reloadGameShellPackages() +{ + const KConfigGroup group(m_config, s_waydroidGamingGroup); + QStringList packages = group.readEntry(s_gameShellPackagesKey, QStringList{}); + packages.removeDuplicates(); + packages.sort(); + + if (m_gameShellPackages == packages) { + return; + } + + m_gameShellPackages = packages; + Q_EMIT gameShellPackagesChanged(); +} diff --git a/components/waydroidintegrationplugin/waydroiddbusclient.h b/components/waydroidintegrationplugin/waydroiddbusclient.h index 1b707936..7836f2f0 100644 --- a/components/waydroidintegrationplugin/waydroiddbusclient.h +++ b/components/waydroidintegrationplugin/waydroiddbusclient.h @@ -10,8 +10,12 @@ #include "waydroidapplicationlistmodel.h" #include "waydroiddbusobject.h" +#include +#include + #include #include +#include #include #include #include @@ -32,6 +36,9 @@ class WaydroidDBusClient : public QObject Q_PROPERTY(bool multiWindows READ multiWindows WRITE setMultiWindows NOTIFY multiWindowsChanged) Q_PROPERTY(bool suspend READ suspend WRITE setSuspend NOTIFY suspendChanged) Q_PROPERTY(bool uevent READ uevent WRITE setUevent NOTIFY ueventChanged) + Q_PROPERTY(QString fakeTouch READ fakeTouch WRITE setFakeTouch NOTIFY fakeTouchChanged) + Q_PROPERTY(QString fakeWifi READ fakeWifi WRITE setFakeWifi NOTIFY fakeWifiChanged) + Q_PROPERTY(QStringList gameShellPackages READ gameShellPackages NOTIFY gameShellPackagesChanged) Q_PROPERTY(WaydroidApplicationListModel *applicationListModel READ applicationListModel CONSTANT) public: @@ -98,16 +105,25 @@ public: QCoro::QmlTask setSuspend(const bool suspend); [[nodiscard]] bool uevent() const; QCoro::QmlTask setUevent(const bool uevent); + [[nodiscard]] QString fakeTouch() const; + QCoro::QmlTask setFakeTouch(const QString &fakeTouch); + [[nodiscard]] QString fakeWifi() const; + QCoro::QmlTask setFakeWifi(const QString &fakeWifi); + [[nodiscard]] QStringList gameShellPackages() const; Q_INVOKABLE QCoro::QmlTask initialize(const SystemType systemType, const RomType romType, const bool forced = false); Q_INVOKABLE QCoro::QmlTask startSession(); Q_INVOKABLE QCoro::QmlTask stopSession(); Q_INVOKABLE QCoro::QmlTask resetWaydroid(); Q_INVOKABLE QCoro::QmlTask installApk(const QString apkFile); + Q_INVOKABLE QCoro::QmlTask launchApplication(const QString appId); Q_INVOKABLE QCoro::QmlTask deleteApplication(const QString appId); + Q_INVOKABLE QCoro::QmlTask refreshSupportsInfo(); Q_INVOKABLE QCoro::QmlTask refreshSessionInfo(); Q_INVOKABLE QCoro::QmlTask refreshAndroidId(); Q_INVOKABLE QCoro::QmlTask refreshApplications(); + Q_INVOKABLE bool gameShellEnabledForPackage(const QString &packageName) const; + Q_INVOKABLE void setGameShellEnabledForPackage(const QString &packageName, bool enabled); Q_INVOKABLE void copyToClipboard(const QString text); @@ -122,6 +138,9 @@ Q_SIGNALS: void multiWindowsChanged(); void suspendChanged(); void ueventChanged(); + void fakeTouchChanged(); + void fakeWifiChanged(); + void gameShellPackagesChanged(); void actionFinished(const QString message); void actionFailed(const QString message); @@ -136,12 +155,14 @@ private Q_SLOTS: void updateMultiWindows(); void updateSuspend(); void updateUevent(); + void updateFakeTouch(); + void updateFakeWifi(); private: OrgKdePlasmashellWaydroidInterface *m_interface; QDBusServiceWatcher *m_watcher; - Status m_status{NotInitialized}; + Status m_status{NotSupported}; SessionStatus m_sessionStatus{SessionStopped}; SystemType m_systemType{UnknownSystemType}; QString m_ipAddress{""}; @@ -152,22 +173,40 @@ private: bool m_multiWindows{false}; bool m_suspend{false}; bool m_uevent{false}; + QString m_fakeTouch; + QString m_fakeWifi; + KSharedConfig::Ptr m_config; + KConfigWatcher::Ptr m_configWatcher; + QStringList m_gameShellPackages; bool m_connected{false}; + bool m_signalsConnected{false}; + bool m_connectionCheckPending{false}; void connectSignals(); void initializeApplicationListModel(); + void checkWaydroidObject(); + void handleUnavailableReply(); + void onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher); + void resetState(); + void scheduleWaydroidObjectCheck(); QCoro::Task initializeTask(const SystemType systemType, const RomType romType, const bool forced = false); QCoro::Task startSessionTask(); QCoro::Task stopSessionTask(); QCoro::Task resetWaydroidTask(); QCoro::Task installApkTask(const QString apkFile); + QCoro::Task launchApplicationTask(const QString appId); QCoro::Task deleteApplicationTask(const QString appId); + QCoro::Task refreshSupportsInfoTask(); QCoro::Task setMultiWindowsTask(const bool multiWindows); QCoro::Task setSuspendTask(const bool suspend); QCoro::Task setUeventTask(const bool uevent); + QCoro::Task setFakeTouchTask(const QString &fakeTouch); + QCoro::Task setFakeWifiTask(const QString &fakeWifi); QCoro::Task refreshSessionInfoTask(); QCoro::Task refreshAndroidIdTask(); QCoro::Task refreshApplicationsTask(); + + void reloadGameShellPackages(); }; \ No newline at end of file diff --git a/components/waydroidintegrationplugin/waydroiddbusobject.cpp b/components/waydroidintegrationplugin/waydroiddbusobject.cpp index 17ae31f5..686580ac 100644 --- a/components/waydroidintegrationplugin/waydroiddbusobject.cpp +++ b/components/waydroidintegrationplugin/waydroiddbusobject.cpp @@ -31,6 +31,8 @@ using namespace Qt::StringLiterals; #define MULTI_WINDOWS_PROP_KEY "persist.waydroid.multi_windows" #define SUSPEND_PROP_KEY "persist.waydroid.suspend" #define UEVENT_PROP_KEY "persist.waydroid.uevent" +#define FAKE_TOUCH_PROP_KEY "persist.waydroid.fake_touch" +#define FAKE_WIFI_PROP_KEY "persist.waydroid.fake_wifi" static const QRegularExpression sessionRegExp(u"Session:\\s*(\\w+)"_s); static const QRegularExpression ipAddressRegExp(u"IP address:\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)"_s); @@ -179,10 +181,12 @@ void WaydroidDBusObject::stopSession() connect(process, &QProcess::finished, this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) { Q_UNUSED(exitStatus); + const QByteArray errorLog = process->readAllStandardError(); process->deleteLater(); - if (exitCode == 0) { - qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << process->readAllStandardError(); + if (exitCode != 0) { + Q_EMIT errorOccurred(i18n("Failed to stop the Waydroid session."), QString::fromUtf8(errorLog)); + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << errorLog; return; } @@ -250,6 +254,27 @@ void WaydroidDBusObject::installApk(const QString apkFile) process->start(WAYDROID_COMMAND, arguments); } +void WaydroidDBusObject::launchApplication(const QString appId) +{ + const QStringList arguments{u"app"_s, u"launch"_s, appId}; + + QProcess *process = new QProcess(this); + + connect(process, &QProcess::finished, this, [this, appId, process](int exitCode, QProcess::ExitStatus exitStatus) { + const QByteArray errorLog = process->readAllStandardError(); + process->deleteLater(); + + if (exitCode == 0 && exitStatus == QProcess::NormalExit) { + Q_EMIT actionFinished(i18n("Application has been launched")); + } else { + Q_EMIT actionFailed(i18n("Application launch failed")); + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Error occurred while launching " << appId << ": " << errorLog; + } + }); + + process->start(WAYDROID_COMMAND, arguments); +} + void WaydroidDBusObject::deleteApplication(const QString appId) { const QStringList arguments{u"app"_s, u"remove"_s, appId}; @@ -375,6 +400,52 @@ void WaydroidDBusObject::setUevent(const bool uevent) coro(this, value, uevent); } +QString WaydroidDBusObject::fakeTouch() const +{ + return m_fakeTouch; +} + +void WaydroidDBusObject::setFakeTouch(const QString &fakeTouch) +{ + if (m_fakeTouch == fakeTouch) { + return; + } + + auto coro = [](WaydroidDBusObject *self, QString fakeTouch) -> QCoro::Task { + QPointer guard(self); + if (co_await self->writePropValue(FAKE_TOUCH_PROP_KEY, fakeTouch)) { + if (guard) { + self->m_fakeTouch = fakeTouch; + Q_EMIT self->fakeTouchChanged(); + } + } + }; + coro(this, fakeTouch); +} + +QString WaydroidDBusObject::fakeWifi() const +{ + return m_fakeWifi; +} + +void WaydroidDBusObject::setFakeWifi(const QString &fakeWifi) +{ + if (m_fakeWifi == fakeWifi) { + return; + } + + auto coro = [](WaydroidDBusObject *self, QString fakeWifi) -> QCoro::Task { + QPointer guard(self); + if (co_await self->writePropValue(FAKE_WIFI_PROP_KEY, fakeWifi)) { + if (guard) { + self->m_fakeWifi = fakeWifi; + Q_EMIT self->fakeWifiChanged(); + } + } + }; + coro(this, fakeWifi); +} + QList WaydroidDBusObject::applications() const { QList paths; @@ -513,6 +584,7 @@ void WaydroidDBusObject::refreshAndroidId() } } else { m_androidId = ""; + Q_EMIT actionFailed(i18n("Failed to fetch Android ID")); qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "KAuth returned an error code:" << executeJob->error(); } @@ -539,6 +611,12 @@ QCoro::Task WaydroidDBusObject::refreshPropsInfo() const QString ueventPropValue = co_await fetchPropValue(UEVENT_PROP_KEY, "false"); m_uevent = ueventPropValue == "true"; Q_EMIT ueventChanged(); + + m_fakeTouch = co_await fetchPropValue(FAKE_TOUCH_PROP_KEY, QString{}); + Q_EMIT fakeTouchChanged(); + + m_fakeWifi = co_await fetchPropValue(FAKE_WIFI_PROP_KEY, QString{}); + Q_EMIT fakeWifiChanged(); } QCoro::Task WaydroidDBusObject::fetchPropValue(const QString key, const QString defaultValue) diff --git a/components/waydroidintegrationplugin/waydroiddbusobject.h b/components/waydroidintegrationplugin/waydroiddbusobject.h index 82e2ba3c..c1c5d1fb 100644 --- a/components/waydroidintegrationplugin/waydroiddbusobject.h +++ b/components/waydroidintegrationplugin/waydroiddbusobject.h @@ -96,6 +96,8 @@ Q_SIGNALS: Q_SCRIPTABLE void multiWindowsChanged(); Q_SCRIPTABLE void suspendChanged(); Q_SCRIPTABLE void ueventChanged(); + Q_SCRIPTABLE void fakeTouchChanged(); + Q_SCRIPTABLE void fakeWifiChanged(); Q_SCRIPTABLE void applicationAdded(QDBusObjectPath path); Q_SCRIPTABLE void applicationRemoved(QDBusObjectPath path); @@ -119,6 +121,10 @@ public Q_SLOTS: Q_SCRIPTABLE void setSuspend(const bool suspend); Q_SCRIPTABLE bool uevent() const; Q_SCRIPTABLE void setUevent(const bool uevent); + Q_SCRIPTABLE QString fakeTouch() const; + Q_SCRIPTABLE void setFakeTouch(const QString &fakeTouch); + Q_SCRIPTABLE QString fakeWifi() const; + Q_SCRIPTABLE void setFakeWifi(const QString &fakeWifi); Q_SCRIPTABLE QList applications() const; Q_SCRIPTABLE void initialize(const int systemType, const int romType, const bool forced = false); @@ -126,7 +132,9 @@ public Q_SLOTS: Q_SCRIPTABLE void stopSession(); Q_SCRIPTABLE void resetWaydroid(); Q_SCRIPTABLE void installApk(const QString apkFile); + Q_SCRIPTABLE void launchApplication(const QString appId); Q_SCRIPTABLE void deleteApplication(const QString appId); + Q_SCRIPTABLE void refreshSupportsInfo(); Q_SCRIPTABLE void refreshSessionInfo(); Q_SCRIPTABLE void refreshAndroidId(); Q_SCRIPTABLE void refreshApplications(); @@ -143,8 +151,9 @@ private: bool m_multiWindows{false}; bool m_suspend{false}; bool m_uevent{false}; + QString m_fakeTouch; + QString m_fakeWifi; - void refreshSupportsInfo(); void refreshInstallationInfo(); QCoro::Task refreshPropsInfo(); diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index 2acc25a9..c27e5a96 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -76,6 +76,14 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES PATH settings ) +ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES + qml/gaming/GameCenterOverlay.qml + qml/gaming/GamingHUD.qml + qml/gaming/GamingQuickSettings.qml + qml/gaming/RunningGamesView.qml + PATH gaming +) + target_link_libraries(org.kde.plasma.mobile.homescreen.folio PRIVATE Qt::Gui Qt::Qml diff --git a/containments/homescreens/folio/qml/FavouritesBar.qml b/containments/homescreens/folio/qml/FavouritesBar.qml index 3e338c08..31f1be81 100644 --- a/containments/homescreens/folio/qml/FavouritesBar.qml +++ b/containments/homescreens/folio/qml/FavouritesBar.qml @@ -706,12 +706,20 @@ MouseArea { x: { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) - return Math.max(0, Math.min(Screen.width - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) + var win = targetDelegate.Window.window + var screenLeft = win && win.screen ? win.screen.virtualX : 0 + var screenRight = screenLeft + (win && win.screen ? win.screen.width : Screen.width) + var centered = delegateGlobal.x + (targetDelegate.width - width) / 2 + return Math.max(screenLeft, Math.min(screenRight - width, centered)) } y: { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) - return Math.max(0, Math.min(Screen.height - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) + var win = targetDelegate.Window.window + var screenTop = win && win.screen ? win.screen.virtualY : 0 + var screenBottom = screenTop + (win && win.screen ? win.screen.height : Screen.height) + var above = delegateGlobal.y - height - Kirigami.Units.smallSpacing + return Math.max(screenTop, Math.min(screenBottom - height, above)) } onShowingChanged: { diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml new file mode 100644 index 00000000..5df0a6b8 --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -0,0 +1,1389 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import QtQuick.Window + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell +import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness +import org.kde.layershell 1.0 as LayerShell +import org.kde.plasma.clock + +import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio + +Window { + id: root + + required property var folio + property int launchTaskBaseline: 0 + property var selectedGame: ({}) + property int recentRevision: 0 + + signal gameStarted() + signal dismissRequested() + + readonly property string actionButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonA) + readonly property string backButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonB) + readonly property string closeButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonX) + readonly property string exitButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonY) + readonly property string leftShoulderLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonLeftShoulder) + readonly property string rightShoulderLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonRightShoulder) + readonly property string quickSettingsButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonBack) + readonly property string searchButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonStart) + + function pulsePrimaryGamepad(lowIntensity, highIntensity, durationMs) { + var pad = GamingShell.GamepadManager.primaryGamepad + if (!pad || !pad.hasRumble) { + return + } + pad.rumble(lowIntensity, highIntensity, durationMs) + } + + function requestExitGamingMode() { + pulsePrimaryGamepad(9000, 15000, 60) + exitGamingDialog.active = true + exitGamingDialog.item.open() + } + + function launchGame(index) { + pulsePrimaryGamepad(14000, 22000, 80) + launchTaskBaseline = runningGames.taskCount + GamingShell.GameLauncherProvider.launch(index) + } + + function launchGameByStorageId(storageId) { + pulsePrimaryGamepad(14000, 22000, 80) + launchTaskBaseline = runningGames.taskCount + GamingShell.GameLauncherProvider.launchByStorageId(storageId) + } + + function openGameDetails(storageId) { + selectedGame = GamingShell.GameLauncherProvider.gameDetails(storageId) + if (!selectedGame.storageId || selectedGame.storageId.length === 0) { + selectedGame = ({}) + return + } + gameDetailsDialog.active = true + gameDetailsDialog.item.open() + } + + function focusRecentGames() { + if (recentList.count <= 0) { + return + } + if (recentList.currentIndex < 0) { + recentList.currentIndex = 0 + } + recentList.forceActiveFocus() + } + + function sourceDescription(source) { + switch (source) { + case "steam": + return i18n("Launches through the Steam protocol handler.") + case "lutris": + return i18n("Launches through the Lutris launcher.") + case "heroic": + return i18n("Launches through Heroic's protocol handler.") + case "waydroid": + return i18n("Launches through the exported Waydroid desktop entry.") + case "flatpak": + return i18n("Launches through its exported desktop entry.") + default: + return i18n("Launches through its desktop entry.") + } + } + + function sourceHint(source) { + switch (source) { + case "waydroid": + return i18n("Manage which Android titles appear here from the Waydroid applications page.") + case "steam": + return i18n("Steam entries come from your local Steam library manifests.") + case "lutris": + return i18n("Lutris entries come from the local Lutris library database.") + case "heroic": + return i18n("Heroic entries come from Heroic's local library cache.") + default: + return i18n("Desktop entries come from the application menu database.") + } + } + + function launchMethodDescription(method) { + switch (method) { + case "desktop-entry": + return i18n("Desktop entry") + case "protocol": + return i18n("Protocol handler") + case "command": + return i18n("Command line") + default: + return i18n("Unknown") + } + } + + function canOpenSourceApp(source) { + return source === "steam" || source === "lutris" || source === "heroic" + } + + function sourceAppActionLabel(source) { + switch (source) { + case "steam": + return i18n("Open Steam") + case "lutris": + return i18n("Open Lutris") + case "heroic": + return i18n("Open Heroic") + default: + return i18n("Open Source App") + } + } + + width: Screen.width + height: Screen.height + color: "transparent" + flags: Qt.FramelessWindowHint + + LayerShell.Window.scope: "gaming-overlay" + LayerShell.Window.layer: LayerShell.Window.LayerTop + LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorBottom + | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight + LayerShell.Window.exclusionZone: -1 + LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityOnDemand + + // Animate opacity on show/hide + opacity: visible ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + Connections { + target: GamingShell.GameLauncherProvider + + function onGameLaunched(name) { + launchFade.restart() + } + + function onGameLaunchFailed(name, error) { + launchErrorTimer.restart() + } + + function onRecentGamesChanged() { + root.recentRevision++ + } + } + + Timer { + id: launchErrorTimer + interval: 6000 + repeat: false + onTriggered: GamingShell.GameLauncherProvider.clearLastLaunchError() + } + + onVisibleChanged: { + if (visible) { + GamingShell.GameLauncherProvider.filterString = "" + GamingShell.GameLauncherProvider.sourceFilter = "" + GamingShell.GameLauncherProvider.refresh() + if (runningGames.hasTasks) { + runningGames.focusFirstTask() + } else { + grid.forceActiveFocus() + } + } + } + + // Translate gamepad buttons to focus navigation + Connections { + target: GamingShell.GamepadManager + enabled: root.visible + + function onButtonPressed(button, gamepadIndex) { + // Route to quick settings panel when open + if (quickSettings.opened) { + switch (button) { + case GamingShell.GamepadManager.ButtonDPadUp: + quickSettings.gamepadUp() + return + case GamingShell.GamepadManager.ButtonDPadDown: + quickSettings.gamepadDown() + return + case GamingShell.GamepadManager.ButtonDPadLeft: + quickSettings.gamepadLeft() + return + case GamingShell.GamepadManager.ButtonDPadRight: + quickSettings.gamepadRight() + return + case GamingShell.GamepadManager.ButtonA: + quickSettings.gamepadAccept() + return + case GamingShell.GamepadManager.ButtonB: + case GamingShell.GamepadManager.ButtonBack: + quickSettings.close() + return + } + return // eat all other buttons while panel is open + } + + switch (button) { + case GamingShell.GamepadManager.ButtonDPadUp: + if (grid.activeFocus) { + if (grid.currentIndex < grid.columns && runningGames.hasTasks) { + if (recentList.count > 0) { + root.focusRecentGames() + } else { + runningGames.focusFirstTask() + } + } else if (grid.currentIndex < grid.columns && recentList.count > 0) { + root.focusRecentGames() + } else { + grid.moveCurrentIndexUp() + } + } else if (recentList.activeFocus && runningGames.hasTasks) { + runningGames.focusFirstTask() + } + break + case GamingShell.GamepadManager.ButtonDPadDown: + if (runningGames.activeFocus) { + if (recentList.count > 0) { + root.focusRecentGames() + } else { + grid.forceActiveFocus() + } + } else if (recentList.activeFocus) { + grid.forceActiveFocus() + } else if (grid.activeFocus) { + grid.moveCurrentIndexDown() + } + break + case GamingShell.GamepadManager.ButtonDPadLeft: + if (recentList.activeFocus) { + recentList.decrementCurrentIndex() + } else if (grid.activeFocus) { + grid.moveCurrentIndexLeft() + } + break + case GamingShell.GamepadManager.ButtonDPadRight: + if (recentList.activeFocus) { + recentList.incrementCurrentIndex() + } else if (grid.activeFocus) { + grid.moveCurrentIndexRight() + } + break + case GamingShell.GamepadManager.ButtonA: + if (runningGames.activeFocus) { + runningGames.activateCurrent() + } else if (recentList.activeFocus && recentList.currentItem) { + root.launchGameByStorageId(recentList.currentItem.storageId) + } else if (grid.activeFocus && grid.currentItem) { + root.launchGame(grid.currentIndex) + } + break + case GamingShell.GamepadManager.ButtonX: + if (runningGames.activeFocus) { + runningGames.closeCurrent() + } else if (recentList.activeFocus && recentList.currentItem) { + root.openGameDetails(recentList.currentItem.storageId) + } else if (grid.activeFocus && grid.currentItem) { + grid.currentItem.showDetails() + } + break + case GamingShell.GamepadManager.ButtonB: + root.dismissRequested() + break + case GamingShell.GamepadManager.ButtonY: + root.requestExitGamingMode() + break + case GamingShell.GamepadManager.ButtonLeftShoulder: + root.cycleSourceFilter(-1) + break + case GamingShell.GamepadManager.ButtonRightShoulder: + root.cycleSourceFilter(1) + break + case GamingShell.GamepadManager.ButtonStart: + if (searchField.activeFocus) { + grid.forceActiveFocus() + } else { + searchField.forceActiveFocus() + } + break + case GamingShell.GamepadManager.ButtonBack: + quickSettings.toggle() + pulsePrimaryGamepad(7000, 11000, 40) + break + } + } + + function onAxisChanged(axis, value, gamepadIndex) { + if (axis === GamingShell.GamepadManager.AxisLeftX) { + stickState.leftX = value + } else if (axis === GamingShell.GamepadManager.AxisLeftY) { + stickState.leftY = value + } else if (axis === GamingShell.GamepadManager.AxisRightY) { + stickState.rightY = value + } + } + } + + // Left-stick navigation state + repeat timer + QtObject { + id: stickState + property real leftX: 0 + property real leftY: 0 + property real rightY: 0 + readonly property real deadzone: 0.4 + } + + function navigateByStick() { + // Route stick to quick settings when open + if (quickSettings.opened) { + if (stickState.leftY < -stickState.deadzone) { + quickSettings.gamepadUp() + } else if (stickState.leftY > stickState.deadzone) { + quickSettings.gamepadDown() + } + if (stickState.leftX < -stickState.deadzone) { + quickSettings.gamepadLeft() + } else if (stickState.leftX > stickState.deadzone) { + quickSettings.gamepadRight() + } + return + } + + if (stickState.leftY < -stickState.deadzone) { + if (grid.activeFocus) { + if (grid.currentIndex < grid.columns && runningGames.hasTasks) { + if (recentList.count > 0) { + root.focusRecentGames() + } else { + runningGames.focusFirstTask() + } + } else if (grid.currentIndex < grid.columns && recentList.count > 0) { + root.focusRecentGames() + } else { + grid.moveCurrentIndexUp() + } + } else if (recentList.activeFocus && runningGames.hasTasks) { + runningGames.focusFirstTask() + } + } else if (stickState.leftY > stickState.deadzone) { + if (runningGames.activeFocus) { + if (recentList.count > 0) { + root.focusRecentGames() + } else { + grid.forceActiveFocus() + } + } else if (recentList.activeFocus) { + grid.forceActiveFocus() + } else if (grid.activeFocus) { + grid.moveCurrentIndexDown() + } + } + if (stickState.leftX < -stickState.deadzone && recentList.activeFocus) { + recentList.decrementCurrentIndex() + } else if (stickState.leftX < -stickState.deadzone && grid.activeFocus) { + grid.moveCurrentIndexLeft() + } else if (stickState.leftX > stickState.deadzone && recentList.activeFocus) { + recentList.incrementCurrentIndex() + } else if (stickState.leftX > stickState.deadzone && grid.activeFocus) { + grid.moveCurrentIndexRight() + } + } + + Timer { + id: stickNavTimer + interval: 150 + repeat: true + running: root.visible + && (Math.abs(stickState.leftX) > stickState.deadzone + || Math.abs(stickState.leftY) > stickState.deadzone) + onRunningChanged: if (running) root.navigateByStick() + onTriggered: root.navigateByStick() + } + + // Right stick: smooth scroll the grid view + Timer { + id: stickScrollTimer + interval: 16 // ~60 Hz for smooth scrolling + repeat: true + running: root.visible && Math.abs(stickState.rightY) > stickState.deadzone + onTriggered: { + // Scale scroll speed with deflection, max ~12px per frame + grid.contentY = Math.max(grid.originY, + Math.min(grid.contentY + stickState.rightY * 12, + grid.contentHeight - grid.height)) + } + } + + // Cycle through source filter tabs. + readonly property var _sourceFilters: ["", "steam", "desktop", "waydroid", "lutris", "heroic"] + function cycleSourceFilter(direction) { + var current = _sourceFilters.indexOf( + GamingShell.GameLauncherProvider.sourceFilter) + if (current < 0) current = 0 + var next = (current + direction + _sourceFilters.length) + % _sourceFilters.length + GamingShell.GameLauncherProvider.sourceFilter = _sourceFilters[next] + sourceFilterBar.currentIndex = next + } + + function sourceLabel(source) { + switch (source) { + case "steam": + return i18n("Steam") + case "waydroid": + return i18n("Waydroid") + case "lutris": + return i18n("Lutris") + case "heroic": + return i18n("Heroic") + case "flatpak": + return i18n("Flatpak") + default: + return "" + } + } + + function sourceChipColor(source) { + switch (source) { + case "steam": + return Qt.rgba(0.12, 0.23, 0.38, 0.9) + case "waydroid": + return Qt.rgba(0.13, 0.42, 0.36, 0.92) + case "lutris": + return Qt.rgba(0.42, 0.25, 0.11, 0.9) + case "heroic": + return Qt.rgba(0.37, 0.19, 0.16, 0.9) + case "flatpak": + return Qt.rgba(0.16, 0.26, 0.46, 0.9) + default: + return Qt.rgba(0.2, 0.2, 0.2, 0.72) + } + } + + Rectangle { + anchors.fill: parent + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Window + color: Qt.rgba(Kirigami.Theme.backgroundColor.r, + Kirigami.Theme.backgroundColor.g, + Kirigami.Theme.backgroundColor.b, 0.92) + } + + FocusScope { + id: contentRoot + anchors.fill: parent + focus: root.visible + + // Escape only dismisses the overlay; exiting gaming mode is explicit. + Keys.onEscapePressed: root.dismissRequested() + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing * 2 + spacing: Kirigami.Units.largeSpacing + + // ---- header ---- + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing + + Kirigami.Heading { + text: i18n("Game Center") + level: 1 + } + + Item { Layout.fillWidth: true } + + // ---- system status indicators ---- + RowLayout { + spacing: Kirigami.Units.smallSpacing + Layout.alignment: Qt.AlignVCenter + + Clock { id: wallClock } + + PC3.Label { + text: Qt.formatTime(wallClock.dateTime, + MobileShell.ShellUtil.isSystem24HourFormat ? "h:mm" : "h:mm ap") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.9 + opacity: 0.8 + } + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: MobileShell.AudioInfo.icon + visible: MobileShell.AudioInfo.isVisible + opacity: 0.7 + } + + MobileShell.InternetIndicator { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + opacity: 0.7 + } + + MobileShell.BluetoothIndicator { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + opacity: 0.7 + } + + MobileShell.BatteryIndicator { + textPixelSize: Kirigami.Units.gridUnit * 0.55 + opacity: 0.7 + } + } + + // ---- quick settings button ---- + QQC2.ToolButton { + icon.name: "configure" + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Quick Settings") + onClicked: quickSettings.open() + } + + QQC2.ToolButton { + icon.name: "window-close" + text: i18n("Exit Gaming Mode") + display: QQC2.AbstractButton.TextBesideIcon + Keys.onReturnPressed: clicked() + Keys.onEnterPressed: clicked() + onClicked: root.requestExitGamingMode() + } + } + + // ---- running games ---- + RunningGamesView { + id: runningGames + Layout.fillWidth: true + onTaskActivated: { + GamingShell.GameLauncherProvider.clearPendingLaunch() + root.gameStarted() + } + onMoveDownRequested: grid.forceActiveFocus() + onTaskCountChanged: { + if (GamingShell.GameLauncherProvider.launchPending + && taskCount > root.launchTaskBaseline) { + GamingShell.GameLauncherProvider.clearPendingLaunch() + } + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Error + text: GamingShell.GameLauncherProvider.lastLaunchError + visible: text.length > 0 + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.GameLauncherProvider.launchPending + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "system-run" + } + + PC3.Label { + Layout.fillWidth: true + text: i18n("Launching %1…", GamingShell.GameLauncherProvider.pendingLaunchName) + opacity: 0.75 + } + } + + // ---- continue playing ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: recentList.count > 0 + + Kirigami.Heading { + level: 2 + text: i18n("Continue Playing") + } + + ListView { + id: recentList + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 5 + orientation: ListView.Horizontal + spacing: Kirigami.Units.largeSpacing + clip: true + keyNavigationEnabled: true + + model: root.visible ? (root.recentRevision, GamingShell.GameLauncherProvider.recentGames(5)) : [] + + function activateCurrentRecent() { + if (currentItem) { + root.launchGameByStorageId(currentItem.storageId) + } + } + + function showCurrentRecentDetails() { + if (currentItem) { + root.openGameDetails(currentItem.storageId) + } + } + + onActiveFocusChanged: { + if (activeFocus && count > 0 && currentIndex < 0) { + currentIndex = 0 + } + } + + Keys.onLeftPressed: decrementCurrentIndex() + Keys.onRightPressed: incrementCurrentIndex() + Keys.onReturnPressed: activateCurrentRecent() + Keys.onEnterPressed: activateCurrentRecent() + Keys.onUpPressed: { + if (runningGames.hasTasks) { + runningGames.focusFirstTask() + } + } + Keys.onDownPressed: grid.forceActiveFocus() + + delegate: QQC2.ItemDelegate { + width: Kirigami.Units.gridUnit * 7 + height: recentList.height + + required property var modelData + readonly property string storageId: modelData.storageId || "" + readonly property bool isCurrent: ListView.isCurrentItem && recentList.activeFocus + + readonly property bool hasArt: modelData.artwork && modelData.artwork.length > 0 + + HoverHandler { id: tileHover } + + background: Rectangle { + radius: Kirigami.Units.cornerRadius + color: parent.isCurrent + ? Kirigami.Theme.highlightColor + : (parent.hovered ? Kirigami.Theme.hoverColor : "transparent") + } + + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing + + Image { + Layout.fillWidth: true + Layout.fillHeight: true + source: hasArt ? "file://" + modelData.artwork : "" + fillMode: Image.PreserveAspectCrop + visible: hasArt + asynchronous: true + } + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.large + implicitHeight: Kirigami.Units.iconSizes.large + source: modelData.icon + visible: !hasArt + } + + PC3.Label { + Layout.fillWidth: true + text: modelData.name + maximumLineCount: 1 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + color: parent.parent.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor + } + } + + onClicked: root.launchGameByStorageId(modelData.storageId) + + QQC2.ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing + visible: tileHover.hovered || parent.isCurrent + icon.name: "documentinfo" + display: QQC2.AbstractButton.IconOnly + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Details") + + onClicked: root.openGameDetails(parent.storageId) + } + } + } + } + + // ---- search + filter ---- + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing + + Kirigami.SearchField { + id: searchField + Layout.fillWidth: true + placeholderText: i18n("Search games…") + onTextChanged: GamingShell.GameLauncherProvider.filterString = text + + Keys.onEscapePressed: { + if (text.length > 0) { + clear() + } else { + root.dismissRequested() + } + } + Keys.onDownPressed: grid.forceActiveFocus() + } + + QQC2.TabBar { + id: sourceFilterBar + Layout.alignment: Qt.AlignVCenter + + QQC2.TabButton { + text: i18n("All") + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "" + } + QQC2.TabButton { + text: "Steam" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "steam" + } + QQC2.TabButton { + text: i18n("Desktop") + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop" + } + QQC2.TabButton { + text: i18n("Waydroid") + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "waydroid" + } + QQC2.TabButton { + text: "Lutris" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "lutris" + } + QQC2.TabButton { + text: "Heroic" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "heroic" + } + } + } + + // ---- game grid ---- + + GridView { + id: grid + + Layout.fillWidth: true + Layout.fillHeight: true + + model: GamingShell.GameLauncherProvider + + readonly property real minCellSize: Kirigami.Units.gridUnit * 8 + readonly property int columns: Math.max(2, Math.floor(width / minCellSize)) + + cellWidth: Math.floor(width / columns) + cellHeight: Math.floor(cellWidth * 1.5) + Kirigami.Units.gridUnit * 2 + + keyNavigationEnabled: true + highlightMoveDuration: 0 + highlight: null + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 4 + visible: grid.count === 0 && !GamingShell.GameLauncherProvider.loading + icon.name: "games-none" + text: searchField.text.length > 0 + ? i18n("No games match your search") + : i18n("No games found") + explanation: searchField.text.length > 0 + ? "" + : i18n("Install games, or enable supported Waydroid apps from the Waydroid applications page") + } + + onActiveFocusChanged: { + if (activeFocus && count > 0 && currentIndex < 0) { + currentIndex = 0 + } + } + + Keys.onUpPressed: { + if (runningGames.hasTasks) { + runningGames.focusFirstTask() + } + } + + Keys.onReturnPressed: { + if (currentIndex >= 0) { + root.launchGame(currentIndex) + } + } + Keys.onEnterPressed: Keys.onReturnPressed(event) + Keys.onEscapePressed: root.dismissRequested() + Keys.onMenuPressed: { + if (currentIndex >= 0) { + root.openGameDetails(currentItem.storageId) + } + } + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_I) && currentIndex >= 0) { + root.openGameDetails(currentItem.storageId) + event.accepted = true + } + } + + delegate: Item { + width: grid.cellWidth + height: grid.cellHeight + + required property int index + required property string name + required property string icon + required property string source + required property string artwork + required property string storageId + required property string launchMethod + required property string lastPlayedText + required property bool pinned + + readonly property bool hasArt: artwork.length > 0 + readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus + + HoverHandler { id: gridTileHover } + + function showDetails() { + root.openGameDetails(storageId) + } + + QQC2.ItemDelegate { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + padding: 0 + + readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus + + background: Rectangle { + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: parent.isCurrent + ? Kirigami.Theme.highlightColor + : (parent.hovered ? Kirigami.Theme.hoverColor : "transparent") + radius: Kirigami.Units.cornerRadius + } + + contentItem: Item { + // ---- cover art tile ---- + ColumnLayout { + anchors.fill: parent + spacing: 0 + visible: hasArt + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Kirigami.Units.cornerRadius + clip: true + color: "transparent" + + Image { + anchors.fill: parent + source: hasArt ? "file://" + artwork : "" + fillMode: Image.PreserveAspectCrop + smooth: true + asynchronous: true + } + + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: Kirigami.Units.smallSpacing + visible: source !== "desktop" + radius: height / 2 + color: root.sourceChipColor(source) + implicitHeight: chipLabel.implicitHeight + Kirigami.Units.smallSpacing + implicitWidth: chipLabel.implicitWidth + Kirigami.Units.largeSpacing + + PC3.Label { + id: chipLabel + anchors.centerIn: parent + text: root.sourceLabel(source) + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.72 + font.weight: Font.DemiBold + color: "white" + } + } + } + + // Title beneath artwork + PC3.Label { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + text: name + maximumLineCount: 1 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + leftPadding: Kirigami.Units.smallSpacing + rightPadding: Kirigami.Units.smallSpacing + color: parent.parent.parent.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor + } + } + + // ---- fallback icon tile ---- + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + visible: !hasArt + spacing: Kirigami.Units.smallSpacing + + Item { Layout.fillHeight: true } + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.huge + implicitHeight: Kirigami.Units.iconSizes.huge + source: icon + + scale: parent.parent.parent.isCurrent ? 1.08 : 1.0 + Behavior on scale { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + } + + PC3.Label { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: name + maximumLineCount: 2 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + color: parent.parent.parent.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor + } + + Rectangle { + Layout.alignment: Qt.AlignHCenter + visible: source !== "desktop" + radius: height / 2 + color: root.sourceChipColor(source) + implicitHeight: sourceChipLabel.implicitHeight + Kirigami.Units.smallSpacing + implicitWidth: sourceChipLabel.implicitWidth + Kirigami.Units.largeSpacing + + PC3.Label { + id: sourceChipLabel + anchors.centerIn: parent + text: root.sourceLabel(source) + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.72 + font.weight: Font.DemiBold + color: "white" + } + } + + Item { Layout.fillHeight: true } + } + } + + onClicked: root.launchGame(index) + } + + QQC2.ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing * 1.5 + visible: gridTileHover.hovered + icon.name: "documentinfo" + display: QQC2.AbstractButton.IconOnly + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Details") + + onClicked: parent.showDetails() + } + + Kirigami.Icon { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: Kirigami.Units.smallSpacing * 1.5 + visible: pinned + source: "starred" + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + } + } + } + + // ---- controller status bar ---- + RowLayout { + Layout.fillWidth: true + visible: GamingShell.GamepadManager.hasGamepad + spacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "input-gaming" + } + + Repeater { + model: GamingShell.GamepadManager + + RowLayout { + spacing: Kirigami.Units.smallSpacing + required property string name + required property int battery + required property string type + required property var device + + PC3.Label { + text: name + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + } + PC3.Label { + text: battery >= 0 ? battery + "%" : "" + visible: battery >= 0 + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + opacity: 0.7 + } + PC3.Label { + text: device.touchpadCount > 0 ? i18n("Touchpad") : "" + visible: device.touchpadCount > 0 + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + opacity: 0.6 + } + PC3.Label { + text: device.hasGyro ? i18n("Gyro") : "" + visible: device.hasGyro + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + opacity: 0.6 + } + } + } + + Item { Layout.fillWidth: true } + + // Gamepad legend + PC3.Label { + text: runningGames.activeFocus + ? i18n("%1: Select %2: Close %3: Back %4: Exit %5: Settings %6: Search", + actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, + quickSettingsButtonLabel, searchButtonLabel) + : recentList.activeFocus + ? i18n("%1: Play %2: Details %3: Back %4: Exit %5: Settings %6: Search", + actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, + quickSettingsButtonLabel, searchButtonLabel) + : i18n("%1: Play %2: Details %3: Back %4: Exit %5/%6: Filter %7: Settings %8: Search", + actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, + leftShoulderLabel, rightShoulderLabel, quickSettingsButtonLabel, searchButtonLabel) + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 + opacity: 0.5 + } + } + } + } + + // Quick settings slide-out panel + GamingQuickSettings { + id: quickSettings + z: 50 + } + + // Launch transition: brief fade to black, then dismiss + Rectangle { + id: launchCurtain + anchors.fill: parent + color: "black" + opacity: 0 + z: 100 + + Behavior on opacity { + NumberAnimation { duration: 250; easing.type: Easing.InQuad } + } + } + + Timer { + id: launchFade + interval: 300 + onTriggered: { + launchCurtain.opacity = 0 + root.gameStarted() + } + onRunningChanged: { + if (running) { + launchCurtain.opacity = 1 + } + } + } + + Loader { + id: gameDetailsDialog + active: false + anchors.fill: parent + + sourceComponent: Kirigami.PromptDialog { + id: theGameDetailsDialog + title: root.selectedGame.name || "" + subtitle: root.sourceLabel(root.selectedGame.source || "") + standardButtons: Kirigami.Dialog.NoButton + + property int pgFpsLimit: root.selectedGame.perGameFpsLimit ?? -1 + property int pgOverlayState: root.selectedGame.perGameOverlayState ?? -1 + customFooterActions: [ + Kirigami.Action { + text: i18n("Close") + onTriggered: theGameDetailsDialog.close() + }, + Kirigami.Action { + visible: root.canOpenSourceApp(root.selectedGame.source || "") + text: root.sourceAppActionLabel(root.selectedGame.source || "") + onTriggered: { + if (GamingShell.GameLauncherProvider.openSourceApp(root.selectedGame.source || "")) { + theGameDetailsDialog.close() + root.gameStarted() + } + } + }, + Kirigami.Action { + text: (root.selectedGame.pinned || false) ? i18n("Unpin") : i18n("Pin to top") + onTriggered: { + GamingShell.GameLauncherProvider.togglePin(root.selectedGame.storageId || "") + theGameDetailsDialog.close() + } + }, + Kirigami.Action { + visible: (root.selectedGame.lastPlayedText || "").length > 0 + text: i18n("Remove from Continue Playing") + onTriggered: { + GamingShell.GameLauncherProvider.clearLastPlayed(root.selectedGame.storageId || "") + theGameDetailsDialog.close() + } + }, + Kirigami.Action { + text: i18n("Play") + enabled: (root.selectedGame.storageId || "").length > 0 + onTriggered: { + root.launchGameByStorageId(root.selectedGame.storageId) + theGameDetailsDialog.close() + } + } + ] + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing + + Rectangle { + Layout.preferredWidth: Kirigami.Units.gridUnit * 5 + Layout.preferredHeight: Kirigami.Units.gridUnit * 7 + radius: Kirigami.Units.cornerRadius + clip: true + color: Kirigami.Theme.alternateBackgroundColor + + Image { + anchors.fill: parent + source: root.selectedGame.artwork && root.selectedGame.artwork.length > 0 + ? "file://" + root.selectedGame.artwork : "" + fillMode: Image.PreserveAspectCrop + visible: source.length > 0 + asynchronous: true + } + + Kirigami.Icon { + anchors.centerIn: parent + visible: !parent.children[0].visible + source: root.selectedGame.icon || "games-config-options" + implicitWidth: Kirigami.Units.iconSizes.huge + implicitHeight: Kirigami.Units.iconSizes.huge + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Rectangle { + visible: (root.selectedGame.source || "") !== "desktop" + radius: height / 2 + color: root.sourceChipColor(root.selectedGame.source || "") + implicitHeight: sourceBadgeLabel.implicitHeight + Kirigami.Units.smallSpacing + implicitWidth: sourceBadgeLabel.implicitWidth + Kirigami.Units.largeSpacing + + PC3.Label { + id: sourceBadgeLabel + anchors.centerIn: parent + text: root.sourceLabel(root.selectedGame.source || "") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + font.weight: Font.DemiBold + color: "white" + } + } + + PC3.Label { + Layout.fillWidth: true + text: root.sourceDescription(root.selectedGame.source || "") + wrapMode: Text.WordWrap + } + + PC3.Label { + Layout.fillWidth: true + text: root.sourceHint(root.selectedGame.source || "") + wrapMode: Text.WordWrap + opacity: 0.75 + } + + PC3.Label { + Layout.fillWidth: true + text: i18n("Launch method: %1", root.launchMethodDescription(root.selectedGame.launchMethod || "")) + wrapMode: Text.WordWrap + opacity: 0.75 + } + + PC3.Label { + Layout.fillWidth: true + visible: (root.selectedGame.lastPlayedText || "").length > 0 + text: i18n("Last played: %1", root.selectedGame.lastPlayedText || "") + wrapMode: Text.WordWrap + opacity: 0.75 + } + + PC3.Label { + Layout.fillWidth: true + text: i18n("Identifier: %1", root.selectedGame.storageId || "") + wrapMode: Text.WrapAnywhere + opacity: 0.6 + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("FPS Cap") + opacity: 0.75 + Layout.alignment: Qt.AlignVCenter + } + + Item { Layout.fillWidth: true } + + QQC2.ButtonGroup { id: pgFpsCap; exclusive: true } + + Repeater { + model: [ + { label: i18n("Global"), fps: -1 }, + { label: i18nc("@action:button FPS cap off", "Off"), fps: 0 }, + { label: "30", fps: 30 }, + { label: "40", fps: 40 }, + { label: "60", fps: 60 } + ] + delegate: QQC2.Button { + required property var modelData + text: modelData.label + flat: true + checkable: true + checked: theGameDetailsDialog.pgFpsLimit === modelData.fps + QQC2.ButtonGroup.group: pgFpsCap + onClicked: { + theGameDetailsDialog.pgFpsLimit = modelData.fps + GamingShell.GameLauncherProvider.setPerGameFpsLimit( + root.selectedGame.storageId || "", modelData.fps) + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("Overlay") + opacity: 0.75 + Layout.alignment: Qt.AlignVCenter + } + + Item { Layout.fillWidth: true } + + QQC2.ButtonGroup { id: pgOverlayGroup; exclusive: true } + + Repeater { + model: [ + { label: i18n("Global"), state: -1 }, + { label: i18n("Off"), state: 0 }, + { label: i18n("On"), state: 1 } + ] + delegate: QQC2.Button { + required property var modelData + text: modelData.label + flat: true + checkable: true + checked: theGameDetailsDialog.pgOverlayState === modelData.state + enabled: modelData.state !== 1 || GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 + QQC2.ButtonGroup.group: pgOverlayGroup + onClicked: { + theGameDetailsDialog.pgOverlayState = modelData.state + GamingShell.GameLauncherProvider.setPerGameOverlayState( + root.selectedGame.storageId || "", modelData.state) + } + } + } + } + } + + onClosed: { + gameDetailsDialog.active = false + root.selectedGame = ({}) + } + } + } + + Loader { + id: exitGamingDialog + active: false + anchors.fill: parent + + sourceComponent: Kirigami.PromptDialog { + id: theExitDialog + title: i18n("Leave gaming mode?") + subtitle: i18n("Your games will keep running in the background.") + standardButtons: Kirigami.Dialog.NoButton + customFooterActions: [ + Kirigami.Action { + text: i18n("Keep Playing") + onTriggered: theExitDialog.close() + }, + Kirigami.Action { + text: i18n("Leave") + onTriggered: { + ShellSettings.Settings.gamingModeEnabled = false + theExitDialog.close() + } + } + ] + onClosed: exitGamingDialog.active = false + } + } +} diff --git a/containments/homescreens/folio/qml/gaming/GamingHUD.qml b/containments/homescreens/folio/qml/gaming/GamingHUD.qml new file mode 100644 index 00000000..e67b0342 --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GamingHUD.qml @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import QtQuick.Window + +import org.kde.kirigami as Kirigami +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell +import org.kde.layershell 1.0 as LayerShell + +Window { + id: root + + signal openRequested() + + // Guard against startup timing where Kirigami units may briefly be 0/NaN. + // LayerShell surfaces must never be committed with zero size. + readonly property real safeGridUnit: ((Kirigami.Units.gridUnit || 0) > 0) ? Kirigami.Units.gridUnit : 16 + + property string toastMessage: "" + property bool toastError: false + readonly property bool toastActive: toastMessage.length > 0 + + // Most-recently-played game for quick resume. Populated from recentGames(1) + // and refreshed whenever the recent list changes. + property var quickResumeGame: null + readonly property bool hasQuickResume: quickResumeGame !== null + + // Window grows leftward from top-right anchor: + // toast active → widest (needs room for message text) + // quick resume → medium (game name + controls) + // idle → compact (controls only) + width: toastActive ? safeGridUnit * 16 : (hasQuickResume ? safeGridUnit * 14 : safeGridUnit * 4) + height: toastActive ? safeGridUnit * 4 : safeGridUnit * 2 + color: "transparent" + flags: Qt.FramelessWindowHint + + Behavior on width { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } + } + Behavior on height { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } + } + + LayerShell.Window.scope: "gaming-hud" + LayerShell.Window.layer: LayerShell.Window.LayerOverlay + LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorRight + LayerShell.Window.exclusionZone: 0 + LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone + + // Driven by the Loader in folio/qml/main.qml — set false to fade out + // before the Loader destroys the window. + property bool showing: true + + opacity: showing ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + Timer { + id: toastTimer + onTriggered: root.toastMessage = "" + } + + Connections { + target: GamingShell.GameLauncherProvider + function onGameLaunched(name) { + root.toastMessage = i18n("Launching %1", name) + root.toastError = false + toastTimer.interval = 3000 + toastTimer.restart() + } + function onGameLaunchFailed(name, error) { + root.toastMessage = error.length > 0 ? error : i18n("Failed to launch %1", name) + root.toastError = true + toastTimer.interval = 5000 + toastTimer.restart() + } + function onRecentGamesChanged() { + const recent = GamingShell.GameLauncherProvider.recentGames(1) + root.quickResumeGame = recent.length > 0 ? recent[0] : null + } + } + + Component.onCompleted: { + const recent = GamingShell.GameLauncherProvider.recentGames(1) + root.quickResumeGame = recent.length > 0 ? recent[0] : null + } + + // ---- HUD pill (always visible, fills window width, grows leftward) ---- + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing + height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2 + radius: height / 2 + color: Qt.rgba(0, 0, 0, 0.55) + + RowLayout { + anchors { + fill: parent + leftMargin: Kirigami.Units.smallSpacing + rightMargin: Kirigami.Units.smallSpacing + } + spacing: 0 + + // Quick-resume section — only visible when a recent game exists + QQC2.ToolButton { + visible: root.hasQuickResume + icon.name: "media-playback-start" + icon.color: "white" + display: QQC2.AbstractButton.IconOnly + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: root.quickResumeGame ? i18n("Resume %1", root.quickResumeGame.name) : "" + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: { + if (root.quickResumeGame) { + GamingShell.GameLauncherProvider.launchByStorageId(root.quickResumeGame.storageId) + } + } + } + + QQC2.Label { + visible: root.hasQuickResume + Layout.fillWidth: true + text: root.quickResumeGame ? root.quickResumeGame.name : "" + color: "white" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + elide: Text.ElideRight + maximumLineCount: 1 + leftPadding: Kirigami.Units.smallSpacing + } + + // Separator between quick-resume and controls + Rectangle { + visible: root.hasQuickResume + width: 1 + implicitHeight: Kirigami.Units.gridUnit + color: Qt.rgba(1, 1, 1, 0.25) + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: Kirigami.Units.smallSpacing + Layout.rightMargin: Kirigami.Units.smallSpacing + } + + // Game Center button + QQC2.ToolButton { + icon.name: "input-gaming" + icon.color: "white" + display: QQC2.AbstractButton.IconOnly + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Game Center") + onClicked: root.openRequested() + } + + // Primary gamepad battery + QQC2.Label { + visible: GamingShell.GamepadManager.hasGamepad + && GamingShell.GamepadManager.primaryGamepad + && GamingShell.GamepadManager.primaryGamepad.batteryPercent >= 0 + text: GamingShell.GamepadManager.primaryGamepad + ? GamingShell.GamepadManager.primaryGamepad.batteryPercent + "%" + : "" + color: "white" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + rightPadding: Kirigami.Units.smallSpacing + } + } + } + + // ---- toast pill (slides in below HUD pill when active) ---- + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing + height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2 + radius: height / 2 + color: root.toastError ? Qt.rgba(0.75, 0.1, 0.05, 0.9) : Qt.rgba(0, 0, 0, 0.55) + + opacity: root.toastActive ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + + RowLayout { + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: Kirigami.Units.largeSpacing + rightMargin: Kirigami.Units.largeSpacing + } + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + source: root.toastError ? "dialog-warning" : "media-playback-start" + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + Layout.alignment: Qt.AlignVCenter + } + + QQC2.Label { + Layout.fillWidth: true + text: root.toastMessage + color: "white" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + elide: Text.ElideRight + maximumLineCount: 1 + } + } + } +} diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml new file mode 100644 index 00000000..a623c310 --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -0,0 +1,692 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness +import org.kde.plasma.private.volume +import org.kde.plasma.networkmanagement as PlasmaNM +import org.kde.bluezqt 1.0 as BluezQt +import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.quicksetting.nightcolor as NightColor + +Item { + id: root + anchors.fill: parent + + property bool opened: false + readonly property string acceptButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonA) + readonly property string closeButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonB) + + function pulsePrimaryGamepad(lowIntensity, highIntensity, durationMs) { + var pad = GamingShell.GamepadManager.primaryGamepad + if (!pad || !pad.hasRumble) { + return + } + pad.rumble(lowIntensity, highIntensity, durationMs) + } + + // Focusable controls for gamepad navigation + property var _controls: [] + property int _focusIndex: 0 + + function _buildControlsList() { + var list = [] + if (GamingShell.PowerProfileControl.available && performanceSection._availableProfiles.length > 0) list.push(profileRow) + if (screenBrightness.brightnessAvailable) list.push(brightnessSlider) + if (PreferredDevice.sink) list.push(volumeSlider) + list.push(dndSwitch) + list.push(launchHintSwitch) + list.push(nightColorSwitch) + list.push(overlaySwitch) + list.push(wifiSwitch) + list.push(btSwitch) + list.push(airplaneSwitch) + _controls = list + } + + function open() { + opened = true + _buildControlsList() + _focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1)) + _highlightCurrent() + pulsePrimaryGamepad(7000, 11000, 40) + } + function close() { + opened = false + pulsePrimaryGamepad(5000, 8000, 30) + } + function toggle() { + if (opened) close(); else open() + } + + function _highlightCurrent() { + if (_controls.length > 0 && _focusIndex >= 0 && _focusIndex < _controls.length) { + _controls[_focusIndex].forceActiveFocus() + } + } + + // Gamepad input handlers called from GameCenterOverlay + function gamepadUp() { + if (_focusIndex > 0) { + _focusIndex-- + _highlightCurrent() + } + } + function gamepadDown() { + if (_focusIndex < _controls.length - 1) { + _focusIndex++ + _highlightCurrent() + } + } + function gamepadLeft() { + var ctrl = _controls[_focusIndex] + if (typeof ctrl.decrease === "function") { + ctrl.decrease() + if (typeof ctrl.moved === "function") ctrl.moved() + } + } + function gamepadRight() { + var ctrl = _controls[_focusIndex] + if (typeof ctrl.increase === "function") { + ctrl.increase() + if (typeof ctrl.moved === "function") ctrl.moved() + } + } + function gamepadAccept() { + var ctrl = _controls[_focusIndex] + if (ctrl === profileRow) { + ctrl.increase() + pulsePrimaryGamepad(6000, 9000, 35) + return + } + if (ctrl instanceof QQC2.Switch) { + ctrl.toggle() + ctrl.toggled() + pulsePrimaryGamepad(6000, 9000, 35) + } + } + + onOpenedChanged: { + if (opened) { + _buildControlsList() + _focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1)) + _highlightCurrent() + } + } + + // Eat clicks on the dimmed backdrop + MouseArea { + anchors.fill: parent + visible: root.opened + onClicked: root.close() + } + + // Dim backdrop + Rectangle { + anchors.fill: parent + color: "black" + opacity: root.opened ? 0.4 : 0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + } + + // Panel sliding in from the right + Rectangle { + id: panel + width: Math.min(root.width * 0.35, Kirigami.Units.gridUnit * 22) + height: root.height + anchors.top: root.top + anchors.bottom: root.bottom + + x: root.opened ? root.width - width : root.width + + Behavior on x { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Window + color: Qt.rgba(Kirigami.Theme.backgroundColor.r, + Kirigami.Theme.backgroundColor.g, + Kirigami.Theme.backgroundColor.b, 0.96) + + // Subtle left border + Rectangle { + width: 1 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + color: Kirigami.Theme.disabledTextColor + opacity: 0.3 + } + + ScreenBrightness.ScreenBrightnessUtil { + id: screenBrightness + } + + PlasmaNM.Handler { + id: nmHandler + } + + PlasmaNM.EnabledConnections { + id: enabledConnections + } + + Flickable { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing * 2 + contentHeight: settingsColumn.implicitHeight + clip: true + + ColumnLayout { + id: settingsColumn + width: parent.width + spacing: Kirigami.Units.largeSpacing * 2 + + // ---- Header ---- + RowLayout { + Layout.fillWidth: true + + Kirigami.Heading { + text: i18n("Quick Settings") + level: 2 + Layout.fillWidth: true + } + + QQC2.ToolButton { + icon.name: "window-close-symbolic" + onClicked: root.close() + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Performance Profile ---- + ColumnLayout { + id: performanceSection + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.PowerProfileControl.available + && _availableProfiles.length > 0 + + PC3.Label { + text: i18n("Performance") + font.bold: true + } + + // Ordered low-to-high so gamepad left=slower, right=faster + readonly property var _profileOrder: ["power-saver", "balanced", "performance"] + readonly property var _availableProfiles: { + var ordered = [] + for (var i = 0; i < _profileOrder.length; i++) { + if (GamingShell.PowerProfileControl.profiles.indexOf(_profileOrder[i]) >= 0) { + ordered.push(_profileOrder[i]) + } + } + return ordered + } + + Item { + id: profileRow + focus: true + Layout.fillWidth: true + Layout.preferredHeight: profileButtons.implicitHeight + + function decrease() { + var profiles = parent._availableProfiles + var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile) + if (idx > 0) { + GamingShell.PowerProfileControl.activeProfile = profiles[idx - 1] + } + } + function increase() { + var profiles = parent._availableProfiles + var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile) + if (idx >= 0 && idx < profiles.length - 1) { + GamingShell.PowerProfileControl.activeProfile = profiles[idx + 1] + } + } + + Rectangle { + anchors.fill: parent + anchors.margins: -Kirigami.Units.smallSpacing + radius: Kirigami.Units.smallSpacing + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: parent.activeFocus ? 2 : 0 + } + + RowLayout { + id: profileButtons + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.smallSpacing + + Repeater { + model: performanceSection._availableProfiles + + QQC2.Button { + Layout.fillWidth: true + text: { + switch (modelData) { + case "performance": return i18n("Performance") + case "balanced": return i18n("Balanced") + case "power-saver": return i18n("Power Saver") + default: return modelData + } + } + icon.name: { + switch (modelData) { + case "performance": return "speedometer" + case "balanced": return "system-suspend-hibernate" + case "power-saver": return "battery-profile-powersave" + default: return "" + } + } + highlighted: GamingShell.PowerProfileControl.activeProfile === modelData + onClicked: GamingShell.PowerProfileControl.activeProfile = modelData + } + } + } + } + } + + // ---- Brightness ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: screenBrightness.brightnessAvailable + + PC3.Label { + text: i18n("Brightness") + font.bold: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "low-brightness" + } + + PC3.Slider { + id: brightnessSlider + Layout.fillWidth: true + from: 1 + to: screenBrightness.maxBrightness + stepSize: Math.max(1, Math.round(screenBrightness.maxBrightness / 20)) + value: screenBrightness.brightness + onMoved: screenBrightness.brightness = value + + Timer { + interval: 0 + running: true + repeat: false + onTriggered: brightnessSlider.value = Qt.binding(() => screenBrightness.brightness) + } + + // Keep Plasma/Kirigami colors while using a cleaner rounded style. + background: Rectangle { + x: brightnessSlider.leftPadding + y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2 + width: brightnessSlider.availableWidth + height: Kirigami.Units.smallSpacing + 2 + radius: height / 2 + color: Kirigami.Theme.alternateBackgroundColor + + Rectangle { + width: parent.width * brightnessSlider.visualPosition + height: parent.height + radius: parent.radius + color: Kirigami.Theme.highlightColor + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: brightnessSlider.activeFocus ? 1 : 0 + } + } + + handle: Rectangle { + x: brightnessSlider.leftPadding + brightnessSlider.visualPosition * (brightnessSlider.availableWidth - width) + y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2 + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + radius: width / 2 + color: Kirigami.Theme.backgroundColor + border.color: brightnessSlider.pressed + ? Kirigami.Theme.highlightColor + : Kirigami.Theme.disabledTextColor + border.width: brightnessSlider.activeFocus || brightnessSlider.pressed ? 2 : 1 + } + } + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "high-brightness" + } + } + } + + // ---- Volume ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: PreferredDevice.sink != null + + PC3.Label { + text: i18n("Volume") + font.bold: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "audio-volume-low" + } + + PC3.Slider { + id: volumeSlider + Layout.fillWidth: true + from: PulseAudio.MinimalVolume + to: PulseAudio.NormalVolume + stepSize: PulseAudio.NormalVolume / 20 + value: PreferredDevice.sink ? PreferredDevice.sink.volume : 0 + onMoved: { + if (PreferredDevice.sink) { + PreferredDevice.sink.volume = value + PreferredDevice.sink.muted = (value === 0) + } + } + + // Keep Plasma/Kirigami colors while using a cleaner rounded style. + background: Rectangle { + x: volumeSlider.leftPadding + y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2 + width: volumeSlider.availableWidth + height: Kirigami.Units.smallSpacing + 2 + radius: height / 2 + color: Kirigami.Theme.alternateBackgroundColor + + Rectangle { + width: parent.width * volumeSlider.visualPosition + height: parent.height + radius: parent.radius + color: Kirigami.Theme.highlightColor + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: volumeSlider.activeFocus ? 1 : 0 + } + } + + handle: Rectangle { + x: volumeSlider.leftPadding + volumeSlider.visualPosition * (volumeSlider.availableWidth - width) + y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2 + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + radius: width / 2 + color: Kirigami.Theme.backgroundColor + border.color: volumeSlider.pressed + ? Kirigami.Theme.highlightColor + : Kirigami.Theme.disabledTextColor + border.width: volumeSlider.activeFocus || volumeSlider.pressed ? 2 : 1 + } + } + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "audio-volume-high" + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Gaming Tweaks ---- + PC3.Label { + text: i18n("Gaming") + font.bold: true + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + rowSpacing: Kirigami.Units.smallSpacing + columnSpacing: Kirigami.Units.largeSpacing + + QQC2.Switch { + id: dndSwitch + text: i18n("Do Not Disturb") + checked: MobileShellState.ShellDBusClient.doNotDisturb + onToggled: MobileShellState.ShellDBusClient.doNotDisturb = checked + } + + QQC2.Switch { + id: launchHintSwitch + text: i18n("Launch Hint") + checked: ShellSettings.Settings.gamingDismissHintEnabled + onToggled: ShellSettings.Settings.gamingDismissHintEnabled = checked + } + + QQC2.Switch { + id: nightColorSwitch + text: i18n("Night Color") + checked: NightColor.NightColorUtil.enabled + onToggled: NightColor.NightColorUtil.enabled = checked + } + + QQC2.Switch { + id: overlaySwitch + text: i18n("Perf Overlay") + checked: GamingShell.GameLauncherProvider.overlayEnabled + enabled: GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 + onToggled: GamingShell.GameLauncherProvider.overlayEnabled = checked + + QQC2.ToolTip.visible: !GamingShell.GameLauncherProvider.mangohudAvailable && hovered + QQC2.ToolTip.text: i18n("MangoHud is not installed") + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + // FPS cap — spans both columns, driven by MangoHud fps_limit + QQC2.ButtonGroup { id: fpsCap; exclusive: true } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + enabled: GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 + + PC3.Label { text: i18n("FPS Cap") } + Item { Layout.fillWidth: true } + + Repeater { + model: [ + { label: i18nc("@action:button FPS cap off", "Off"), fps: 0 }, + { label: "30", fps: 30 }, + { label: "40", fps: 40 }, + { label: "60", fps: 60 } + ] + delegate: QQC2.Button { + required property var modelData + text: modelData.label + flat: true + checkable: true + checked: GamingShell.GameLauncherProvider.fpsLimit === modelData.fps + QQC2.ButtonGroup.group: fpsCap + onClicked: GamingShell.GameLauncherProvider.fpsLimit = modelData.fps + } + } + } + + // GameMode status (auto-managed, read-only indicator) + RowLayout { + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.GameModeControl.available + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "games-achievements" + } + PC3.Label { + text: GamingShell.GameModeControl.active + ? i18n("GameMode requested") + : i18n("GameMode not requested") + opacity: 0.7 + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Connectivity toggles ---- + PC3.Label { + text: i18n("Connectivity") + font.bold: true + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + rowSpacing: Kirigami.Units.smallSpacing + columnSpacing: Kirigami.Units.largeSpacing + + // WiFi toggle + QQC2.Switch { + id: wifiSwitch + text: i18n("Wi-Fi") + checked: enabledConnections.wirelessEnabled + onToggled: nmHandler.enableWireless(checked) + } + + // Bluetooth toggle + QQC2.Switch { + id: btSwitch + text: i18n("Bluetooth") + checked: !BluezQt.Manager.bluetoothBlocked + onToggled: BluezQt.Manager.bluetoothBlocked = !checked + } + + // Airplane mode + QQC2.Switch { + id: airplaneSwitch + text: i18n("Airplane Mode") + checked: PlasmaNM.Configuration.airplaneModeEnabled + onToggled: { + nmHandler.enableAirplaneMode(!PlasmaNM.Configuration.airplaneModeEnabled) + PlasmaNM.Configuration.airplaneModeEnabled = !PlasmaNM.Configuration.airplaneModeEnabled + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Battery info ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: MobileShell.BatteryInfo.isVisible + + PC3.Label { + text: i18n("Battery") + font.bold: true + } + + RowLayout { + spacing: Kirigami.Units.smallSpacing + + MobileShell.BatteryIndicator { + textPixelSize: Kirigami.Units.gridUnit * 0.7 + } + + PC3.Label { + text: MobileShell.BatteryInfo.pluggedIn ? i18n("Charging") : "" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + opacity: 0.7 + } + } + } + + // ---- Controller info ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.GamepadManager.hasGamepad + + PC3.Label { + text: i18n("Controllers") + font.bold: true + } + + Repeater { + model: GamingShell.GamepadManager + + RowLayout { + spacing: Kirigami.Units.smallSpacing + required property string name + required property int battery + required property string type + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "input-gaming" + } + + PC3.Label { + text: name + } + + PC3.Label { + text: battery >= 0 ? battery + "%" : i18n("Wired") + opacity: 0.7 + } + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Gamepad legend ---- + PC3.Label { + Layout.fillWidth: true + text: i18n("↕: Navigate ↔: Adjust %1: Toggle %2: Close", + acceptButtonLabel, closeButtonLabel) + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + opacity: 0.5 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.fillHeight: true } + } + } + } +} diff --git a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml new file mode 100644 index 00000000..4f6cae2a --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PC3 +import org.kde.taskmanager as TaskManager + +import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio + +Item { + id: root + + implicitHeight: taskList.count > 0 ? column.implicitHeight : 0 + readonly property bool hasTasks: taskList.count > 0 + readonly property int taskCount: taskList.count + + signal taskActivated() + signal moveDownRequested() + + function focusFirstTask() { + if (!hasTasks) { + return; + } + taskList.currentIndex = Math.max(0, taskList.currentIndex) + taskList.positionViewAtIndex(taskList.currentIndex, ListView.Visible) + taskList.forceActiveFocus() + } + + function activateCurrent() { + if (taskList.currentItem) { + taskList.currentItem.activate() + } + } + + function closeCurrent() { + if (taskList.currentItem) { + var idx = taskList.currentIndex + tasks.requestClose(tasks.makeModelIndex(idx)) + } + } + + TaskManager.VirtualDesktopInfo { id: vdInfo } + TaskManager.ActivityInfo { id: actInfo } + + TaskManager.TasksModel { + id: tasks + filterByVirtualDesktop: true + filterByActivity: true + filterNotMaximized: false + filterByScreen: true + filterHidden: false + virtualDesktop: vdInfo.currentDesktop + activity: actInfo.currentActivity + groupMode: TaskManager.TasksModel.GroupApplications + } + + Behavior on implicitHeight { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + ColumnLayout { + id: column + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.smallSpacing + visible: taskList.count > 0 + + Kirigami.Heading { + level: 2 + text: i18n("Running") + } + + ListView { + id: taskList + Layout.fillWidth: true + implicitHeight: contentHeight + model: tasks + orientation: ListView.Horizontal + spacing: Kirigami.Units.largeSpacing + clip: true + keyNavigationEnabled: true + currentIndex: 0 + + onActiveFocusChanged: { + if (activeFocus && count > 0 && currentIndex < 0) { + currentIndex = 0 + } + } + + Keys.onLeftPressed: { + if (count <= 0) { + return + } + currentIndex = Math.max(0, currentIndex - 1) + positionViewAtIndex(currentIndex, ListView.Contain) + } + + Keys.onRightPressed: { + if (count <= 0) { + return + } + currentIndex = Math.min(count - 1, currentIndex + 1) + positionViewAtIndex(currentIndex, ListView.Contain) + } + + Keys.onDownPressed: root.moveDownRequested() + Keys.onReturnPressed: currentItem && currentItem.activate() + Keys.onEnterPressed: currentItem && currentItem.activate() + + delegate: QQC2.ItemDelegate { + id: taskItem + + required property var decoration + required property var winIdList + + width: Kirigami.Units.gridUnit * 8 + height: Kirigami.Units.gridUnit * 5 + + readonly property var modelIndex: tasks.makeModelIndex(index) + readonly property bool isCurrent: ListView.isCurrentItem && taskList.activeFocus + readonly property string titleText: typeof model !== "undefined" && model.display ? model.display : "" + + function activate() { + tasks.requestActivate(taskItem.modelIndex) + root.taskActivated() + } + + onClicked: { + taskList.currentIndex = index + activate() + } + + Keys.onReturnPressed: activate() + Keys.onEnterPressed: activate() + + Rectangle { + anchors.fill: parent + radius: Kirigami.Units.cornerRadius + color: taskItem.isCurrent + ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, + Kirigami.Theme.highlightColor.b, 0.25) + : resumeArea.containsPress + ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, + Kirigami.Theme.highlightColor.b, 0.3) + : resumeArea.containsMouse + ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, + Kirigami.Theme.textColor.b, 0.1) + : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, + Kirigami.Theme.textColor.b, 0.06) + + Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.large + implicitHeight: Kirigami.Units.iconSizes.large + source: taskItem.decoration + } + + PC3.Label { + Layout.fillWidth: true + text: taskItem.titleText + maximumLineCount: 1 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + } + } + + // Close button (top-right corner) + QQC2.ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing / 2 + width: Kirigami.Units.iconSizes.small + height: width + icon.name: "window-close-symbolic" + icon.width: Kirigami.Units.iconSizes.small + icon.height: Kirigami.Units.iconSizes.small + display: QQC2.AbstractButton.IconOnly + onClicked: tasks.requestClose(taskItem.modelIndex) + } + + MouseArea { + id: resumeArea + anchors.fill: parent + hoverEnabled: true + z: -1 + // Leave room for the close button + onClicked: { + taskList.currentIndex = index + taskItem.activate() + } + } + } + } + } +} diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index f667410a..1c8de4f0 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -16,6 +16,7 @@ import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell import org.kde.layershell 1.0 as LayerShell import org.kde.plasma.private.sessions 2.0 @@ -25,13 +26,84 @@ import org.kde.kirigamiaddons.components as KirigamiAddonsComponents import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio +import "./gaming" + import "./private" ContainmentItem { id: root property Folio.HomeScreen folio: root.plasmoid + // Tracks whether the Game Center grid is visible within gaming mode. + // If gaming mode is already enabled at startup, open it immediately so + // the user is never left without controls. + property bool gameCenterOpen: ShellSettings.Settings.gamingModeEnabled + property bool showGameCenterHint: false + + // State saved when gaming mode activates, restored when it deactivates + property string _savedPowerProfile: "" + property bool _savedDnd: false + property bool _gamingSessionActive: false + + function _applyGamingModeState(enabled) { + root.gameCenterOpen = enabled + GamingShell.GamepadManager.active = enabled + + if (enabled === root._gamingSessionActive) { + return + } + + if (enabled) { + // Save current state and apply gaming optimizations + root._savedDnd = MobileShellState.ShellDBusClient.doNotDisturb + MobileShellState.ShellDBusClient.doNotDisturb = true + + if (GamingShell.PowerProfileControl.available) { + root._savedPowerProfile = GamingShell.PowerProfileControl.activeProfile + GamingShell.PowerProfileControl.activeProfile = "performance" + } + + GamingShell.GameModeControl.requestStart() + root._gamingSessionActive = true + } else { + // Restore previous state + MobileShellState.ShellDBusClient.doNotDisturb = root._savedDnd + + if (GamingShell.PowerProfileControl.available && root._savedPowerProfile.length > 0) { + GamingShell.PowerProfileControl.activeProfile = root._savedPowerProfile + } + + GamingShell.GameModeControl.requestEnd() + root._gamingSessionActive = false + } + } + + Timer { + id: gameCenterHintTimer + interval: 2600 + onTriggered: root.showGameCenterHint = false + } + + Connections { + target: ShellSettings.Settings + function onGamingModeEnabledChanged() { + root._applyGamingModeState(ShellSettings.Settings.gamingModeEnabled) + } + } + + // Gamepad Guide button toggles Game Center overlay + Connections { + target: GamingShell.GamepadManager + enabled: ShellSettings.Settings.gamingModeEnabled + function onButtonPressed(button, gamepadIndex) { + if (button === GamingShell.GamepadManager.ButtonGuide) { + root.gameCenterOpen = !root.gameCenterOpen + } + } + } + Component.onCompleted: { + root._applyGamingModeState(ShellSettings.Settings.gamingModeEnabled) folio.FolioSettings.load(); folio.FavouritesModel.load(); folio.PageListModel.load(); @@ -71,6 +143,18 @@ ContainmentItem { screenGeometry: Plasmoid.containment.screenGeometry } + // In gaming mode, reopen Game Center when the last window goes away + // so the user is never stranded on a bare wallpaper. + Connections { + target: windowMaximizedTracker + enabled: ShellSettings.Settings.gamingModeEnabled + function onShowingWindowChanged() { + if (!windowMaximizedTracker.showingWindow && !root.gameCenterOpen) { + root.gameCenterOpen = true + } + } + } + // Close app drawer when a new window appears Connections { target: WindowPlugin.WindowUtil @@ -89,6 +173,12 @@ ContainmentItem { MobileShellState.ShellDBusClient.closeActionDrawer(); } + if (ShellSettings.Settings.gamingModeEnabled) { + // In gaming mode Home/Menu should reopen the Game Center overlay. + root.gameCenterOpen = true; + return; + } + if (ShellSettings.Settings.convergenceModeEnabled) { // Convergence: toggle the app drawer as a layer-shell overlay // without disturbing open windows. @@ -186,7 +276,7 @@ ContainmentItem { // task panel containment; this window only provides the visible dock. Window { id: dockOverlay - visible: ShellSettings.Settings.convergenceModeEnabled + visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled color: "transparent" width: Screen.width height: Kirigami.Units.gridUnit * 3 @@ -286,6 +376,7 @@ ContainmentItem { Window { id: drawerOverlay visible: ShellSettings.Settings.convergenceModeEnabled + && !ShellSettings.Settings.gamingModeEnabled && folio.HomeScreenState.appDrawerOpenProgress > 0 color: "transparent" width: Screen.width @@ -645,6 +736,74 @@ ContainmentItem { } } + // Game Center overlay — full-screen grid of games shown when gaming mode + // is active. Sits at LayerTop so it covers running application windows + // without going above system notifications. + GameCenterOverlay { + id: gameCenterOverlay + folio: root.folio + visible: ShellSettings.Settings.gamingModeEnabled && root.gameCenterOpen + + onGameStarted: root.gameCenterOpen = false + onDismissRequested: { + root.gameCenterOpen = false + if (ShellSettings.Settings.gamingDismissHintEnabled) { + root.showGameCenterHint = true + gameCenterHintTimer.restart() + } + } + } + + // Small persistent button at the top-right corner of the screen that lets + // the user return to the Game Center after launching a game. + // Keep the Loader active for the full duration of gaming mode so the + // opacity Behavior in GamingHUD can animate both fade-in and fade-out. + // + // Hide the HUD while a game window covers the screen. A mapped LayerShell + // surface prevents KWin from using DRM direct scanout for the fullscreen + // game window. Setting showing=false triggers the opacity fade-out and then + // sets visible=false, which unmaps the Wayland surface and lets KWin bypass + // the compositor render loop entirely for the game frame. + Loader { + active: ShellSettings.Settings.gamingModeEnabled + sourceComponent: GamingHUD { + visible: showing + showing: !root.gameCenterOpen && !windowMaximizedTracker.showingWindow + onOpenRequested: root.gameCenterOpen = true + } + } + + Rectangle { + id: gameCenterHint + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Kirigami.Units.gridUnit * 2 + visible: root.showGameCenterHint && ShellSettings.Settings.gamingDismissHintEnabled + opacity: visible ? 1 : 0 + z: 2000 + radius: Kirigami.Units.cornerRadius + color: Qt.rgba(0, 0, 0, 0.65) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.2) + + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + + implicitWidth: hintText.implicitWidth + Kirigami.Units.gridUnit * 2 + implicitHeight: hintText.implicitHeight + Kirigami.Units.largeSpacing + + PlasmaComponents.Label { + id: hintText + anchors.centerIn: parent + text: i18n("Gaming mode is still on. Use Home or the gamepad icon to reopen Game Center.") + color: "white" + wrapMode: Text.WordWrap + width: Math.min(root.width * 0.8, Kirigami.Units.gridUnit * 30) + horizontalAlignment: Text.AlignHCenter + } + } + MobileShell.HomeScreen { id: homeScreen anchors.fill: parent diff --git a/containments/panel/qml/StatusPanel.qml b/containments/panel/qml/StatusPanel.qml index 091db624..11f78247 100644 --- a/containments/panel/qml/StatusPanel.qml +++ b/containments/panel/qml/StatusPanel.qml @@ -131,6 +131,7 @@ Item { context: Qt.ApplicationShortcut onActivated: { if (drawer.actionDrawer.intendedToBeVisible) { + drawer.actionDrawer.intendedToBeVisible = false; drawer.actionDrawer.close(); } else { drawer.actionDrawer.openToPinnedMode = false; diff --git a/containments/panel/qml/main.qml b/containments/panel/qml/main.qml index 3944ee9f..7effa258 100644 --- a/containments/panel/qml/main.qml +++ b/containments/panel/qml/main.qml @@ -34,11 +34,17 @@ ContainmentItem { // Whether the startup feedback is showing readonly property bool showingStartupFeedback: MobileShellState.ShellDBusObject.startupFeedbackModel.activeWindowIsStartupFeedback + readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled + // Whether an app is maximized and showing (does not include startup feedback) readonly property bool showingApp: windowMaximizedTracker.showingWindow && !showingStartupFeedback // Whether the currently showing app is in "fullscreen" readonly property bool fullscreen: { + if (gamingMode) { + return true; + } + // In convergence mode the status bar is always visible, like a desktop panel. if (ShellSettings.Settings.convergenceModeEnabled) { return false; @@ -69,7 +75,7 @@ ContainmentItem { } } - readonly property real panelHeight: MobileShell.Constants.topPanelHeight + readonly property real panelHeight: gamingMode ? 0 : MobileShell.Constants.topPanelHeight onPanelHeightChanged: setWindowProperties() function setWindowProperties() { @@ -123,6 +129,11 @@ ContainmentItem { function onConvergenceModeEnabledChanged() { root.setWindowProperties(); } + + function onGamingModeEnabledChanged() { + root.setWindowProperties(); + MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : (fullscreen ? "hidden" : "default"); + } } Component.onCompleted: { @@ -136,22 +147,23 @@ ContainmentItem { // MaximizeArea by the panel height. Window { id: topBarSpaceReserver - visible: ShellSettings.Settings.convergenceModeEnabled + visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled color: "transparent" flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput - height: root.panelHeight + height: Math.max(1, root.panelHeight) width: 1 LayerShell.Window.scope: "topbar-space" LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight - LayerShell.Window.exclusionZone: root.panelHeight + LayerShell.Window.exclusionZone: Math.max(1, root.panelHeight) LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone } // Visual panel component StatusPanel { id: statusPanel + visible: !ShellSettings.Settings.gamingModeEnabled anchors.fill: parent containmentItem: root } diff --git a/containments/taskpanel/qml/main.qml b/containments/taskpanel/qml/main.qml index 9f3aa4cf..31cde951 100644 --- a/containments/taskpanel/qml/main.qml +++ b/containments/taskpanel/qml/main.qml @@ -38,12 +38,14 @@ ContainmentItem { readonly property bool inLandscape: MobileShell.Constants.navigationPanelOnSide(Screen.width, Screen.height) - readonly property real navigationPanelHeight: MobileShell.Constants.navigationPanelThickness + readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled + + readonly property real navigationPanelHeight: gamingMode ? 0 : MobileShell.Constants.navigationPanelThickness onNavigationPanelHeightChanged: setWindowProperties() readonly property real intendedWindowThickness: navigationPanelHeight readonly property real intendedWindowLength: inLandscape ? Screen.height : Screen.width - readonly property real intendedWindowOffset: inLandscape ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel + readonly property real intendedWindowOffset: (inLandscape && !gamingMode) ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel readonly property int intendedWindowLocation: inLandscape ? PlasmaCore.Types.RightEdge : PlasmaCore.Types.BottomEdge onIntendedWindowLengthChanged: maximizeTimer.restart() // ensure it always takes up the full length of the screen @@ -136,6 +138,11 @@ ContainmentItem { function onConvergenceModeEnabledChanged() { root.setWindowProperties(); } + + function onGamingModeEnabledChanged() { + root.setWindowProperties(); + navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? MobileShell.Constants.navigationPanelThickness : 0; + } } Component.onCompleted: setWindowProperties(); @@ -153,18 +160,19 @@ ContainmentItem { Window { id: dockSpaceReserver visible: ShellSettings.Settings.convergenceModeEnabled + && !ShellSettings.Settings.gamingModeEnabled && !(ShellSettings.Settings.autoHidePanelsEnabled && windowMaximizedTracker.showingWindow) color: "transparent" flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput // height is set by layer-shell anchoring; provide a fallback. - height: Kirigami.Units.gridUnit * 3 + height: Math.max(1, MobileShell.Constants.navigationPanelThickness) width: 1 // layer-shell stretches it via AnchorLeft|AnchorRight LayerShell.Window.scope: "dock-space" LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight - LayerShell.Window.exclusionZone: Kirigami.Units.gridUnit * 3 + LayerShell.Window.exclusionZone: Math.max(1, MobileShell.Constants.navigationPanelThickness) LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone } @@ -177,7 +185,9 @@ ContainmentItem { return (windowMaximizedTracker.showingWindow || isCurrentWindowFullscreen) && !showingStartupFeedback } readonly property alias isCurrentWindowFullscreen: windowMaximizedTracker.isCurrentWindowFullscreen - readonly property bool fullscreen: isCurrentWindowFullscreen || (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar) + readonly property bool fullscreen: ShellSettings.Settings.gamingModeEnabled + || isCurrentWindowFullscreen + || (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar) WindowPlugin.WindowMaximizedTracker { id: windowMaximizedTracker @@ -205,6 +215,7 @@ ContainmentItem { Item { id: navigationPanel + visible: !ShellSettings.Settings.gamingModeEnabled anchors.fill: parent property real offset: 0 diff --git a/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml b/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml index 7ee54b11..941df96e 100644 --- a/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml +++ b/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml @@ -17,6 +17,45 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP KCM.SimpleKCM { id: root + function packagePatterns(value: string): var { + if (value === "") { + return [] + } + + return value.split(",") + .map(pattern => pattern.trim()) + .filter(pattern => pattern !== "") + } + + function wildcardRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&") + return new RegExp("^" + escaped.replace(/\*/g, ".*") + "$") + } + + function hasExactPackage(value: string, packageName: string): bool { + return packagePatterns(value).includes(packageName) + } + + function hasWildcardPackage(value: string, packageName: string): bool { + return packagePatterns(value) + .filter(pattern => pattern.includes("*")) + .some(pattern => wildcardRegExp(pattern).test(packageName)) + } + + function hasEffectivePackage(value: string, packageName: string): bool { + return hasExactPackage(value, packageName) || hasWildcardPackage(value, packageName) + } + + function updateExactPackage(value: string, packageName: string, enabled: bool): string { + const updatedPatterns = packagePatterns(value).filter(pattern => pattern !== packageName) + + if (enabled) { + updatedPatterns.push(packageName) + } + + return updatedPatterns.join(",") + } + topPadding: Kirigami.Units.largeSpacing bottomPadding: Kirigami.Units.largeSpacing leftPadding: 0 @@ -74,6 +113,7 @@ KCM.SimpleKCM { } ColumnLayout { + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized spacing: Kirigami.Units.largeSpacing Kirigami.InlineMessage { @@ -85,6 +125,11 @@ KCM.SimpleKCM { showCloseButton: true } + Kirigami.PlaceholderMessage { + Layout.fillWidth: true + explanation: i18n("This page manages the launchers exported by Waydroid. Enable Show in Game Shell for Android apps you want listed in Game Center's Waydroid tab. Touch and Wi-Fi toggles add or remove exact package names from Waydroid's documented compatibility property lists. Wildcard rules remain in the main Waydroid properties page.") + } + FormCard.FormCard { Repeater { model: AIP.WaydroidDBusClient.applicationListModel @@ -95,23 +140,105 @@ KCM.SimpleKCM { width: ListView.view.width background: null - contentItem: RowLayout { - QQC2.Label { + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing + + RowLayout { Layout.fillWidth: true - text: model.name - elide: Text.ElideRight + + QQC2.Label { + Layout.fillWidth: true + text: model.name + elide: Text.ElideRight + } + + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + text: i18nc("@action:button", "Launch the application") + icon.name: "media-playback-start" + + onClicked: AIP.WaydroidDBusClient.launchApplication(model.id) + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + text: i18nc("@action:button", "Delete the application") + icon.name: "usermenu-delete" + + onClicked: AIP.WaydroidDBusClient.deleteApplication(model.id) + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } } - QQC2.ToolButton { - display: QQC2.AbstractButton.IconOnly - text: i18nc("@action:button", "Delete the application") - icon.name: "usermenu-delete" + RowLayout { + Layout.fillWidth: true - onClicked: AIP.WaydroidDBusClient.deleteApplication(model.id) + QQC2.CheckBox { + text: i18n("Show in Game Shell") + checked: AIP.WaydroidDBusClient.gameShellPackages.indexOf(model.id) !== -1 - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: text - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: AIP.WaydroidDBusClient.setGameShellEnabledForPackage(model.id, checked) + } + + Item { + Layout.fillWidth: true + } + } + + QQC2.Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + color: Kirigami.Theme.disabledTextColor + text: i18n("Adds this app to the Game Shell allowlist so it appears in Game Center under Waydroid.") + } + + RowLayout { + Layout.fillWidth: true + + QQC2.CheckBox { + id: fakeTouchToggle + text: i18n("Touch") + checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeTouch, model.id) + enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeTouch, model.id) + + onClicked: { + AIP.WaydroidDBusClient.fakeTouch = root.updateExactPackage(AIP.WaydroidDBusClient.fakeTouch, model.id, checked) + } + + QQC2.ToolTip.visible: hovered && !enabled + QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties") + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.CheckBox { + id: fakeWifiToggle + text: i18n("Wi-Fi") + checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeWifi, model.id) + enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeWifi, model.id) + + onClicked: { + AIP.WaydroidDBusClient.fakeWifi = root.updateExactPackage(AIP.WaydroidDBusClient.fakeWifi, model.id, checked) + } + + QQC2.ToolTip.visible: hovered && !enabled + QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties") + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + QQC2.Label { + Layout.fillWidth: true + visible: !fakeTouchToggle.enabled || !fakeWifiToggle.enabled + wrapMode: Text.WordWrap + color: Kirigami.Theme.disabledTextColor + text: i18n("One or more compatibility settings for this app come from a wildcard rule. Edit the global Waydroid property to change that rule.") } } } @@ -121,4 +248,22 @@ KCM.SimpleKCM { } } } + + ColumnLayout { + visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized + anchors.centerIn: parent + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + text: i18n("Waydroid is unavailable") + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + } + + PC3.Button { + text: i18n("Check again") + Layout.alignment: Qt.AlignHCenter + onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() + } + } } diff --git a/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml b/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml index 88b51696..1858084c 100644 --- a/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml +++ b/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml @@ -15,6 +15,13 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP ColumnLayout { id: root + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized + && AIP.WaydroidDBusClient.sessionStatus === AIP.WaydroidDBusClient.SessionRunning + + function packagePatternSummary(value: string): string { + return value === "" ? i18n("Not set") : value + } + FormCard.FormHeader { title: i18n("General information") } @@ -66,6 +73,64 @@ ColumnLayout { onAccepted: AIP.WaydroidDBusClient.resetWaydroid() } + Kirigami.PromptDialog { + id: fakeTouchDialog + title: i18n("Touch input override") + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel + + onOpened: { + fakeTouchField.text = AIP.WaydroidDBusClient.fakeTouch + fakeTouchField.forceActiveFocus() + } + + onAccepted: AIP.WaydroidDBusClient.fakeTouch = fakeTouchField.text.trim() + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: i18n("Comma-separated package names for apps where mouse input should be interpreted as touch. Supports * wildcards. Leave empty to clear the override.") + } + + QQC2.TextField { + id: fakeTouchField + Layout.fillWidth: true + placeholderText: "com.rovio.*" + } + } + } + + Kirigami.PromptDialog { + id: fakeWifiDialog + title: i18n("Wi-Fi override") + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel + + onOpened: { + fakeWifiField.text = AIP.WaydroidDBusClient.fakeWifi + fakeWifiField.forceActiveFocus() + } + + onAccepted: AIP.WaydroidDBusClient.fakeWifi = fakeWifiField.text.trim() + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: i18n("Comma-separated package names for apps that should always appear to be on Wi-Fi. Supports * wildcards. Leave empty to clear the override.") + } + + QQC2.TextField { + id: fakeWifiField + Layout.fillWidth: true + placeholderText: "com.gameloft.*" + } + } + } + } // Some information such as IP address can take time to be set by Waydroid @@ -140,5 +205,29 @@ ColumnLayout { infoMessage.visible = true } } + + FormCard.FormDelegateSeparator { above: uevent; below: fakeTouch } + + FormCard.FormTextDelegate { + id: fakeTouch + text: i18n("Touch input override") + description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeTouch) + trailing: PC3.Button { + text: i18n("Edit") + onClicked: fakeTouchDialog.open() + } + } + + FormCard.FormDelegateSeparator { above: fakeTouch; below: fakeWifi } + + FormCard.FormTextDelegate { + id: fakeWifi + text: i18n("Wi-Fi override") + description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeWifi) + trailing: PC3.Button { + text: i18n("Edit") + onClicked: fakeWifiDialog.open() + } + } } } diff --git a/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml b/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml index 1087bcb4..89aa2472 100644 --- a/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml +++ b/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml @@ -29,7 +29,9 @@ KCM.SimpleKCM { } WaydroidLoader { - visible: AIP.WaydroidDBusClient.androidId === "" + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized + && AIP.WaydroidDBusClient.androidId === "" + && !inlineMessage.visible text: i18n("Fetching your Android ID.\nIt can take a few seconds.") } @@ -53,7 +55,8 @@ KCM.SimpleKCM { } ColumnLayout { - visible: AIP.WaydroidDBusClient.androidId !== "" + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized + && AIP.WaydroidDBusClient.androidId !== "" anchors.verticalCenter: parent.verticalCenter anchors.left: parent anchors.leftMargin: Kirigami.Units.largeSpacing @@ -76,4 +79,22 @@ KCM.SimpleKCM { } } } + + ColumnLayout { + visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized + anchors.centerIn: parent + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + text: i18n("Waydroid is unavailable") + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + } + + QQC2.Button { + text: i18n("Check again") + Layout.alignment: Qt.AlignHCenter + onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() + } + } } diff --git a/kcms/waydroidintegration/ui/main.qml b/kcms/waydroidintegration/ui/main.qml index 6e5b618a..432206c7 100644 --- a/kcms/waydroidintegration/ui/main.qml +++ b/kcms/waydroidintegration/ui/main.qml @@ -29,14 +29,14 @@ KCM.SimpleKCM { spacing: Kirigami.Units.largeSpacing QQC2.Label { - text: i18n("Waydroid is not installed") + text: i18n("Waydroid is unavailable") Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter } PC3.Button { - text: i18n("Check installation") + text: i18n("Check again") Layout.alignment: Qt.AlignHCenter onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() } diff --git a/kwin/scripts/convergentwindows/contents/ui/main.qml b/kwin/scripts/convergentwindows/contents/ui/main.qml index 4bc06e1e..deb73722 100644 --- a/kwin/scripts/convergentwindows/contents/ui/main.qml +++ b/kwin/scripts/convergentwindows/contents/ui/main.qml @@ -11,9 +11,9 @@ Loader { property var currentWindow - // Window that needs geometry clamping after un-maximize in convergence - // mode. Set in onMaximizedChanged and consumed by the timer below. - property var pendingConstrainWindow: null + // Windows awaiting geometry clamping after un-maximize in convergence + // mode. Using an array so concurrent un-maximizes are not lost. + property var pendingConstrainWindows: [] // After a window is un-maximized in convergence mode, the dockSpaceReserver // LayerShell surface needs one Wayland roundtrip to (re)commit its exclusive @@ -24,27 +24,30 @@ Loader { id: constrainAfterRestoreTimer interval: 200 onTriggered: { - const window = root.pendingConstrainWindow - root.pendingConstrainWindow = null - if (!window || window.deleted || !window.normalWindow) return - if (!ShellSettings.Settings.convergenceModeEnabled) return + const windows = root.pendingConstrainWindows.slice() + root.pendingConstrainWindows = [] + for (const window of windows) { + if (!window || window.deleted || !window.normalWindow) continue + if (!ShellSettings.Settings.convergenceModeEnabled) continue + if (ShellSettings.Settings.gamingModeEnabled) continue - const output = window.output - const desktop = window.desktops[0] - if (!output) return - if (!desktop) return + const output = window.output + const desktop = window.desktops[0] + if (!output) continue + if (!desktop) continue - const maxRect = KWinComponents.Workspace.clientArea( - KWinComponents.Workspace.MaximizeArea, output, desktop) - const geo = window.frameGeometry - const maxBottom = maxRect.y + maxRect.height + const maxRect = KWinComponents.Workspace.clientArea( + KWinComponents.Workspace.MaximizeArea, output, desktop) + const geo = window.frameGeometry + const maxBottom = maxRect.y + maxRect.height - if (geo.y + geo.height > maxBottom) { - // Clip the bottom edge to MaximizeArea; preserve top position - // and width. Ensure height is at least 100px to avoid - // pathological cases where the window starts above maxRect. - const newH = Math.max(100, maxBottom - geo.y) - window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH) + if (geo.y + geo.height > maxBottom) { + // Clip the bottom edge to MaximizeArea; preserve top position + // and width. Ensure height is at least 100px to avoid + // pathological cases where the window starts above maxRect. + const newH = Math.max(100, maxBottom - geo.y) + window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH) + } } } } @@ -60,6 +63,12 @@ Loader { return; } + if (ShellSettings.Settings.gamingModeEnabled) { + window.noBorder = true; + window.setMaximize(true, true); + return; + } + if (ShellSettings.Settings.convergenceModeEnabled) { window.noBorder = false; } else { @@ -91,6 +100,9 @@ Loader { target: currentWindow function onFullScreenChanged() { + if (!currentWindow) { + return; + } currentWindow.interactiveMoveResizeFinished.connect((currentWindow) => { root.run(currentWindow); }); @@ -98,6 +110,9 @@ Loader { } function onMaximizedChanged() { + if (!currentWindow) { + return; + } if (!currentWindow.maximizable) { return; } @@ -110,7 +125,7 @@ Loader { // zone is re-committed over a Wayland roundtrip. if (ShellSettings.Settings.convergenceModeEnabled && ShellSettings.Settings.autoHidePanelsEnabled) { - root.pendingConstrainWindow = currentWindow + root.pendingConstrainWindows.push(currentWindow) constrainAfterRestoreTimer.restart() } } @@ -128,12 +143,25 @@ Loader { } } } + + function onGamingModeEnabledChanged() { + const windows = KWinComponents.Workspace.windows; + + for (let i = 0; i < windows.length; i++) { + if (windows[i].normalWindow) { + root.run(windows[i]); + } + } + } } Connections { target: KWinComponents.Workspace function onWindowAdded(window) { + if (!window) { + return; + } if (window.normalWindow) { window.interactiveMoveResizeFinished.connect((window) => { root.run(window); @@ -143,6 +171,9 @@ Loader { } function onWindowActivated(window) { + if (!window) { + return; + } if (window.normalWindow) { currentWindow = window; window.interactiveMoveResizeFinished.connect((window) => { diff --git a/quicksettings/CMakeLists.txt b/quicksettings/CMakeLists.txt index a560bdaf..0869f509 100644 --- a/quicksettings/CMakeLists.txt +++ b/quicksettings/CMakeLists.txt @@ -8,6 +8,8 @@ plasma_install_package(bluetooth org.kde.plasma.quicksetting.bluetooth quicksett plasma_install_package(caffeine org.kde.plasma.quicksetting.caffeine quicksettings) plasma_install_package(docked org.kde.plasma.quicksetting.docked quicksettings) plasma_install_package(donotdisturb org.kde.plasma.quicksetting.donotdisturb quicksettings) +plasma_install_package(gaming org.kde.plasma.quicksetting.gaming quicksettings) +plasma_install_package(gaminghint org.kde.plasma.quicksetting.gaminghint quicksettings) plasma_install_package(autohidepanels org.kde.plasma.quicksetting.autohidepanels quicksettings) plasma_install_package(keyboardtoggle org.kde.plasma.quicksetting.keyboardtoggle quicksettings) plasma_install_package(kscreenosd org.kde.plasma.quicksetting.kscreenosd quicksettings) diff --git a/quicksettings/gaming/Messages.sh b/quicksettings/gaming/Messages.sh new file mode 100644 index 00000000..f1cdd746 --- /dev/null +++ b/quicksettings/gaming/Messages.sh @@ -0,0 +1,16 @@ +#! /usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +set -e +: "${XGETTEXT:?XGETTEXT is not set}" +: "${podir:?podir is not set}" + +mapfile -t files < <(find . \( -name '*.js' -o -name '*.qml' -o -name '*.cpp' \) -print) +if [[ ${#files[@]} -eq 0 ]]; then + echo "Messages.sh: no input files found" >&2 + exit 1 +fi + +"$XGETTEXT" "${files[@]}" -o "$podir/plasma_org.kde.plasma.quicksetting.gaming.pot" diff --git a/quicksettings/gaming/contents/ui/main.qml b/quicksettings/gaming/contents/ui/main.qml new file mode 100644 index 00000000..a99e9660 --- /dev/null +++ b/quicksettings/gaming/contents/ui/main.qml @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick 2.15 + +import org.kde.kirigami as Kirigami + +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS + +QS.QuickSetting { + id: root + + text: i18n("Gaming Mode") + icon: "input-gaming" + status: enabled ? i18n("Active") : i18n("Inactive") + enabled: ShellSettings.Settings.gamingModeEnabled + + function requestDisable() { + confirmDisableDialog.active = true; + } + + function toggle() { + if (ShellSettings.Settings.gamingModeEnabled) { + requestDisable(); + return; + } + + ShellSettings.Settings.gamingModeEnabled = true; + } + + Loader { + id: confirmDisableDialog + active: false + onLoaded: item.open() + + sourceComponent: Kirigami.PromptDialog { + id: theConfirmDialog + title: i18n("Leave gaming mode?") + subtitle: i18n("Your games will keep running in the background.") + standardButtons: Kirigami.Dialog.NoButton + customFooterActions: [ + Kirigami.Action { + text: i18n("Keep Playing") + onTriggered: theConfirmDialog.close() + }, + Kirigami.Action { + text: i18n("Leave") + onTriggered: { + ShellSettings.Settings.gamingModeEnabled = false + theConfirmDialog.close() + } + } + ] + onClosed: confirmDisableDialog.active = false + } + } +} diff --git a/quicksettings/gaming/metadata.json b/quicksettings/gaming/metadata.json new file mode 100644 index 00000000..a86169b0 --- /dev/null +++ b/quicksettings/gaming/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "KPackage/GenericQML", + "KPlugin": { + "Authors": [ + { + "Email": "mightymarco4@gmail.com", + "Name": "Marco Allegretti" + } + ], + "Description": "Quick setting to toggle gaming mode for Plasma Mobile", + "Icon": "input-gaming", + "Id": "org.kde.plasma.quicksetting.gaming", + "License": "EUPL-1.2", + "Name": "Gaming Mode", + "Version": "0.1", + "Website": "" + } +} diff --git a/quicksettings/gaminghint/Messages.sh b/quicksettings/gaminghint/Messages.sh new file mode 100644 index 00000000..51cc9cbd --- /dev/null +++ b/quicksettings/gaminghint/Messages.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +set -e +: "${XGETTEXT:?XGETTEXT is not set}" +: "${podir:?podir is not set}" + +shopt -s nullglob +files=(*.json contents/ui/*.qml) +if [[ ${#files[@]} -eq 0 ]]; then + echo "Messages.sh: no input files found" >&2 + exit 1 +fi +"$XGETTEXT" "${files[@]}" -o "$podir/plasma_mobile_qt.pot" diff --git a/quicksettings/gaminghint/contents/ui/main.qml b/quicksettings/gaminghint/contents/ui/main.qml new file mode 100644 index 00000000..54073fa1 --- /dev/null +++ b/quicksettings/gaminghint/contents/ui/main.qml @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick 2.15 + +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS + +QS.QuickSetting { + text: i18n("Launch Hint") + icon: "dialog-information" + status: ShellSettings.Settings.gamingDismissHintEnabled ? i18n("On") : i18n("Off") + enabled: true + available: ShellSettings.Settings.gamingModeEnabled + + function toggle() { + ShellSettings.Settings.gamingDismissHintEnabled = !ShellSettings.Settings.gamingDismissHintEnabled; + } +} diff --git a/quicksettings/gaminghint/metadata.json b/quicksettings/gaminghint/metadata.json new file mode 100644 index 00000000..7095774f --- /dev/null +++ b/quicksettings/gaminghint/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "KPackage/GenericQML", + "KPlugin": { + "Authors": [ + { + "Email": "mightymarco4@gmail.com", + "Name": "Marco Allegretti" + } + ], + "Description": "Quick setting to toggle the Game Center dismiss hint banner", + "Icon": "dialog-information", + "Id": "org.kde.plasma.quicksetting.gaminghint", + "License": "EUPL-1.2", + "Name": "Game Center Hint", + "Version": "0.1", + "Website": "" + } +} diff --git a/screenshots/SHIFT-touch.png.license b/screenshots/SHIFT-touch.png.license index d31163d3..e1e3a62d 100644 --- a/screenshots/SHIFT-touch.png.license +++ b/screenshots/SHIFT-touch.png.license @@ -1,2 +1,2 @@ -SPDX-FileCopyrightText: 2026 Marco Allegretti. +SPDX-FileCopyrightText: 2026 Marco Allegretti SPDX-License-Identifier: EUPL-1.2