Add quick settings panel and game sources

Slide-out panel with brightness, volume, Wi-Fi, Bluetooth,
and airplane mode. Fully gamepad-navigable; D-pad and stick
input routed to the panel while open. System status bar
(clock, battery, connectivity) added to game center header.

Lutris games discovered from ~/.local/share/lutris/pga.db,
Heroic from ~/.config/heroic/store_cache/ JSON files.
Deduplication extended to cover all launcher sources.

Desktop entries launched via KIO::ApplicationLauncherJob to
expand Exec field codes. Generic commands parsed with
KShell::splitArgs. SQLite uses RAII cleanup guard.

Clamped SDL axis, rumble, and LED values. Renamed rumble
params from frequency to intensity. GamepadManager uses
singleton factory. setPlayerIndex checks SDL return.

FavouritesBar popup uses screen virtualX/Y for multi-monitor.
This commit is contained in:
Marco Allegretti 2026-04-20 14:09:33 +02:00
parent 4a9a5b2a97
commit bcb985c652
11 changed files with 767 additions and 31 deletions

View file

@ -2,6 +2,7 @@
# SPDX-License-Identifier: EUPL-1.2 # SPDX-License-Identifier: EUPL-1.2
find_package(SDL3 REQUIRED) 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) 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::Core
Qt::Qml Qt::Qml
Qt::Quick Qt::Quick
Qt::Sql
KF6::I18n KF6::I18n
KF6::KIOGui
KF6::Service KF6::Service
KF6::CoreAddons KF6::CoreAddons
KF6::ConfigCore KF6::ConfigCore

View file

@ -4,6 +4,7 @@
#include "gamelauncherprovider.h" #include "gamelauncherprovider.h"
#include <KConfigGroup> #include <KConfigGroup>
#include <KIO/ApplicationLauncherJob>
#include <KService> #include <KService>
#include <KSharedConfig> #include <KSharedConfig>
#include <KShell> #include <KShell>
@ -12,8 +13,13 @@
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess> #include <QProcess>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTextStream> #include <QTextStream>
@ -90,6 +96,8 @@ void GameLauncherProvider::refresh()
loadDesktopGames(); loadDesktopGames();
loadSteamGames(); loadSteamGames();
loadFlatpakGames(); loadFlatpakGames();
loadLutrisGames();
loadHeroicGames();
loadRecentTimestamps(); loadRecentTimestamps();
// Deduplicate: when the same game appears from multiple sources, // Deduplicate: when the same game appears from multiple sources,
@ -137,14 +145,14 @@ void GameLauncherProvider::launchEntry(GameEntry &entry)
if (entry.source == QLatin1String("desktop")) { if (entry.source == QLatin1String("desktop")) {
auto service = KService::serviceByStorageId(entry.storageId); auto service = KService::serviceByStorageId(entry.storageId);
if (service) { if (service) {
QStringList args = KShell::splitArgs(service->exec()); auto *job = new KIO::ApplicationLauncherJob(service);
if (!args.isEmpty()) { job->start();
QString program = args.takeFirst();
QProcess::startDetached(program, args);
}
} }
} else if (entry.launchCommand.contains(QStringLiteral("://"))) {
// Protocol handler (e.g. heroic://launch/...) — open via xdg-open
QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand});
} else { } else {
QStringList parts = entry.launchCommand.split(QLatin1Char(' ')); QStringList parts = KShell::splitArgs(entry.launchCommand);
if (!parts.isEmpty()) { if (!parts.isEmpty()) {
QString program = parts.takeFirst(); QString program = parts.takeFirst();
QProcess::startDetached(program, parts); QProcess::startDetached(program, parts);
@ -159,19 +167,20 @@ void GameLauncherProvider::launchEntry(GameEntry &entry)
void GameLauncherProvider::deduplicateGames() void GameLauncherProvider::deduplicateGames()
{ {
// Build a set of names from Steam entries (case-insensitive). // Build a set of names from dedicated launcher entries (case-insensitive).
QSet<QString> steamNames; // These have better artwork and metadata, so they win over plain .desktop entries.
QSet<QString> launcherNames;
for (const auto &g : std::as_const(m_allGames)) { for (const auto &g : std::as_const(m_allGames)) {
if (g.source == QLatin1String("steam")) { if (g.source == QLatin1String("steam") || g.source == QLatin1String("lutris") || g.source == QLatin1String("heroic")) {
steamNames.insert(g.name.toLower()); 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.erase(std::remove_if(m_allGames.begin(),
m_allGames.end(), m_allGames.end(),
[&steamNames](const GameEntry &g) { [&launcherNames](const GameEntry &g) {
return g.source == QLatin1String("desktop") && steamNames.contains(g.name.toLower()); return g.source == QLatin1String("desktop") && launcherNames.contains(g.name.toLower());
}), }),
m_allGames.end()); m_allGames.end());
} }
@ -329,6 +338,171 @@ void GameLauncherProvider::loadFlatpakGames()
// the Game category properly). // 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<quintptr>(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 QString GameLauncherProvider::filterString() const
{ {
return m_filterString; return m_filterString;

View file

@ -74,6 +74,8 @@ private:
void loadDesktopGames(); void loadDesktopGames();
void loadSteamGames(); void loadSteamGames();
void loadFlatpakGames(); void loadFlatpakGames();
void loadLutrisGames();
void loadHeroicGames();
void deduplicateGames(); void deduplicateGames();
void loadRecentTimestamps(); void loadRecentTimestamps();
void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void saveRecentTimestamp(const QString &storageId, const QDateTime &when);

View file

@ -7,6 +7,8 @@
#include <SDL3/SDL_power.h> #include <SDL3/SDL_power.h>
#include <SDL3/SDL_properties.h> #include <SDL3/SDL_properties.h>
#include <algorithm>
GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent) GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent)
: QObject(parent) : QObject(parent)
, m_pad(pad) , m_pad(pad)
@ -96,16 +98,20 @@ void GamepadDevice::setPlayerIndex(int index)
if (!m_pad) { if (!m_pad) {
return; return;
} }
SDL_SetGamepadPlayerIndex(m_pad, index); if (SDL_SetGamepadPlayerIndex(m_pad, index)) {
Q_EMIT playerIndexChanged(); Q_EMIT playerIndexChanged();
} }
}
bool GamepadDevice::rumble(int lowFreqMs, int highFreqMs, int durationMs) bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs)
{ {
if (!m_pad) { if (!m_pad) {
return false; return false;
} }
return SDL_RumbleGamepad(m_pad, static_cast<uint16_t>(lowFreqMs), static_cast<uint16_t>(highFreqMs), static_cast<uint32_t>(durationMs)); auto lo = static_cast<uint16_t>(std::clamp(lowIntensity, 0, 65535));
auto hi = static_cast<uint16_t>(std::clamp(highIntensity, 0, 65535));
auto dur = static_cast<uint32_t>(std::clamp(durationMs, 0, durationMs));
return SDL_RumbleGamepad(m_pad, lo, hi, dur);
} }
bool GamepadDevice::setLED(int r, int g, int b) 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) { if (!m_pad) {
return false; return false;
} }
return SDL_SetGamepadLED(m_pad, static_cast<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(b)); auto cr = static_cast<uint8_t>(std::clamp(r, 0, 255));
auto cg = static_cast<uint8_t>(std::clamp(g, 0, 255));
auto cb = static_cast<uint8_t>(std::clamp(b, 0, 255));
return SDL_SetGamepadLED(m_pad, cr, cg, cb);
} }
SDL_Gamepad *GamepadDevice::sdlGamepad() const SDL_Gamepad *GamepadDevice::sdlGamepad() const

