From bcb985c6528cb18f6201dc52390ee59868dbfed6 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 14:09:33 +0200 Subject: [PATCH] Add quick settings panel and game sources Slide-out panel with brightness, volume, Wi-Fi, Bluetooth, and airplane mode. Fully gamepad-navigable; D-pad and stick input routed to the panel while open. System status bar (clock, battery, connectivity) added to game center header. Lutris games discovered from ~/.local/share/lutris/pga.db, Heroic from ~/.config/heroic/store_cache/ JSON files. Deduplication extended to cover all launcher sources. Desktop entries launched via KIO::ApplicationLauncherJob to expand Exec field codes. Generic commands parsed with KShell::splitArgs. SQLite uses RAII cleanup guard. Clamped SDL axis, rumble, and LED values. Renamed rumble params from frequency to intensity. GamepadManager uses singleton factory. setPlayerIndex checks SDL return. FavouritesBar popup uses screen virtualX/Y for multi-monitor. --- components/gamingshellplugin/CMakeLists.txt | 3 + .../gamelauncherprovider.cpp | 200 ++++++++- .../gamingshellplugin/gamelauncherprovider.h | 2 + .../gamingshellplugin/gamepaddevice.cpp | 19 +- components/gamingshellplugin/gamepaddevice.h | 2 +- .../gamingshellplugin/gamepadmanager.cpp | 20 +- components/gamingshellplugin/gamepadmanager.h | 5 + containments/homescreens/folio/CMakeLists.txt | 1 + .../homescreens/folio/qml/FavouritesBar.qml | 12 +- .../folio/qml/gaming/GameCenterOverlay.qml | 122 +++++- .../folio/qml/gaming/GamingQuickSettings.qml | 412 ++++++++++++++++++ 11 files changed, 767 insertions(+), 31 deletions(-) create mode 100644 containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml diff --git a/components/gamingshellplugin/CMakeLists.txt b/components/gamingshellplugin/CMakeLists.txt index 803d5d99..97d75dc8 100644 --- a/components/gamingshellplugin/CMakeLists.txt +++ b/components/gamingshellplugin/CMakeLists.txt @@ -2,6 +2,7 @@ # SPDX-License-Identifier: EUPL-1.2 find_package(SDL3 REQUIRED) +find_package(Qt6 REQUIRED COMPONENTS Sql) ecm_add_qml_module(gamingshellplugin URI org.kde.plasma.private.mobileshell.gamingshellplugin GENERATE_PLUGIN_SOURCE) @@ -15,7 +16,9 @@ target_link_libraries(gamingshellplugin PRIVATE Qt::Core Qt::Qml Qt::Quick + Qt::Sql KF6::I18n + KF6::KIOGui KF6::Service KF6::CoreAddons KF6::ConfigCore diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index a0072565..91915600 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -4,6 +4,7 @@ #include "gamelauncherprovider.h" #include +#include #include #include #include @@ -12,8 +13,13 @@ #include #include #include +#include +#include +#include #include #include +#include +#include #include #include @@ -90,6 +96,8 @@ void GameLauncherProvider::refresh() loadDesktopGames(); loadSteamGames(); loadFlatpakGames(); + loadLutrisGames(); + loadHeroicGames(); loadRecentTimestamps(); // Deduplicate: when the same game appears from multiple sources, @@ -137,14 +145,14 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) if (entry.source == QLatin1String("desktop")) { auto service = KService::serviceByStorageId(entry.storageId); if (service) { - QStringList args = KShell::splitArgs(service->exec()); - if (!args.isEmpty()) { - QString program = args.takeFirst(); - QProcess::startDetached(program, args); - } + auto *job = new KIO::ApplicationLauncherJob(service); + job->start(); } + } else if (entry.launchCommand.contains(QStringLiteral("://"))) { + // Protocol handler (e.g. heroic://launch/...) — open via xdg-open + QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand}); } else { - QStringList parts = entry.launchCommand.split(QLatin1Char(' ')); + QStringList parts = KShell::splitArgs(entry.launchCommand); if (!parts.isEmpty()) { QString program = parts.takeFirst(); QProcess::startDetached(program, parts); @@ -159,19 +167,20 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) void GameLauncherProvider::deduplicateGames() { - // Build a set of names from Steam entries (case-insensitive). - QSet steamNames; + // 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")) { - steamNames.insert(g.name.toLower()); + 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 Steam entry. + // Remove desktop entries whose name matches a launcher entry. m_allGames.erase(std::remove_if(m_allGames.begin(), m_allGames.end(), - [&steamNames](const GameEntry &g) { - return g.source == QLatin1String("desktop") && steamNames.contains(g.name.toLower()); + [&launcherNames](const GameEntry &g) { + return g.source == QLatin1String("desktop") && launcherNames.contains(g.name.toLower()); }), m_allGames.end()); } @@ -329,6 +338,171 @@ void GameLauncherProvider::loadFlatpakGames() // the Game category properly). } +// --- 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); + entry.installed = true; + + // 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"); + entry.installed = true; + + // 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; diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index f7839594..56c76626 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -74,6 +74,8 @@ private: void loadDesktopGames(); void loadSteamGames(); void loadFlatpakGames(); + void loadLutrisGames(); + void loadHeroicGames(); void deduplicateGames(); void loadRecentTimestamps(); void saveRecentTimestamp(const QString &storageId, const QDateTime &when); diff --git a/components/gamingshellplugin/gamepaddevice.cpp b/components/gamingshellplugin/gamepaddevice.cpp index 95208682..d558f29e 100644 --- a/components/gamingshellplugin/gamepaddevice.cpp +++ b/components/gamingshellplugin/gamepaddevice.cpp @@ -7,6 +7,8 @@ #include #include +#include + GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent) : QObject(parent) , m_pad(pad) @@ -96,16 +98,20 @@ void GamepadDevice::setPlayerIndex(int index) if (!m_pad) { return; } - SDL_SetGamepadPlayerIndex(m_pad, index); - Q_EMIT playerIndexChanged(); + if (SDL_SetGamepadPlayerIndex(m_pad, index)) { + Q_EMIT playerIndexChanged(); + } } -bool GamepadDevice::rumble(int lowFreqMs, int highFreqMs, int durationMs) +bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs) { if (!m_pad) { return false; } - return SDL_RumbleGamepad(m_pad, static_cast(lowFreqMs), static_cast(highFreqMs), static_cast(durationMs)); + 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::setLED(int r, int g, int b) @@ -113,7 +119,10 @@ bool GamepadDevice::setLED(int r, int g, int b) if (!m_pad) { return false; } - return SDL_SetGamepadLED(m_pad, static_cast(r), static_cast(g), static_cast(b)); + 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); } SDL_Gamepad *GamepadDevice::sdlGamepad() const diff --git a/components/gamingshellplugin/gamepaddevice.h b/components/gamingshellplugin/gamepaddevice.h index 637ace61..c4534f2c 100644 --- a/components/gamingshellplugin/gamepaddevice.h +++ b/components/gamingshellplugin/gamepaddevice.h @@ -36,7 +36,7 @@ public: int playerIndex() const; void setPlayerIndex(int index); - Q_INVOKABLE bool rumble(int lowFreqMs, int highFreqMs, int durationMs); + Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs); Q_INVOKABLE bool setLED(int r, int g, int b); SDL_Gamepad *sdlGamepad() const; diff --git a/components/gamingshellplugin/gamepadmanager.cpp b/components/gamingshellplugin/gamepadmanager.cpp index c3958558..aefe0d31 100644 --- a/components/gamingshellplugin/gamepadmanager.cpp +++ b/components/gamingshellplugin/gamepadmanager.cpp @@ -8,6 +8,9 @@ #include #include +#include + +#include GamepadManager::GamepadManager(QObject *parent) : QAbstractListModel(parent) @@ -21,6 +24,16 @@ 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 @@ -180,7 +193,7 @@ void GamepadManager::poll() case SDL_EVENT_GAMEPAD_AXIS_MOTION: { int idx = indexForInstanceId(event.gaxis.which); if (idx >= 0) { - float normalized = static_cast(event.gaxis.value) / 32767.0f; + float normalized = std::clamp(static_cast(event.gaxis.value) / 32767.0f, -1.0f, 1.0f); Q_EMIT axisChanged(event.gaxis.axis, normalized, idx); } break; @@ -192,9 +205,8 @@ void GamepadManager::poll() } // Refresh battery state periodically (every ~5 seconds = 300 frames) - static int batteryCounter = 0; - if (++batteryCounter >= 300) { - batteryCounter = 0; + if (++m_batteryCounter >= 300) { + m_batteryCounter = 0; for (auto *dev : std::as_const(m_gamepads)) { dev->refreshBattery(); } diff --git a/components/gamingshellplugin/gamepadmanager.h b/components/gamingshellplugin/gamepadmanager.h index ba81fe2e..76b2d7ba 100644 --- a/components/gamingshellplugin/gamepadmanager.h +++ b/components/gamingshellplugin/gamepadmanager.h @@ -7,6 +7,8 @@ #include #include +class QQmlEngine; +class QJSEngine; class GamepadDevice; class GamepadManager : public QAbstractListModel @@ -24,6 +26,8 @@ public: explicit GamepadManager(QObject *parent = nullptr); ~GamepadManager() override; + static GamepadManager *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine); + enum Roles { DeviceRole = Qt::UserRole + 1, NameRole, @@ -95,6 +99,7 @@ private: bool m_active = false; bool m_sdlInitialized = false; + int m_batteryCounter = 0; QTimer m_pollTimer; QList m_gamepads; }; diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index ce9cf8e6..c27e5a96 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -79,6 +79,7 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES 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 ) diff --git a/containments/homescreens/folio/qml/FavouritesBar.qml b/containments/homescreens/folio/qml/FavouritesBar.qml index 485d9173..31f1be81 100644 --- a/containments/homescreens/folio/qml/FavouritesBar.qml +++ b/containments/homescreens/folio/qml/FavouritesBar.qml @@ -707,15 +707,19 @@ MouseArea { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var win = targetDelegate.Window.window - var scrW = win && win.screen ? win.screen.width : Screen.width - return Math.max(0, Math.min(scrW - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) + 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) var win = targetDelegate.Window.window - var scrH = win && win.screen ? win.screen.height : Screen.height - return Math.max(0, Math.min(scrH - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) + 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 index 5c5c2637..25e52c27 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -12,7 +12,9 @@ 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 @@ -76,6 +78,32 @@ Window { 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) { @@ -130,6 +158,9 @@ Window { searchField.forceActiveFocus() } break + case GamingShell.GamepadManager.ButtonBack: + quickSettings.toggle() + break } } @@ -154,6 +185,21 @@ Window { } 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) { @@ -202,7 +248,7 @@ Window { } // Cycle through source filter tabs (All → Steam → Desktop → All …) - readonly property var _sourceFilters: ["", "steam", "desktop"] + readonly property var _sourceFilters: ["", "steam", "desktop", "lutris", "heroic"] function cycleSourceFilter(direction) { var current = _sourceFilters.indexOf( GamingShell.GameLauncherProvider.sourceFilter) @@ -238,13 +284,63 @@ Window { // ---- header ---- RowLayout { Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing Kirigami.Heading { - Layout.fillWidth: true 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") @@ -372,6 +468,16 @@ Window { width: implicitWidth onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop" } + QQC2.TabButton { + text: "Lutris" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "lutris" + } + QQC2.TabButton { + text: "Heroic" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "heroic" + } } } @@ -534,6 +640,8 @@ Window { Layout.alignment: Qt.AlignHCenter text: source === "steam" ? "Steam" : source === "flatpak" ? "Flatpak" + : source === "lutris" ? "Lutris" + : source === "heroic" ? "Heroic" : "" visible: source !== "desktop" font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 @@ -588,8 +696,8 @@ Window { // Gamepad legend PC3.Label { text: runningGames.hasTasks - ? i18n("A: Select X: Close B: Back Y: Exit LB/RB: Filter ☰: Search") - : i18n("A: Select B: Back Y: Exit LB/RB: Filter ☰: Search") + ? i18n("A: Select X: Close B: Back Y: Exit ⊞: Settings ☰: Search") + : i18n("A: Select B: Back Y: Exit LB/RB: Filter ⊞: Settings ☰: Search") font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 opacity: 0.5 } @@ -597,6 +705,12 @@ Window { } } + // Quick settings slide-out panel + GamingQuickSettings { + id: quickSettings + z: 50 + } + // Launch transition: brief fade to black, then dismiss Rectangle { id: launchCurtain diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml new file mode 100644 index 00000000..937a694a --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -0,0 +1,412 @@ +// 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.screenbrightnessplugin as ScreenBrightness +import org.kde.plasma.private.volume +import org.kde.plasma.networkmanagement as PlasmaNM +import org.kde.bluezqt 1.0 as BluezQt + +Item { + id: root + anchors.fill: parent + + property bool opened: false + + // Focusable controls for gamepad navigation + property var _controls: [] + property int _focusIndex: 0 + + function _buildControlsList() { + var list = [] + if (screenBrightness.brightnessAvailable) list.push(brightnessSlider) + if (PreferredDevice.sink) list.push(volumeSlider) + list.push(wifiSwitch) + list.push(btSwitch) + list.push(airplaneSwitch) + _controls = list + } + + function open() { + opened = true + _buildControlsList() + _focusIndex = 0 + _highlightCurrent() + } + function close() { + opened = false + } + 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 (ctrl instanceof PC3.Slider) { + ctrl.decrease() + ctrl.moved() + } + } + function gamepadRight() { + var ctrl = _controls[_focusIndex] + if (ctrl instanceof PC3.Slider) { + ctrl.increase() + ctrl.moved() + } + } + function gamepadAccept() { + var ctrl = _controls[_focusIndex] + if (ctrl instanceof QQC2.Switch) { + ctrl.toggle() + ctrl.toggled() + } + } + + // 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 } + + // ---- 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) + } + + // Focus highlight + 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 + } + } + + 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) + } + } + + // Focus highlight + 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 + } + } + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "audio-volume-high" + } + } + } + + 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 A: Toggle B: Close") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + opacity: 0.5 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.fillHeight: true } + } + } + } +}