Add gamingshellplugin with SDL3 and game library

SDL3-backed gamepad manager polls at 60Hz, handles hotplug,
exposes battery/rumble/LED per device. Game launcher aggregates
XDG desktop entries, Steam appmanifests, and Flatpak into a
single sorted model.

Game Center uses the new model instead of folio's search model.
D-pad/A/B/Y navigate and launch. Guide button toggles the
overlay. Battery and controller status shown in the HUD.
This commit is contained in:
Marco Allegretti 2026-04-20 09:43:11 +02:00
parent 70fa861b30
commit 1bf8cfb2ba
12 changed files with 1116 additions and 26 deletions

View file

@ -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"

View file

@ -14,3 +14,4 @@ add_subdirectory(wallpaperimageplugin)
add_subdirectory(dpmsplugin)
add_subdirectory(screenbrightnessplugin)
add_subdirectory(raiselockscreenplugin)
add_subdirectory(gamingshellplugin)

View file

@ -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)

View file

@ -0,0 +1,284 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "gamelauncherprovider.h"
#include <KService>
#include <KShell>
#include <KSycoca>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QTextStream>
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<int, QByteArray> 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).
}

View file

@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QAbstractListModel>
#include <QList>
#include <QString>
#include <qqmlregistration.h>
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<int, QByteArray> 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<GameEntry> m_games;
bool m_loading = false;
};

View file

@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "gamepaddevice.h"
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_power.h>
#include <SDL3/SDL_properties.h>
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<uint16_t>(lowFreqMs), static_cast<uint16_t>(highFreqMs), static_cast<uint32_t>(durationMs));
}
bool GamepadDevice::setLED(int r, int g, int b)
{
if (!m_pad) {
return false;
}
return SDL_SetGamepadLED(m_pad, static_cast<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(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();
}
}

View file

@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QObject>
#include <QString>
#include <qqmlregistration.h>
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;
};

View file

@ -0,0 +1,261 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "gamepadmanager.h"
#include "gamepaddevice.h"
#include <SDL3/SDL.h>
#include <SDL3/SDL_gamepad.h>
#include <QDebug>
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<int, QByteArray> 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<float>(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;
}

View file

@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QAbstractListModel>
#include <QTimer>
#include <qqmlregistration.h>
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<int, QByteArray> 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<GamepadDevice *> m_gamepads;
};

View file

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

View file

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

View file

@ -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