View file

@ -36,7 +36,7 @@ public:
int playerIndex() const; int playerIndex() const;
void setPlayerIndex(int index); 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); Q_INVOKABLE bool setLED(int r, int g, int b);
SDL_Gamepad *sdlGamepad() const; SDL_Gamepad *sdlGamepad() const;

View file

@ -8,6 +8,9 @@
#include <SDL3/SDL_gamepad.h> #include <SDL3/SDL_gamepad.h>
#include <QDebug> #include <QDebug>
#include <QQmlEngine>
#include <algorithm>
GamepadManager::GamepadManager(QObject *parent) GamepadManager::GamepadManager(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
@ -21,6 +24,16 @@ GamepadManager::~GamepadManager()
stop(); 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 --- // --- QAbstractListModel ---
int GamepadManager::rowCount(const QModelIndex &parent) const int GamepadManager::rowCount(const QModelIndex &parent) const
@ -180,7 +193,7 @@ void GamepadManager::poll()
case SDL_EVENT_GAMEPAD_AXIS_MOTION: { case SDL_EVENT_GAMEPAD_AXIS_MOTION: {
int idx = indexForInstanceId(event.gaxis.which); int idx = indexForInstanceId(event.gaxis.which);
if (idx >= 0) { if (idx >= 0) {
float normalized = static_cast<float>(event.gaxis.value) / 32767.0f; float normalized = std::clamp(static_cast<float>(event.gaxis.value) / 32767.0f, -1.0f, 1.0f);
Q_EMIT axisChanged(event.gaxis.axis, normalized, idx); Q_EMIT axisChanged(event.gaxis.axis, normalized, idx);
} }
break; break;
@ -192,9 +205,8 @@ void GamepadManager::poll()
} }
// Refresh battery state periodically (every ~5 seconds = 300 frames) // Refresh battery state periodically (every ~5 seconds = 300 frames)
static int batteryCounter = 0; if (++m_batteryCounter >= 300) {
if (++batteryCounter >= 300) { m_batteryCounter = 0;
batteryCounter = 0;
for (auto *dev : std::as_const(m_gamepads)) { for (auto *dev : std::as_const(m_gamepads)) {
dev->refreshBattery(); dev->refreshBattery();
} }

View file

@ -7,6 +7,8 @@
#include <QTimer> #include <QTimer>
#include <qqmlregistration.h> #include <qqmlregistration.h>
class QQmlEngine;
class QJSEngine;
class GamepadDevice; class GamepadDevice;
class GamepadManager : public QAbstractListModel class GamepadManager : public QAbstractListModel
@ -24,6 +26,8 @@ public:
explicit GamepadManager(QObject *parent = nullptr); explicit GamepadManager(QObject *parent = nullptr);
~GamepadManager() override; ~GamepadManager() override;
static GamepadManager *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine);
enum Roles { enum Roles {
DeviceRole = Qt::UserRole + 1, DeviceRole = Qt::UserRole + 1,
NameRole, NameRole,
@ -95,6 +99,7 @@ private:
bool m_active = false; bool m_active = false;
bool m_sdlInitialized = false; bool m_sdlInitialized = false;
int m_batteryCounter = 0;
QTimer m_pollTimer; QTimer m_pollTimer;
QList<GamepadDevice *> m_gamepads; QList<GamepadDevice *> m_gamepads;
}; };

View file

@ -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 ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
qml/gaming/GameCenterOverlay.qml qml/gaming/GameCenterOverlay.qml
qml/gaming/GamingHUD.qml qml/gaming/GamingHUD.qml
qml/gaming/GamingQuickSettings.qml
qml/gaming/RunningGamesView.qml qml/gaming/RunningGamesView.qml
PATH gaming PATH gaming
) )

