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 } + } + } + } +}