mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-04-29 15:03:09 +00:00
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:
parent
4a9a5b2a97
commit
bcb985c652
11 changed files with 767 additions and 31 deletions
|
|
@ -2,6 +2,7 @@
|
|||
# SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
find_package(SDL3 REQUIRED)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Sql)
|
||||
|
||||
ecm_add_qml_module(gamingshellplugin URI org.kde.plasma.private.mobileshell.gamingshellplugin GENERATE_PLUGIN_SOURCE)
|
||||
|
||||
|
|
@ -15,7 +16,9 @@ target_link_libraries(gamingshellplugin PRIVATE
|
|||
Qt::Core
|
||||
Qt::Qml
|
||||
Qt::Quick
|
||||
Qt::Sql
|
||||
KF6::I18n
|
||||
KF6::KIOGui
|
||||
KF6::Service
|
||||
KF6::CoreAddons
|
||||
KF6::ConfigCore
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "gamelauncherprovider.h"
|
||||
|
||||
#include <KConfigGroup>
|
||||
#include <KIO/ApplicationLauncherJob>
|
||||
#include <KService>
|
||||
#include <KSharedConfig>
|
||||
#include <KShell>
|
||||
|
|
@ -12,8 +13,13 @@
|
|||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QStandardPaths>
|
||||
#include <QTextStream>
|
||||
|
||||
|
|
@ -90,6 +96,8 @@ void GameLauncherProvider::refresh()
|
|||
loadDesktopGames();
|
||||
loadSteamGames();
|
||||
loadFlatpakGames();
|
||||
loadLutrisGames();
|
||||
loadHeroicGames();
|
||||
loadRecentTimestamps();
|
||||
|
||||
// Deduplicate: when the same game appears from multiple sources,
|
||||
|
|
@ -137,14 +145,14 @@ void GameLauncherProvider::launchEntry(GameEntry &entry)
|
|||
if (entry.source == QLatin1String("desktop")) {
|
||||
auto service = KService::serviceByStorageId(entry.storageId);
|
||||
if (service) {
|
||||
QStringList args = KShell::splitArgs(service->exec());
|
||||
if (!args.isEmpty()) {
|
||||
QString program = args.takeFirst();
|
||||
QProcess::startDetached(program, args);
|
||||
}
|
||||
auto *job = new KIO::ApplicationLauncherJob(service);
|
||||
job->start();
|
||||
}
|
||||
} else if (entry.launchCommand.contains(QStringLiteral("://"))) {
|
||||
// Protocol handler (e.g. heroic://launch/...) — open via xdg-open
|
||||
QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand});
|
||||
} else {
|
||||
QStringList parts = entry.launchCommand.split(QLatin1Char(' '));
|
||||
QStringList parts = KShell::splitArgs(entry.launchCommand);
|
||||
if (!parts.isEmpty()) {
|
||||
QString program = parts.takeFirst();
|
||||
QProcess::startDetached(program, parts);
|
||||
|
|
@ -159,19 +167,20 @@ void GameLauncherProvider::launchEntry(GameEntry &entry)
|
|||
|
||||
void GameLauncherProvider::deduplicateGames()
|
||||
{
|
||||
// Build a set of names from Steam entries (case-insensitive).
|
||||
QSet<QString> steamNames;
|
||||
// Build a set of names from dedicated launcher entries (case-insensitive).
|
||||
// These have better artwork and metadata, so they win over plain .desktop entries.
|
||||
QSet<QString> launcherNames;
|
||||
for (const auto &g : std::as_const(m_allGames)) {
|
||||
if (g.source == QLatin1String("steam")) {
|
||||
steamNames.insert(g.name.toLower());
|
||||
if (g.source == QLatin1String("steam") || g.source == QLatin1String("lutris") || g.source == QLatin1String("heroic")) {
|
||||
launcherNames.insert(g.name.toLower());
|
||||
}
|
||||
}
|
||||
|
||||
// Remove desktop entries whose name matches a Steam entry.
|
||||
// Remove desktop entries whose name matches a launcher entry.
|
||||
m_allGames.erase(std::remove_if(m_allGames.begin(),
|
||||
m_allGames.end(),
|
||||
[&steamNames](const GameEntry &g) {
|
||||
return g.source == QLatin1String("desktop") && steamNames.contains(g.name.toLower());
|
||||
[&launcherNames](const GameEntry &g) {
|
||||
return g.source == QLatin1String("desktop") && launcherNames.contains(g.name.toLower());
|
||||
}),
|
||||
m_allGames.end());
|
||||
}
|
||||
|
|
@ -329,6 +338,171 @@ void GameLauncherProvider::loadFlatpakGames()
|
|||
// the Game category properly).
|
||||
}
|
||||
|
||||
// --- Lutris library (SQLite) ---
|
||||
|
||||
void GameLauncherProvider::loadLutrisGames()
|
||||
{
|
||||
const QString dbPath = QDir::homePath() + QStringLiteral("/.local/share/lutris/pga.db");
|
||||
if (!QFile::exists(dbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a unique connection name to avoid conflicting with other code.
|
||||
// RAII guard ensures QSqlDatabase::removeDatabase runs on every exit path.
|
||||
const QString connName = QStringLiteral("lutris_games_%1").arg(reinterpret_cast<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
|
||||
{
|
||||
return m_filterString;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ private:
|
|||
void loadDesktopGames();
|
||||
void loadSteamGames();
|
||||
void loadFlatpakGames();
|
||||
void loadLutrisGames();
|
||||
void loadHeroicGames();
|
||||
void deduplicateGames();
|
||||
void loadRecentTimestamps();
|
||||
void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
#include <SDL3/SDL_power.h>
|
||||
#include <SDL3/SDL_properties.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_pad(pad)
|
||||
|
|
@ -96,16 +98,20 @@ void GamepadDevice::setPlayerIndex(int index)
|
|||
if (!m_pad) {
|
||||
return;
|
||||
}
|
||||
SDL_SetGamepadPlayerIndex(m_pad, index);
|
||||
Q_EMIT playerIndexChanged();
|
||||
if (SDL_SetGamepadPlayerIndex(m_pad, index)) {
|
||||
Q_EMIT playerIndexChanged();
|
||||
}
|
||||
}
|
||||
|
||||
bool GamepadDevice::rumble(int lowFreqMs, int highFreqMs, int durationMs)
|
||||
bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs)
|
||||
{
|
||||
if (!m_pad) {
|
||||
return false;
|
||||
}
|
||||
return SDL_RumbleGamepad(m_pad, static_cast<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)
|
||||
|
|
@ -113,7 +119,10 @@ bool GamepadDevice::setLED(int r, int g, int b)
|
|||
if (!m_pad) {
|
||||
return false;
|
||||
}
|
||||
return SDL_SetGamepadLED(m_pad, static_cast<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
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ public:
|
|||
int playerIndex() const;
|
||||
void setPlayerIndex(int index);
|
||||
|
||||
Q_INVOKABLE bool rumble(int lowFreqMs, int highFreqMs, int durationMs);
|
||||
Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs);
|
||||
Q_INVOKABLE bool setLED(int r, int g, int b);
|
||||
|
||||
SDL_Gamepad *sdlGamepad() const;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
#include <SDL3/SDL_gamepad.h>
|
||||
|
||||
#include <QDebug>
|
||||
#include <QQmlEngine>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
GamepadManager::GamepadManager(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
|
|
@ -21,6 +24,16 @@ GamepadManager::~GamepadManager()
|
|||
stop();
|
||||
}
|
||||
|
||||
GamepadManager *GamepadManager::create(QQmlEngine *qmlEngine, QJSEngine *jsEngine)
|
||||
{
|
||||
Q_UNUSED(jsEngine)
|
||||
static GamepadManager *s_instance = nullptr;
|
||||
if (!s_instance) {
|
||||
s_instance = new GamepadManager(qmlEngine);
|
||||
}
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
// --- QAbstractListModel ---
|
||||
|
||||
int GamepadManager::rowCount(const QModelIndex &parent) const
|
||||
|
|
@ -180,7 +193,7 @@ void GamepadManager::poll()
|
|||
case SDL_EVENT_GAMEPAD_AXIS_MOTION: {
|
||||
int idx = indexForInstanceId(event.gaxis.which);
|
||||
if (idx >= 0) {
|
||||
float normalized = static_cast<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);
|
||||
}
|
||||
break;
|
||||
|
|
@ -192,9 +205,8 @@ void GamepadManager::poll()
|
|||
}
|
||||
|
||||
// Refresh battery state periodically (every ~5 seconds = 300 frames)
|
||||
static int batteryCounter = 0;
|
||||
if (++batteryCounter >= 300) {
|
||||
batteryCounter = 0;
|
||||
if (++m_batteryCounter >= 300) {
|
||||
m_batteryCounter = 0;
|
||||
for (auto *dev : std::as_const(m_gamepads)) {
|
||||
dev->refreshBattery();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
#include <QTimer>
|
||||
#include <qqmlregistration.h>
|
||||
|
||||
class QQmlEngine;
|
||||
class QJSEngine;
|
||||
class GamepadDevice;
|
||||
|
||||
class GamepadManager : public QAbstractListModel
|
||||
|
|
@ -24,6 +26,8 @@ public:
|
|||
explicit GamepadManager(QObject *parent = nullptr);
|
||||
~GamepadManager() override;
|
||||
|
||||
static GamepadManager *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine);
|
||||
|
||||
enum Roles {
|
||||
DeviceRole = Qt::UserRole + 1,
|
||||
NameRole,
|
||||
|
|
@ -95,6 +99,7 @@ private:
|
|||
|
||||
bool m_active = false;
|
||||
bool m_sdlInitialized = false;
|
||||
int m_batteryCounter = 0;
|
||||
QTimer m_pollTimer;
|
||||
QList<GamepadDevice *> m_gamepads;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
|
|||
ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
|
||||
qml/gaming/GameCenterOverlay.qml
|
||||
qml/gaming/GamingHUD.qml
|
||||
qml/gaming/GamingQuickSettings.qml
|
||||
qml/gaming/RunningGamesView.qml
|
||||
PATH gaming
|
||||
)
|
||||
|
|
|
|||
|
|
@ -707,15 +707,19 @@ MouseArea {
|
|||
if (!targetDelegate) return 0
|
||||
var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
|
||||
var win = targetDelegate.Window.window
|
||||
var scrW = win && win.screen ? win.screen.width : Screen.width
|
||||
return Math.max(0, Math.min(scrW - width, delegateGlobal.x + (targetDelegate.width - width) / 2))
|
||||
var screenLeft = win && win.screen ? win.screen.virtualX : 0
|
||||
var screenRight = screenLeft + (win && win.screen ? win.screen.width : Screen.width)
|
||||
var centered = delegateGlobal.x + (targetDelegate.width - width) / 2
|
||||
return Math.max(screenLeft, Math.min(screenRight - width, centered))
|
||||
}
|
||||
y: {
|
||||
if (!targetDelegate) return 0
|
||||
var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
|
||||
var win = targetDelegate.Window.window
|
||||
var scrH = win && win.screen ? win.screen.height : Screen.height
|
||||
return Math.max(0, Math.min(scrH - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing))
|
||||
var screenTop = win && win.screen ? win.screen.virtualY : 0
|
||||
var screenBottom = screenTop + (win && win.screen ? win.screen.height : Screen.height)
|
||||
var above = delegateGlobal.y - height - Kirigami.Units.smallSpacing
|
||||
return Math.max(screenTop, Math.min(screenBottom - height, above))
|
||||
}
|
||||
|
||||
onShowingChanged: {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import org.kde.plasma.private.mobileshell as MobileShell
|
|||
import org.kde.plasma.private.mobileshell.state as MobileShellState
|
||||
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
|
||||
import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell
|
||||
import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness
|
||||
import org.kde.layershell 1.0 as LayerShell
|
||||
import org.kde.plasma.clock
|
||||
|
||||
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
|
||||
|
||||
|
|
@ -76,6 +78,32 @@ Window {
|
|||
enabled: root.visible
|
||||
|
||||
function onButtonPressed(button, gamepadIndex) {
|
||||
// Route to quick settings panel when open
|
||||
if (quickSettings.opened) {
|
||||
switch (button) {
|
||||
case GamingShell.GamepadManager.ButtonDPadUp:
|
||||
quickSettings.gamepadUp()
|
||||
return
|
||||
case GamingShell.GamepadManager.ButtonDPadDown:
|
||||
quickSettings.gamepadDown()
|
||||
return
|
||||
case GamingShell.GamepadManager.ButtonDPadLeft:
|
||||
quickSettings.gamepadLeft()
|
||||
return
|
||||
case GamingShell.GamepadManager.ButtonDPadRight:
|
||||
quickSettings.gamepadRight()
|
||||
return
|
||||
case GamingShell.GamepadManager.ButtonA:
|
||||
quickSettings.gamepadAccept()
|
||||
return
|
||||
case GamingShell.GamepadManager.ButtonB:
|
||||
case GamingShell.GamepadManager.ButtonBack:
|
||||
quickSettings.close()
|
||||
return
|
||||
}
|
||||
return // eat all other buttons while panel is open
|
||||
}
|
||||
|
||||
switch (button) {
|
||||
case GamingShell.GamepadManager.ButtonDPadUp:
|
||||
if (grid.activeFocus) {
|
||||
|
|
@ -130,6 +158,9 @@ Window {
|
|||
searchField.forceActiveFocus()
|
||||
}
|
||||
break
|
||||
case GamingShell.GamepadManager.ButtonBack:
|
||||
quickSettings.toggle()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +185,21 @@ Window {
|
|||
}
|
||||
|
||||
function navigateByStick() {
|
||||
// Route stick to quick settings when open
|
||||
if (quickSettings.opened) {
|
||||
if (stickState.leftY < -stickState.deadzone) {
|
||||
quickSettings.gamepadUp()
|
||||
} else if (stickState.leftY > stickState.deadzone) {
|
||||
quickSettings.gamepadDown()
|
||||
}
|
||||
if (stickState.leftX < -stickState.deadzone) {
|
||||
quickSettings.gamepadLeft()
|
||||
} else if (stickState.leftX > stickState.deadzone) {
|
||||
quickSettings.gamepadRight()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (stickState.leftY < -stickState.deadzone) {
|
||||
if (grid.activeFocus) {
|
||||
if (grid.currentIndex < grid.columns && runningGames.hasTasks) {
|
||||
|
|
@ -202,7 +248,7 @@ Window {
|
|||
}
|
||||
|
||||
// Cycle through source filter tabs (All → Steam → Desktop → All …)
|
||||
readonly property var _sourceFilters: ["", "steam", "desktop"]
|
||||
readonly property var _sourceFilters: ["", "steam", "desktop", "lutris", "heroic"]
|
||||
function cycleSourceFilter(direction) {
|
||||
var current = _sourceFilters.indexOf(
|
||||
GamingShell.GameLauncherProvider.sourceFilter)
|
||||
|
|
@ -238,13 +284,63 @@ Window {
|
|||
// ---- header ----
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Kirigami.Heading {
|
||||
Layout.fillWidth: true
|
||||
text: i18n("Game Center")
|
||||
level: 1
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
// ---- system status indicators ----
|
||||
RowLayout {
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Clock { id: wallClock }
|
||||
|
||||
PC3.Label {
|
||||
text: Qt.formatTime(wallClock.dateTime,
|
||||
MobileShell.ShellUtil.isSystem24HourFormat ? "h:mm" : "h:mm ap")
|
||||
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.9
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
Kirigami.Icon {
|
||||
implicitWidth: Kirigami.Units.iconSizes.small
|
||||
implicitHeight: Kirigami.Units.iconSizes.small
|
||||
source: MobileShell.AudioInfo.icon
|
||||
visible: MobileShell.AudioInfo.isVisible
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
MobileShell.InternetIndicator {
|
||||
implicitWidth: Kirigami.Units.iconSizes.small
|
||||
implicitHeight: Kirigami.Units.iconSizes.small
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
MobileShell.BluetoothIndicator {
|
||||
implicitWidth: Kirigami.Units.iconSizes.small
|
||||
implicitHeight: Kirigami.Units.iconSizes.small
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
MobileShell.BatteryIndicator {
|
||||
textPixelSize: Kirigami.Units.gridUnit * 0.55
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
|
||||
// ---- quick settings button ----
|
||||
QQC2.ToolButton {
|
||||
icon.name: "configure"
|
||||
QQC2.ToolTip.visible: hovered
|
||||
QQC2.ToolTip.text: i18n("Quick Settings")
|
||||
onClicked: quickSettings.open()
|
||||
}
|
||||
|
||||
QQC2.ToolButton {
|
||||
icon.name: "window-close"
|
||||
text: i18n("Exit Gaming Mode")
|
||||
|
|
@ -372,6 +468,16 @@ Window {
|
|||
width: implicitWidth
|
||||
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop"
|
||||
}
|
||||
QQC2.TabButton {
|
||||
text: "Lutris"
|
||||
width: implicitWidth
|
||||
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "lutris"
|
||||
}
|
||||
QQC2.TabButton {
|
||||
text: "Heroic"
|
||||
width: implicitWidth
|
||||
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "heroic"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -534,6 +640,8 @@ Window {
|
|||
Layout.alignment: Qt.AlignHCenter
|
||||
text: source === "steam" ? "Steam"
|
||||
: source === "flatpak" ? "Flatpak"
|
||||
: source === "lutris" ? "Lutris"
|
||||
: source === "heroic" ? "Heroic"
|
||||
: ""
|
||||
visible: source !== "desktop"
|
||||
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
|
||||
|
|
@ -588,8 +696,8 @@ Window {
|
|||
// Gamepad legend
|
||||
PC3.Label {
|
||||
text: runningGames.hasTasks
|
||||
? i18n("A: Select X: Close B: Back Y: Exit LB/RB: Filter ☰: Search")
|
||||
: i18n("A: Select B: Back Y: Exit LB/RB: Filter ☰: Search")
|
||||
? i18n("A: Select X: Close B: Back Y: Exit ⊞: Settings ☰: Search")
|
||||
: i18n("A: Select B: Back Y: Exit LB/RB: Filter ⊞: Settings ☰: Search")
|
||||
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
|
||||
opacity: 0.5
|
||||
}
|
||||
|
|
@ -597,6 +705,12 @@ Window {
|
|||
}
|
||||
}
|
||||
|
||||
// Quick settings slide-out panel
|
||||
GamingQuickSettings {
|
||||
id: quickSettings
|
||||
z: 50
|
||||
}
|
||||
|
||||
// Launch transition: brief fade to black, then dismiss
|
||||
Rectangle {
|
||||
id: launchCurtain
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue