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/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..0e823be6 --- /dev/null +++ b/components/gamingshellplugin/CMakeLists.txt @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +find_package(SDL3 REQUIRED) + +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 +) + +target_link_libraries(gamingshellplugin PRIVATE + Qt::Core + Qt::Qml + Qt::Quick + KF6::I18n + KF6::Service + KF6::CoreAddons + 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..1b1fafb5 --- /dev/null +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamelauncherprovider.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +GameLauncherProvider::GameLauncherProvider(QObject *parent) + : QAbstractListModel(parent) +{ + connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); + 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 ArtworkRole: + return g.artwork; + case InstalledRole: + return g.installed; + } + return {}; +} + +QHash GameLauncherProvider::roleNames() const +{ + return { + {NameRole, "name"}, + {IconRole, "icon"}, + {SourceRole, "source"}, + {StorageIdRole, "storageId"}, + {LaunchCommandRole, "launchCommand"}, + {ArtworkRole, "artwork"}, + {InstalledRole, "installed"}, + }; +} + +int GameLauncherProvider::count() const +{ + return m_games.size(); +} + +bool GameLauncherProvider::loading() const +{ + return m_loading; +} + +void GameLauncherProvider::refresh() +{ + m_loading = true; + Q_EMIT loadingChanged(); + + beginResetModel(); + m_games.clear(); + + loadDesktopGames(); + loadSteamGames(); + loadFlatpakGames(); + + // Sort alphabetically, case-insensitive + std::sort(m_games.begin(), m_games.end(), [](const GameEntry &a, const GameEntry &b) { + return a.name.compare(b.name, Qt::CaseInsensitive) < 0; + }); + + endResetModel(); + + m_loading = false; + Q_EMIT loadingChanged(); + Q_EMIT countChanged(); +} + +void GameLauncherProvider::launch(int index) +{ + if (index < 0 || index >= m_games.size()) { + return; + } + const auto &g = m_games.at(index); + + if (g.source == QLatin1String("desktop")) { + // Launch via KService for proper activation tracking + auto service = KService::serviceByStorageId(g.storageId); + if (service) { + // Use QProcess to launch the exec line — KIO::ApplicationLauncherJob + // would be better but requires KIOWidgets which is heavy for a plugin. + QStringList args = KShell::splitArgs(service->exec()); + if (!args.isEmpty()) { + QString program = args.takeFirst(); + QProcess::startDetached(program, args); + } + } + } else { + // Steam, Flatpak, etc. — run the launch command directly + QStringList parts = g.launchCommand.split(QLatin1Char(' ')); + if (!parts.isEmpty()) { + QString program = parts.takeFirst(); + QProcess::startDetached(program, parts); + } + } + + Q_EMIT gameLaunched(g.name); +} + +// --- XDG .desktop games --- + +void GameLauncherProvider::loadDesktopGames() +{ + 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; + for (const auto &cat : cats) { + if (cat.compare(QLatin1String("Game"), Qt::CaseInsensitive) == 0) { + isGame = true; + break; + } + } + if (!isGame) { + continue; + } + + GameEntry entry; + entry.name = service->name(); + entry.icon = service->icon(); + entry.source = QStringLiteral("desktop"); + entry.storageId = service->storageId(); + entry.launchCommand = service->exec(); + entry.installed = true; + m_games.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; + } + // Simple parse: look for "path" lines + static const QRegularExpression pathRe(QStringLiteral("\"path\"\\s+\"([^\"]+)\"")); + QTextStream stream(&vdf); + while (!stream.atEnd()) { + const QString line = stream.readLine(); + auto match = pathRe.match(line); + if (match.hasMatch()) { + libraryPaths.append(match.captured(1)); + } + } + } + + // Scan each library path for appmanifest_*.acf + static const QRegularExpression nameRe(QStringLiteral("\"name\"\\s+\"([^\"]+)\"")); + static const QRegularExpression appidRe(QStringLiteral("\"appid\"\\s+\"(\\d+)\"")); + + 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; + } + QString appName; + QString appId; + QTextStream ts(&f); + while (!ts.atEnd()) { + const QString line = ts.readLine(); + if (appName.isEmpty()) { + auto m = nameRe.match(line); + if (m.hasMatch()) { + appName = m.captured(1); + } + } + if (appId.isEmpty()) { + auto m = appidRe.match(line); + if (m.hasMatch()) { + appId = m.captured(1); + } + } + if (!appName.isEmpty() && !appId.isEmpty()) { + break; + } + } + + 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; + entry.installed = true; + + // 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_games.append(entry); + } + } +} + +// --- Flatpak games (non-Steam) --- + +void GameLauncherProvider::loadFlatpakGames() +{ + // Flatpak games that export .desktop files with Game category + // are already picked up by loadDesktopGames() via KService. + // This method is a hook for future Flatpak-specific enrichment + // (e.g. querying flatpak metadata for games that don't set + // the Game category properly). +} diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h new file mode 100644 index 00000000..b570bc81 --- /dev/null +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#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) + +public: + explicit GameLauncherProvider(QObject *parent = nullptr); + + enum Roles { + NameRole = Qt::UserRole + 1, + IconRole, + SourceRole, // "desktop", "steam", "flatpak" + StorageIdRole, // .desktop file name or launch URI + LaunchCommandRole, + ArtworkRole, // path to banner/grid image (empty if none) + InstalledRole, + }; + 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; + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void launch(int index); + +Q_SIGNALS: + void countChanged(); + void loadingChanged(); + void gameLaunched(const QString &name); + +private: + struct GameEntry { + QString name; + QString icon; + QString source; + QString storageId; + QString launchCommand; + QString artwork; + bool installed = true; + }; + + void loadDesktopGames(); + void loadSteamGames(); + void loadFlatpakGames(); + + QList m_games; + bool m_loading = false; +}; diff --git a/components/gamingshellplugin/gamepaddevice.cpp b/components/gamingshellplugin/gamepaddevice.cpp new file mode 100644 index 00000000..95208682 --- /dev/null +++ b/components/gamingshellplugin/gamepaddevice.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamepaddevice.h" + +#include +#include +#include + +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::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::playerIndex() const +{ + if (!m_pad) { + return -1; + } + return SDL_GetGamepadPlayerIndex(m_pad); +} + +void GamepadDevice::setPlayerIndex(int index) +{ + if (!m_pad) { + return; + } + SDL_SetGamepadPlayerIndex(m_pad, index); + Q_EMIT playerIndexChanged(); +} + +bool GamepadDevice::rumble(int lowFreqMs, int highFreqMs, int durationMs) +{ + if (!m_pad) { + return false; + } + return SDL_RumbleGamepad(m_pad, static_cast(lowFreqMs), static_cast(highFreqMs), static_cast(durationMs)); +} + +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)); +} + +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..637ace61 --- /dev/null +++ b/components/gamingshellplugin/gamepaddevice.h @@ -0,0 +1,53 @@ +// 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 hasLED READ hasLED 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 hasLED() const; + int playerIndex() const; + void setPlayerIndex(int index); + + Q_INVOKABLE bool rumble(int lowFreqMs, int highFreqMs, int durationMs); + Q_INVOKABLE bool setLED(int r, int g, int b); + + 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..c3958558 --- /dev/null +++ b/components/gamingshellplugin/gamepadmanager.cpp @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamepadmanager.h" +#include "gamepaddevice.h" + +#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(); +} + +// --- 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); +} + +// --- Lifecycle --- + +void GamepadManager::start() +{ + if (m_sdlInitialized) { + return; + } + if (!SDL_Init(SDL_INIT_GAMEPAD)) { + qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError(); + return; + } + m_sdlInitialized = 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 = static_cast(event.gaxis.value) / 32767.0f; + Q_EMIT axisChanged(event.gaxis.axis, normalized, idx); + } + break; + } + + default: + break; + } + } + + // Refresh battery state periodically (every ~5 seconds = 300 frames) + static int batteryCounter = 0; + if (++batteryCounter >= 300) { + 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..eaab5fa2 --- /dev/null +++ b/components/gamingshellplugin/gamepadmanager.h @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include + +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; + + 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) + + 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; + + Q_INVOKABLE GamepadDevice *gamepadAt(int index) 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; + QTimer m_pollTimer; + QList m_gamepads; +}; diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 5caf136e..e19b2de5 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -11,6 +11,7 @@ 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.layershell 1.0 as LayerShell import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio @@ -48,7 +49,7 @@ Window { onVisibleChanged: { if (visible) { - root.folio.ApplicationListSearchModel.categoryFilter = "Game" + GamingShell.GameLauncherProvider.refresh() if (runningGames.hasTasks) { runningGames.focusFirstTask() } else { @@ -57,6 +58,53 @@ Window { } } + // Translate gamepad buttons to focus navigation + Connections { + target: GamingShell.GamepadManager + enabled: root.visible + + function onButtonPressed(button, gamepadIndex) { + switch (button) { + case GamingShell.GamepadManager.ButtonDPadUp: + if (grid.activeFocus) { + if (grid.currentIndex < grid.columns && runningGames.hasTasks) { + runningGames.focusFirstTask() + } else { + grid.moveCurrentIndexUp() + } + } + break + case GamingShell.GamepadManager.ButtonDPadDown: + if (taskList.activeFocus || runningGames.activeFocus) { + grid.forceActiveFocus() + } else if (grid.activeFocus) { + grid.moveCurrentIndexDown() + } + break + case GamingShell.GamepadManager.ButtonDPadLeft: + if (grid.activeFocus) grid.moveCurrentIndexLeft() + break + case GamingShell.GamepadManager.ButtonDPadRight: + if (grid.activeFocus) grid.moveCurrentIndexRight() + break + case GamingShell.GamepadManager.ButtonA: + if (grid.activeFocus && grid.currentItem) { + GamingShell.GameLauncherProvider.launch(grid.currentIndex) + root.gameStarted() + } else if (taskList.activeFocus && taskList.currentItem) { + taskList.currentItem.activate() + } + break + case GamingShell.GamepadManager.ButtonB: + root.dismissRequested() + break + case GamingShell.GamepadManager.ButtonY: + root.requestExitGamingMode() + break + } + } + } + Rectangle { anchors.fill: parent Kirigami.Theme.inherit: false @@ -119,11 +167,7 @@ Window { Layout.fillWidth: true Layout.fillHeight: true - // Filter the app list to the Games XDG category. - // ApplicationListSearchModel.categoryFilter is declared in - // applicationlistmodel.h and filters on the CategoriesRole of - // ApplicationListModel. - model: root.folio.ApplicationListSearchModel + model: GamingShell.GameLauncherProvider readonly property real minCellSize: Kirigami.Units.gridUnit * 7 readonly property int columns: Math.max(2, Math.floor(width / minCellSize)) @@ -147,23 +191,129 @@ Window { } } - Keys.onReturnPressed: currentItem && currentItem.launch() - Keys.onEnterPressed: currentItem && currentItem.launch() + Keys.onReturnPressed: { + if (currentIndex >= 0) { + GamingShell.GameLauncherProvider.launch(currentIndex) + root.gameStarted() + } + } + Keys.onEnterPressed: Keys.onReturnPressed(event) Keys.onEscapePressed: root.dismissRequested() delegate: Item { width: grid.cellWidth height: grid.cellHeight - GameTile { + required property int index + required property string name + required property string icon + required property string source + + QQC2.ItemDelegate { anchors.fill: parent - folio: root.folio - application: model.delegate ? model.delegate.application : null - isCurrent: GridView.isCurrentItem && grid.activeFocus - onLaunchRequested: root.gameStarted() + + 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 + Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } + } + + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.huge + implicitHeight: Kirigami.Units.iconSizes.huge + source: icon + + scale: 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.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor + } + + // Source badge + PC3.Label { + Layout.alignment: Qt.AlignHCenter + text: source === "steam" ? "Steam" + : source === "flatpak" ? "Flatpak" + : "" + visible: source !== "desktop" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 + opacity: 0.6 + } + } + + onClicked: { + GamingShell.GameLauncherProvider.launch(index) + root.gameStarted() + } } } } + + // ---- 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 + + 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 + } + } + } + + Item { Layout.fillWidth: true } + + // Gamepad legend + PC3.Label { + text: i18n("A: Select B: Back Y: Exit") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 + opacity: 0.5 + } + } } } diff --git a/containments/homescreens/folio/qml/gaming/GamingHUD.qml b/containments/homescreens/folio/qml/gaming/GamingHUD.qml index 6c04e3b8..b39fae4a 100644 --- a/containments/homescreens/folio/qml/gaming/GamingHUD.qml +++ b/containments/homescreens/folio/qml/gaming/GamingHUD.qml @@ -7,6 +7,7 @@ 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 { @@ -43,14 +44,31 @@ Window { radius: height / 2 color: Qt.rgba(0, 0, 0, 0.55) - QQC2.ToolButton { + Row { anchors.centerIn: parent - icon.name: "input-gaming" - icon.color: "white" - display: QQC2.AbstractButton.IconOnly - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: i18n("Game Center") - onClicked: root.openRequested() + spacing: Kirigami.Units.smallSpacing + + 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() + } + + // Show primary gamepad battery when connected + 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 + anchors.verticalCenter: parent.verticalCenter + } } } } diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index 15e7dabd..d1b7ffbc 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 @@ -49,11 +50,24 @@ ContainmentItem { target: ShellSettings.Settings function onGamingModeEnabledChanged() { root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled + GamingShell.GamepadManager.active = 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.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled + GamingShell.GamepadManager.active = ShellSettings.Settings.gamingModeEnabled folio.FolioSettings.load(); folio.FavouritesModel.load(); folio.PageListModel.load(); @@ -690,12 +704,6 @@ ContainmentItem { gameCenterHintTimer.restart() } } - - onVisibleChanged: { - if (!visible) { - folio.ApplicationListSearchModel.categoryFilter = "" - } - } } // Small persistent button at the top-right corner of the screen that lets