View file

@ -707,15 +707,19 @@ MouseArea {
if (!targetDelegate) return 0 if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
var win = targetDelegate.Window.window var win = targetDelegate.Window.window
var scrW = win && win.screen ? win.screen.width : Screen.width var screenLeft = win && win.screen ? win.screen.virtualX : 0
return Math.max(0, Math.min(scrW - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) 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: { y: {
if (!targetDelegate) return 0 if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
var win = targetDelegate.Window.window var win = targetDelegate.Window.window
var scrH = win && win.screen ? win.screen.height : Screen.height var screenTop = win && win.screen ? win.screen.virtualY : 0
return Math.max(0, Math.min(scrH - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) 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: { onShowingChanged: {

View file

@ -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.state as MobileShellState
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell 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.layershell 1.0 as LayerShell
import org.kde.plasma.clock
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
@ -76,6 +78,32 @@ Window {
enabled: root.visible enabled: root.visible
function onButtonPressed(button, gamepadIndex) { 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) { switch (button) {
case GamingShell.GamepadManager.ButtonDPadUp: case GamingShell.GamepadManager.ButtonDPadUp:
if (grid.activeFocus) { if (grid.activeFocus) {
@ -130,6 +158,9 @@ Window {
searchField.forceActiveFocus() searchField.forceActiveFocus()
} }
break break
case GamingShell.GamepadManager.ButtonBack:
quickSettings.toggle()
break
} }
} }
@ -154,6 +185,21 @@ Window {
} }
function navigateByStick() { 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 (stickState.leftY < -stickState.deadzone) {
if (grid.activeFocus) { if (grid.activeFocus) {
if (grid.currentIndex < grid.columns && runningGames.hasTasks) { if (grid.currentIndex < grid.columns && runningGames.hasTasks) {
@ -202,7 +248,7 @@ Window {
} }
// Cycle through source filter tabs (All Steam Desktop All ) // 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) { function cycleSourceFilter(direction) {
var current = _sourceFilters.indexOf( var current = _sourceFilters.indexOf(
GamingShell.GameLauncherProvider.sourceFilter) GamingShell.GameLauncherProvider.sourceFilter)
@ -238,13 +284,63 @@ Window {
// ---- header ---- // ---- header ----
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
Kirigami.Heading { Kirigami.Heading {
Layout.fillWidth: true
text: i18n("Game Center") text: i18n("Game Center")
level: 1 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 { QQC2.ToolButton {
icon.name: "window-close" icon.name: "window-close"
text: i18n("Exit Gaming Mode") text: i18n("Exit Gaming Mode")
@ -372,6 +468,16 @@ Window {
width: implicitWidth width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop" 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 Layout.alignment: Qt.AlignHCenter
text: source === "steam" ? "Steam" text: source === "steam" ? "Steam"
: source === "flatpak" ? "Flatpak" : source === "flatpak" ? "Flatpak"
: source === "lutris" ? "Lutris"
: source === "heroic" ? "Heroic"
: "" : ""
visible: source !== "desktop" visible: source !== "desktop"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
@ -588,8 +696,8 @@ Window {
// Gamepad legend // Gamepad legend
PC3.Label { PC3.Label {
text: runningGames.hasTasks text: runningGames.hasTasks
? i18n("A: Select X: Close 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 ☰: Search") : i18n("A: Select B: Back Y: Exit LB/RB: Filter ⊞: Settings ☰: Search")
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.5 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 // Launch transition: brief fade to black, then dismiss
Rectangle { Rectangle {
id: launchCurtain id: launchCurtain

View file

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