Merge feature/gaming-shell-mode into master

This commit is contained in:
Marco Allegretti 2026-04-25 10:22:50 +02:00
commit 76a4a7111e
48 changed files with 6175 additions and 87 deletions

View file

@ -112,6 +112,7 @@ set_package_properties(PlasmaWaylandProtocols PROPERTIES
find_package(LibKWorkspace CONFIG REQUIRED) find_package(LibKWorkspace CONFIG REQUIRED)
find_package(Libudev REQUIRED) find_package(Libudev REQUIRED)
find_package(SDL3 REQUIRED)
find_package(KWinDBusInterface) find_package(KWinDBusInterface)
set_package_properties(KWinDBusInterface PROPERTIES DESCRIPTION "KWin DBus interface" set_package_properties(KWinDBusInterface PROPERTIES DESCRIPTION "KWin DBus interface"

7
Nebra.code-workspace Normal file
View file

@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}

View file

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

View file

@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
find_package(SDL3 REQUIRED)
find_package(Qt6 REQUIRED COMPONENTS Sql DBus)
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
powerprofilecontrol.cpp
gamemodecontrol.cpp
)
target_link_libraries(gamingshellplugin PRIVATE
Qt::Core
Qt::DBus
Qt::Qml
Qt::Quick
Qt::Sql
KF6::I18n
KF6::KIOGui
KF6::Service
KF6::CoreAddons
KF6::ConfigCore
SDL3::SDL3
)
ecm_finalize_qml_module(gamingshellplugin)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QString>
#include <QTimer>
#include <QVariantMap>
#include <qqmlregistration.h>
#include <KConfigWatcher>
#include <KSharedConfig>
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)
Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged)
Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged)
Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged)
Q_PROPERTY(bool mangohudAvailable READ mangohudAvailable NOTIFY mangohudAvailableChanged)
Q_PROPERTY(int fpsLimit READ fpsLimit WRITE setFpsLimit NOTIFY fpsLimitChanged)
Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged)
Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged)
Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged)
public:
explicit GameLauncherProvider(QObject *parent = nullptr);
enum Roles {
NameRole = Qt::UserRole + 1,
IconRole,
SourceRole, // "desktop", "waydroid", "steam", "flatpak"
StorageIdRole, // .desktop file name or launch URI
LaunchCommandRole,
LaunchMethodRole,
ArtworkRole, // path to banner/grid image (empty if none)
LastPlayedTextRole,
PinnedRole,
};
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;
QString filterString() const;
void setFilterString(const QString &filter);
QString sourceFilter() const;
void setSourceFilter(const QString &source);
bool overlayEnabled() const;
void setOverlayEnabled(bool enabled);
bool mangohudAvailable() const;
int fpsLimit() const;
void setFpsLimit(int limit);
bool launchPending() const;
QString pendingLaunchName() const;
QString lastLaunchError() const;
Q_INVOKABLE void refresh();
Q_INVOKABLE void launch(int index);
Q_INVOKABLE void launchByStorageId(const QString &storageId);
Q_INVOKABLE QVariantMap gameDetails(const QString &storageId) const;
Q_INVOKABLE bool openSourceApp(const QString &source);
Q_INVOKABLE void clearLastPlayed(const QString &storageId);
Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
Q_INVOKABLE void clearPendingLaunch();
Q_INVOKABLE void clearLastLaunchError();
Q_INVOKABLE void togglePin(const QString &storageId);
Q_INVOKABLE int perGameFpsLimit(const QString &storageId) const;
Q_INVOKABLE void setPerGameFpsLimit(const QString &storageId, int limit);
Q_INVOKABLE int perGameOverlayState(const QString &storageId) const;
Q_INVOKABLE void setPerGameOverlayState(const QString &storageId, int state);
Q_SIGNALS:
void countChanged();
void loadingChanged();
void filterStringChanged();
void sourceFilterChanged();
void overlayEnabledChanged();
void mangohudAvailableChanged();
void fpsLimitChanged();
void launchPendingChanged();
void lastLaunchErrorChanged();
void gameLaunched(const QString &name);
void gameLaunchFailed(const QString &name, const QString &error);
void recentGamesChanged();
private:
struct GameEntry {
QString name;
QString icon;
QString source;
QString storageId;
QString launchCommand;
QString artwork;
QDateTime lastPlayed;
};
void loadDesktopGames();
void loadSteamGames();
void loadLutrisGames();
void loadHeroicGames();
void deduplicateGames();
void loadRecentTimestamps();
void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
void applyFilter();
void launchEntry(GameEntry &entry);
bool launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid = nullptr);
QString launchMethodForEntry(const GameEntry &entry) const;
QString formatLastPlayed(const QDateTime &when) const;
// Returns the current m_allGames index for the storage id.
// Callers must re-lookup after any mutation that can rebuild or reorder the list.
int findEntryIndexByStorageId(const QString &storageId) const;
void markLaunchSucceeded(const QString &storageId, const QString &name);
void markLaunchFailed(const QString &name, const QString &error);
void setPendingLaunch(const QString &name);
void loadPinnedGames();
QList<GameEntry> m_allGames;
QList<GameEntry> m_games; // filtered view
QString m_filterString;
QString m_sourceFilter; // empty = all, or a specific source such as "desktop" or "waydroid"
KSharedConfigPtr m_config;
bool m_loading = false;
bool m_overlayEnabled = false;
int m_fpsLimit = 0;
bool m_mangohudAvailable = false;
QString m_mangohudPath;
QSet<QString> m_pinnedGames;
bool m_launchPending = false;
QString m_pendingLaunchName;
QString m_lastLaunchError;
QTimer m_pendingLaunchTimer;
KConfigWatcher::Ptr m_configWatcher;
};

View file

@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "gamemodecontrol.h"
#include <QDBusConnection>
#include <QDBusReply>
#include <QDebug>
#include <unistd.h> // getpid()
static const QString s_service = QStringLiteral("com.feralinteractive.GameMode");
static const QString s_path = QStringLiteral("/com/feralinteractive/GameMode");
static const QString s_iface = QStringLiteral("com.feralinteractive.GameMode");
GameModeControl::GameModeControl(QObject *parent)
: QObject(parent)
, m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::sessionBus(), this))
{
m_available = m_iface->isValid();
if (!m_available) {
qDebug() << "GameModeControl: Feral GameMode not available";
}
}
bool GameModeControl::available() const
{
return m_available;
}
bool GameModeControl::active() const
{
return m_active;
}
void GameModeControl::requestStart()
{
if (!m_available || m_active) {
return;
}
QDBusReply<int> reply = m_iface->call(QStringLiteral("RegisterGame"), static_cast<int>(getpid()));
if (reply.isValid() && reply.value() == 0) {
m_active = true;
Q_EMIT activeChanged();
} else {
qWarning() << "GameModeControl: RegisterGame failed:" << reply.error().message();
}
}
void GameModeControl::requestEnd()
{
if (!m_available || !m_active) {
return;
}
QDBusReply<int> reply = m_iface->call(QStringLiteral("UnregisterGame"), static_cast<int>(getpid()));
if (reply.isValid() && reply.value() == 0) {
m_active = false;
Q_EMIT activeChanged();
} else {
qWarning() << "GameModeControl: UnregisterGame failed:" << reply.error().message();
}
}

View file

@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QDBusInterface>
#include <QObject>
#include <qqmlregistration.h>
/**
* D-Bus client for Feral GameMode (com.feralinteractive.GameMode).
*
* Calling requestStart() tells the daemon to apply performance
* optimizations (CPU governor, I/O priority, GPU perf mode, etc.)
* for the calling process. requestEnd() reverses them.
*
* GameMode is optional if the daemon is not installed the calls
* are silently ignored.
*/
class GameModeControl : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool available READ available NOTIFY availableChanged)
Q_PROPERTY(bool active READ active NOTIFY activeChanged)
public:
explicit GameModeControl(QObject *parent = nullptr);
bool available() const;
bool active() const;
Q_INVOKABLE void requestStart();
Q_INVOKABLE void requestEnd();
Q_SIGNALS:
void availableChanged();
void activeChanged();
private:
QDBusInterface *m_iface = nullptr;
bool m_available = false;
bool m_active = false;
};

View file

@ -0,0 +1,240 @@
// 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>
#include <SDL3/SDL_sensor.h>
#include <algorithm>
static QString gamepadButtonLabelToString(SDL_GamepadButtonLabel label)
{
switch (label) {
case SDL_GAMEPAD_BUTTON_LABEL_A:
return QStringLiteral("A");
case SDL_GAMEPAD_BUTTON_LABEL_B:
return QStringLiteral("B");
case SDL_GAMEPAD_BUTTON_LABEL_X:
return QStringLiteral("X");
case SDL_GAMEPAD_BUTTON_LABEL_Y:
return QStringLiteral("Y");
case SDL_GAMEPAD_BUTTON_LABEL_CROSS:
return QStringLiteral("Cross");
case SDL_GAMEPAD_BUTTON_LABEL_CIRCLE:
return QStringLiteral("Circle");
case SDL_GAMEPAD_BUTTON_LABEL_SQUARE:
return QStringLiteral("Square");
case SDL_GAMEPAD_BUTTON_LABEL_TRIANGLE:
return QStringLiteral("Triangle");
default:
return QStringLiteral("?");
}
}
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::hasTriggerRumble() const
{
if (!m_pad) {
return false;
}
SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad);
return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_TRIGGER_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::touchpadCount() const
{
if (!m_pad) {
return 0;
}
return SDL_GetNumGamepadTouchpads(m_pad);
}
bool GamepadDevice::hasGyro() const
{
return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_GYRO);
}
bool GamepadDevice::hasAccelerometer() const
{
return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_ACCEL);
}
int GamepadDevice::playerIndex() const
{
if (!m_pad) {
return -1;
}
return SDL_GetGamepadPlayerIndex(m_pad);
}
void GamepadDevice::setPlayerIndex(int index)
{
if (!m_pad) {
return;
}
if (SDL_SetGamepadPlayerIndex(m_pad, index)) {
Q_EMIT playerIndexChanged();
}
}
bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs)
{
if (!m_pad) {
return false;
}
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::rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs)
{
if (!m_pad) {
return false;
}
auto left = static_cast<uint16_t>(std::clamp(leftIntensity, 0, 65535));
auto right = static_cast<uint16_t>(std::clamp(rightIntensity, 0, 65535));
auto dur = static_cast<uint32_t>(std::clamp(durationMs, 0, durationMs));
return SDL_RumbleGamepadTriggers(m_pad, left, right, dur);
}
bool GamepadDevice::setLED(int r, int g, int b)
{
if (!m_pad) {
return false;
}
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);
}
QString GamepadDevice::buttonLabel(int button) const
{
if (!m_pad) {
return QStringLiteral("?");
}
switch (button) {
case SDL_GAMEPAD_BUTTON_SOUTH:
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_WEST:
case SDL_GAMEPAD_BUTTON_NORTH:
return gamepadButtonLabelToString(SDL_GetGamepadButtonLabel(m_pad, static_cast<SDL_GamepadButton>(button)));
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return type() == QLatin1String("playstation") ? QStringLiteral("L1") : type() == QLatin1String("nintendo") ? QStringLiteral("L") : QStringLiteral("LB");
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return type() == QLatin1String("playstation") ? QStringLiteral("R1") : type() == QLatin1String("nintendo") ? QStringLiteral("R") : QStringLiteral("RB");
case SDL_GAMEPAD_BUTTON_BACK:
return type() == QLatin1String("playstation") ? QStringLiteral("Create")
: type() == QLatin1String("nintendo") ? QStringLiteral("-")
: QStringLiteral("View");
case SDL_GAMEPAD_BUTTON_START:
return type() == QLatin1String("playstation") ? QStringLiteral("Options")
: type() == QLatin1String("nintendo") ? QStringLiteral("+")
: QStringLiteral("Menu");
case SDL_GAMEPAD_BUTTON_GUIDE:
return type() == QLatin1String("playstation") ? QStringLiteral("PS")
: type() == QLatin1String("nintendo") ? QStringLiteral("Home")
: QStringLiteral("Guide");
default:
return QStringLiteral("?");
}
}
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,63 @@
// 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 hasTriggerRumble READ hasTriggerRumble CONSTANT)
Q_PROPERTY(bool hasLED READ hasLED CONSTANT)
Q_PROPERTY(int touchpadCount READ touchpadCount CONSTANT)
Q_PROPERTY(bool hasGyro READ hasGyro CONSTANT)
Q_PROPERTY(bool hasAccelerometer READ hasAccelerometer 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 hasTriggerRumble() const;
bool hasLED() const;
int touchpadCount() const;
bool hasGyro() const;
bool hasAccelerometer() const;
int playerIndex() const;
void setPlayerIndex(int index);
Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs);
Q_INVOKABLE bool rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs);
Q_INVOKABLE bool setLED(int r, int g, int b);
Q_INVOKABLE QString buttonLabel(int button) const;
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,314 @@
// 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 <SDL3/SDL_hints.h>
#include <QDebug>
#include <QQmlEngine>
#include <algorithm>
GamepadManager::GamepadManager(QObject *parent)
: QAbstractListModel(parent)
{
m_pollTimer.setInterval(16); // ~60 Hz
connect(&m_pollTimer, &QTimer::timeout, this, &GamepadManager::poll);
}
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
{
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);
}
QString GamepadManager::buttonLabel(int button, int gamepadIndex) const
{
GamepadDevice *device = nullptr;
if (gamepadIndex >= 0 && gamepadIndex < m_gamepads.size()) {
device = m_gamepads.at(gamepadIndex);
} else {
device = primaryGamepad();
}
if (!device) {
switch (button) {
case SDL_GAMEPAD_BUTTON_SOUTH:
return QStringLiteral("A");
case SDL_GAMEPAD_BUTTON_EAST:
return QStringLiteral("B");
case SDL_GAMEPAD_BUTTON_WEST:
return QStringLiteral("X");
case SDL_GAMEPAD_BUTTON_NORTH:
return QStringLiteral("Y");
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return QStringLiteral("LB");
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return QStringLiteral("RB");
case SDL_GAMEPAD_BUTTON_BACK:
return QStringLiteral("Back");
case SDL_GAMEPAD_BUTTON_START:
return QStringLiteral("Start");
case SDL_GAMEPAD_BUTTON_GUIDE:
return QStringLiteral("Guide");
default:
return QStringLiteral("?");
}
}
return device->buttonLabel(button);
}
// --- Lifecycle ---
void GamepadManager::start()
{
if (m_sdlInitialized) {
return;
}
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
if (!SDL_Init(SDL_INIT_GAMEPAD)) {
qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError();
return;
}
m_sdlInitialized = true;
SDL_SetGamepadEventsEnabled(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 = std::clamp(static_cast<float>(event.gaxis.value) / 32767.0f, -1.0f, 1.0f);
Q_EMIT axisChanged(event.gaxis.axis, normalized, idx);
}
break;
}
default:
break;
}
}
// Refresh battery state periodically (every ~5 seconds = 300 frames)
if (++m_batteryCounter >= 300) {
m_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,106 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QAbstractListModel>
#include <QTimer>
#include <qqmlregistration.h>
class QQmlEngine;
class QJSEngine;
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;
static GamepadManager *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine);
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)
// Axes matching SDL_GamepadAxis
enum Axis {
AxisLeftX,
AxisLeftY,
AxisRightX,
AxisRightY,
AxisLeftTrigger,
AxisRightTrigger,
};
Q_ENUM(Axis)
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;
GamepadDevice *gamepadAt(int index) const;
Q_INVOKABLE QString buttonLabel(int button, int gamepadIndex = -1) 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;
int m_batteryCounter = 0;
QTimer m_pollTimer;
QList<GamepadDevice *> m_gamepads;
};

View file

@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#include "powerprofilecontrol.h"
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusReply>
#include <QDBusVariant>
#include <QDebug>
static const QString s_service = QStringLiteral("net.hadess.PowerProfiles");
static const QString s_path = QStringLiteral("/net/hadess/PowerProfiles");
static const QString s_iface = QStringLiteral("net.hadess.PowerProfiles");
static const QString s_propIface = QStringLiteral("org.freedesktop.DBus.Properties");
PowerProfileControl::PowerProfileControl(QObject *parent)
: QObject(parent)
, m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::systemBus(), this))
{
if (m_iface->isValid()) {
m_available = true;
fetchState();
// Subscribe to property changes
QDBusConnection::systemBus()
.connect(s_service, s_path, s_propIface, QStringLiteral("PropertiesChanged"), this, SLOT(onPropertiesChanged(QString, QVariantMap, QStringList)));
} else {
qDebug() << "PowerProfileControl: power-profiles-daemon not available";
}
}
QString PowerProfileControl::activeProfile() const
{
return m_activeProfile;
}
void PowerProfileControl::setActiveProfile(const QString &profile)
{
if (!m_available || profile == m_activeProfile) {
return;
}
if (!m_profiles.contains(profile)) {
return;
}
// Write via org.freedesktop.DBus.Properties.Set
QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus());
propIface.call(QStringLiteral("Set"), s_iface, QStringLiteral("ActiveProfile"), QVariant::fromValue(QDBusVariant(profile)));
}
QStringList PowerProfileControl::profiles() const
{
return m_profiles;
}
bool PowerProfileControl::available() const
{
return m_available;
}
void PowerProfileControl::fetchState()
{
// Read ActiveProfile
QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus());
QDBusReply<QDBusVariant> profileReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("ActiveProfile"));
if (profileReply.isValid()) {
const QString profile = profileReply.value().variant().toString();
if (profile != m_activeProfile) {
m_activeProfile = profile;
Q_EMIT activeProfileChanged();
}
}
// Read Profiles — array of dicts, each with a "Profile" key
QDBusReply<QDBusVariant> profilesReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("Profiles"));
if (profilesReply.isValid()) {
QStringList profiles;
const QVariant profilesVariant = profilesReply.value().variant();
if (profilesVariant.canConvert<QVariantList>()) {
const QVariantList list = profilesVariant.toList();
for (const QVariant &item : list) {
const QVariantMap map = item.toMap();
QString profileName;
if (map.contains(QStringLiteral("Profile"))) {
QVariant value = map.value(QStringLiteral("Profile"));
if (value.canConvert<QDBusVariant>()) {
value = value.value<QDBusVariant>().variant();
}
profileName = value.toString();
}
if (!profileName.isEmpty()) {
profiles.append(profileName);
}
}
}
if (profiles != m_profiles) {
m_profiles = profiles;
Q_EMIT profilesChanged();
}
}
}
void PowerProfileControl::onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated)
{
Q_UNUSED(invalidated)
if (interface != s_iface) {
return;
}
if (changed.contains(QStringLiteral("ActiveProfile"))) {
QVariant value = changed.value(QStringLiteral("ActiveProfile"));
if (value.canConvert<QDBusVariant>()) {
value = value.value<QDBusVariant>().variant();
}
const QString profile = value.toString();
if (profile != m_activeProfile) {
m_activeProfile = profile;
Q_EMIT activeProfileChanged();
}
}
if (changed.contains(QStringLiteral("Profiles"))) {
fetchState();
}
}

View file

@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
#pragma once
#include <QDBusInterface>
#include <QObject>
#include <QStringList>
#include <qqmlregistration.h>
class PowerProfileControl : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(QString activeProfile READ activeProfile WRITE setActiveProfile NOTIFY activeProfileChanged)
Q_PROPERTY(QStringList profiles READ profiles NOTIFY profilesChanged)
Q_PROPERTY(bool available READ available NOTIFY availableChanged)
public:
explicit PowerProfileControl(QObject *parent = nullptr);
QString activeProfile() const;
void setActiveProfile(const QString &profile);
QStringList profiles() const;
bool available() const;
Q_SIGNALS:
void activeProfileChanged();
void profilesChanged();
void availableChanged();
private Q_SLOTS:
void onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated);
private:
void fetchState();
QDBusInterface *m_iface = nullptr;
QString m_activeProfile;
QStringList m_profiles;
bool m_available = false;
};

View file

@ -86,13 +86,15 @@ QQuickItem *AppletHost::fullRepresentationFor(const QString &pluginId)
m_containment->addApplet(applet); m_containment->addApplet(applet);
auto *item = PlasmaQuick::AppletQuickItem::itemForApplet(applet); auto *item = PlasmaQuick::AppletQuickItem::itemForApplet(applet);
m_items.insert(pluginId, item);
if (!item) { if (!item) {
qWarning() << "AppletHost: no AppletQuickItem for" << pluginId; qWarning() << "AppletHost: no AppletQuickItem for" << pluginId;
applet->deleteLater();
return nullptr; return nullptr;
} }
m_items.insert(pluginId, item);
item->setPreloadFullRepresentation(true); item->setPreloadFullRepresentation(true);
auto *fullRepItem = item->fullRepresentationItem(); auto *fullRepItem = item->fullRepresentationItem();

View file

@ -165,6 +165,7 @@ Item {
shrinkSize: 0 shrinkSize: 0
onClicked: { onClicked: {
if (!root.taskModel) return;
root.taskModel.requestActivate(root.taskModel.makeModelIndex(taskDelegate.index)); root.taskModel.requestActivate(root.taskModel.makeModelIndex(taskDelegate.index));
} }
@ -180,18 +181,27 @@ Item {
Controls.MenuItem { Controls.MenuItem {
text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize") text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize")
icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize" icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize"
onTriggered: root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index)) onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index))
}
} }
Controls.MenuItem { Controls.MenuItem {
text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize") text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize")
icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize" icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize"
onTriggered: root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index)) onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index))
}
} }
Controls.MenuSeparator {} Controls.MenuSeparator {}
Controls.MenuItem { Controls.MenuItem {
text: i18n("Close") text: i18n("Close")
icon.name: "window-close" icon.name: "window-close"
onTriggered: root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index)) onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index))
}
} }
} }

View file

@ -45,6 +45,8 @@ QList<QString> QuickSettingsConfig::enabledQuickSettings() const
QStringLiteral("org.kde.plasma.quicksetting.screenshot"), QStringLiteral("org.kde.plasma.quicksetting.screenshot"),
QStringLiteral("org.kde.plasma.quicksetting.powermenu"), QStringLiteral("org.kde.plasma.quicksetting.powermenu"),
QStringLiteral("org.kde.plasma.quicksetting.donotdisturb"), QStringLiteral("org.kde.plasma.quicksetting.donotdisturb"),
QStringLiteral("org.kde.plasma.quicksetting.gaming"),
QStringLiteral("org.kde.plasma.quicksetting.gaminghint"),
QStringLiteral("org.kde.plasma.quicksetting.caffeine"), QStringLiteral("org.kde.plasma.quicksetting.caffeine"),
QStringLiteral("org.kde.plasma.quicksetting.keyboardtoggle"), QStringLiteral("org.kde.plasma.quicksetting.keyboardtoggle"),
QStringLiteral("org.kde.plasma.quicksetting.hotspot")}); QStringLiteral("org.kde.plasma.quicksetting.hotspot")});

View file

@ -44,6 +44,8 @@ MobileShellSettings::MobileShellSettings(QObject *parent)
Q_EMIT actionDrawerTopRightModeChanged(); Q_EMIT actionDrawerTopRightModeChanged();
Q_EMIT convergenceModeEnabledChanged(); Q_EMIT convergenceModeEnabledChanged();
Q_EMIT autoHidePanelsEnabledChanged(); Q_EMIT autoHidePanelsEnabledChanged();
Q_EMIT gamingModeEnabledChanged();
Q_EMIT gamingDismissHintEnabledChanged();
Q_EMIT allowLogoutChanged(); Q_EMIT allowLogoutChanged();
} }
if (group.name() == LOCKSCREEN_CONFIG_GROUP) { if (group.name() == LOCKSCREEN_CONFIG_GROUP) {
@ -248,6 +250,32 @@ void MobileShellSettings::setAutoHidePanelsEnabled(bool enabled)
m_config->sync(); m_config->sync();
} }
bool MobileShellSettings::gamingModeEnabled() const
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
return group.readEntry("gamingModeEnabled", false);
}
void MobileShellSettings::setGamingModeEnabled(bool enabled)
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
group.writeEntry("gamingModeEnabled", enabled, KConfigGroup::Notify);
m_config->sync();
}
bool MobileShellSettings::gamingDismissHintEnabled() const
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
return group.readEntry("gamingDismissHintEnabled", true);
}
void MobileShellSettings::setGamingDismissHintEnabled(bool enabled)
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
group.writeEntry("gamingDismissHintEnabled", enabled, KConfigGroup::Notify);
m_config->sync();
}
void MobileShellSettings::updateNavigationBarsInPlasma() void MobileShellSettings::updateNavigationBarsInPlasma()
{ {
// Do not update panels when not in Plasma Mobile // Do not update panels when not in Plasma Mobile

View file

@ -52,6 +52,10 @@ class MobileShellSettings : public QObject
// Auto Hide Panels // Auto Hide Panels
Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged) Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged)
// Gaming mode
Q_PROPERTY(bool gamingModeEnabled READ gamingModeEnabled WRITE setGamingModeEnabled NOTIFY gamingModeEnabledChanged)
Q_PROPERTY(bool gamingDismissHintEnabled READ gamingDismissHintEnabled WRITE setGamingDismissHintEnabled NOTIFY gamingDismissHintEnabledChanged)
// logout dialog // logout dialog
Q_PROPERTY(bool allowLogout READ allowLogout READ allowLogout NOTIFY allowLogoutChanged) Q_PROPERTY(bool allowLogout READ allowLogout READ allowLogout NOTIFY allowLogoutChanged)
@ -264,6 +268,12 @@ public:
*/ */
void setAutoHidePanelsEnabled(bool enabled); void setAutoHidePanelsEnabled(bool enabled);
bool gamingModeEnabled() const;
void setGamingModeEnabled(bool enabled);
bool gamingDismissHintEnabled() const;
void setGamingDismissHintEnabled(bool enabled);
/** /**
* Whether logout button is shown in the logout/shutdown dialog. * Whether logout button is shown in the logout/shutdown dialog.
*/ */
@ -310,6 +320,8 @@ Q_SIGNALS:
void quickSettingsColumnsChanged(); void quickSettingsColumnsChanged();
void convergenceModeEnabledChanged(); void convergenceModeEnabledChanged();
void autoHidePanelsEnabledChanged(); void autoHidePanelsEnabledChanged();
void gamingModeEnabledChanged();
void gamingDismissHintEnabledChanged();
void allowLogoutChanged(); void allowLogoutChanged();
void lockscreenLeftButtonActionChanged(); void lockscreenLeftButtonActionChanged();
void lockscreenRightButtonActionChanged(); void lockscreenRightButtonActionChanged();

View file

@ -35,6 +35,17 @@ void WaydroidApplicationListModel::initializeApplications(const QList<QDBusObjec
endResetModel(); endResetModel();
} }
void WaydroidApplicationListModel::clearApplications()
{
if (m_applications.isEmpty()) {
return;
}
beginResetModel();
m_applications.clear();
endResetModel();
}
void WaydroidApplicationListModel::addApplication(const QDBusObjectPath &objectPath) void WaydroidApplicationListModel::addApplication(const QDBusObjectPath &objectPath)
{ {
beginInsertRows({}, m_applications.size(), m_applications.size()); beginInsertRows({}, m_applications.size(), m_applications.size());

View file

@ -33,6 +33,7 @@ public:
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
void initializeApplications(const QList<QDBusObjectPath> &applicationObjectPaths); void initializeApplications(const QList<QDBusObjectPath> &applicationObjectPaths);
void clearApplications();
public Q_SLOTS: public Q_SLOTS:
void addApplication(const QDBusObjectPath &objectPath); void addApplication(const QDBusObjectPath &objectPath);

View file

@ -6,24 +6,38 @@
#include "waydroiddbusclient.h" #include "waydroiddbusclient.h"
#include <KConfigGroup>
#include <QClipboard> #include <QClipboard>
#include <QCoroDBusPendingReply> #include <QCoroDBusPendingReply>
#include <QDBusMessage>
#include <QGuiApplication> #include <QGuiApplication>
#include <QTimer>
using namespace Qt::StringLiterals; using namespace Qt::StringLiterals;
static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming");
static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages");
WaydroidDBusClient::WaydroidDBusClient(QObject *parent) WaydroidDBusClient::WaydroidDBusClient(QObject *parent)
: QObject{parent} : QObject{parent}
, m_interface{new OrgKdePlasmashellWaydroidInterface{u"org.kde.plasmashell"_s, u"/Waydroid"_s, QDBusConnection::sessionBus(), this}} , m_interface{new OrgKdePlasmashellWaydroidInterface{u"org.kde.plasmashell"_s, u"/Waydroid"_s, QDBusConnection::sessionBus(), this}}
, m_watcher{new QDBusServiceWatcher{u"org.kde.plasmashell"_s, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange, this}} , m_watcher{new QDBusServiceWatcher{u"org.kde.plasmashell"_s, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange, this}}
, m_applicationListModel{new WaydroidApplicationListModel{this}} , m_applicationListModel{new WaydroidApplicationListModel{this}}
, m_config{KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))}
{ {
m_configWatcher = KConfigWatcher::create(m_config);
connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) {
if (group.name() == s_waydroidGamingGroup) {
m_config->reparseConfiguration();
reloadGameShellPackages();
}
});
reloadGameShellPackages();
// Check if the service is already running // Check if the service is already running
if (QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) { if (QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) {
m_connected = true; checkWaydroidObject();
if (m_interface->isValid()) {
connectSignals();
}
} }
connect(m_watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, [this](const QString &service, const QString &oldOwner, const QString &newOwner) { connect(m_watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, [this](const QString &service, const QString &oldOwner, const QString &newOwner) {
@ -31,12 +45,11 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent)
if (newOwner.isEmpty()) { if (newOwner.isEmpty()) {
// Service stopped // Service stopped
m_connected = false; m_connected = false;
m_connectionCheckPending = false;
resetState();
} else if (oldOwner.isEmpty()) { } else if (oldOwner.isEmpty()) {
// Service started // Service started
m_connected = true; checkWaydroidObject();
if (m_interface->isValid()) {
connectSignals();
}
} }
} }
}); });
@ -44,6 +57,9 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent)
void WaydroidDBusClient::connectSignals() void WaydroidDBusClient::connectSignals()
{ {
if (!m_signalsConnected) {
m_signalsConnected = true;
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) { connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) {
Q_EMIT downloadStatusChanged(downloaded, total, speed); Q_EMIT downloadStatusChanged(downloaded, total, speed);
@ -55,6 +71,8 @@ void WaydroidDBusClient::connectSignals()
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent); connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeTouchChanged, this, &WaydroidDBusClient::updateFakeTouch);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeWifiChanged, this, &WaydroidDBusClient::updateFakeWifi);
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) { connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) {
Q_EMIT actionFinished(message); Q_EMIT actionFinished(message);
}); });
@ -64,6 +82,7 @@ void WaydroidDBusClient::connectSignals()
connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) { connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) {
Q_EMIT errorOccurred(title, message); Q_EMIT errorOccurred(title, message);
}); });
}
initializeApplicationListModel(); initializeApplicationListModel();
updateStatus(); updateStatus();
@ -74,6 +93,118 @@ void WaydroidDBusClient::connectSignals()
updateMultiWindows(); updateMultiWindows();
updateSuspend(); updateSuspend();
updateUevent(); updateUevent();
updateFakeTouch();
updateFakeWifi();
}
void WaydroidDBusClient::checkWaydroidObject()
{
if (m_connectionCheckPending) {
return;
}
m_connectionCheckPending = true;
const QDBusMessage message =
QDBusMessage::createMethodCall(u"org.kde.plasmashell"_s, u"/Waydroid"_s, u"org.freedesktop.DBus.Introspectable"_s, u"Introspect"_s);
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, &WaydroidDBusClient::onWaydroidObjectCheckFinished);
}
void WaydroidDBusClient::onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher)
{
m_connectionCheckPending = false;
QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) {
m_connected = false;
watcher->deleteLater();
resetState();
scheduleWaydroidObjectCheck();
return;
}
m_connected = true;
watcher->deleteLater();
if (m_interface->isValid()) {
connectSignals();
}
}
void WaydroidDBusClient::handleUnavailableReply()
{
if (!m_connected) {
return;
}
m_connected = false;
resetState();
scheduleWaydroidObjectCheck();
}
void WaydroidDBusClient::resetState()
{
if (m_status != NotSupported) {
m_status = NotSupported;
Q_EMIT statusChanged();
}
if (m_sessionStatus != SessionStopped) {
m_sessionStatus = SessionStopped;
Q_EMIT sessionStatusChanged();
}
if (m_systemType != UnknownSystemType) {
m_systemType = UnknownSystemType;
Q_EMIT systemTypeChanged();
}
if (!m_ipAddress.isEmpty()) {
m_ipAddress.clear();
Q_EMIT ipAddressChanged();
}
if (!m_androidId.isEmpty()) {
m_androidId.clear();
Q_EMIT androidIdChanged();
}
if (m_multiWindows) {
m_multiWindows = false;
Q_EMIT multiWindowsChanged();
}
if (m_suspend) {
m_suspend = false;
Q_EMIT suspendChanged();
}
if (m_uevent) {
m_uevent = false;
Q_EMIT ueventChanged();
}
if (!m_fakeTouch.isEmpty()) {
m_fakeTouch.clear();
Q_EMIT fakeTouchChanged();
}
if (!m_fakeWifi.isEmpty()) {
m_fakeWifi.clear();
Q_EMIT fakeWifiChanged();
}
m_applicationListModel->clearApplications();
}
void WaydroidDBusClient::scheduleWaydroidObjectCheck()
{
if (!QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) {
return;
}
QTimer::singleShot(1000, this, &WaydroidDBusClient::checkWaydroidObject);
} }
void WaydroidDBusClient::initializeApplicationListModel() void WaydroidDBusClient::initializeApplicationListModel()
@ -85,6 +216,7 @@ void WaydroidDBusClient::initializeApplicationListModel()
QDBusPendingReply<QList<QDBusObjectPath>> reply = *watcher; QDBusPendingReply<QList<QDBusObjectPath>> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch applications:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch applications:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -133,6 +265,10 @@ WaydroidApplicationListModel *WaydroidDBusClient::applicationListModel() const
QCoro::Task<void> WaydroidDBusClient::setMultiWindowsTask(const bool multiWindows) QCoro::Task<void> WaydroidDBusClient::setMultiWindowsTask(const bool multiWindows)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->setMultiWindows(multiWindows); co_await m_interface->setMultiWindows(multiWindows);
} }
@ -148,6 +284,10 @@ bool WaydroidDBusClient::multiWindows() const
QCoro::Task<void> WaydroidDBusClient::setSuspendTask(const bool suspend) QCoro::Task<void> WaydroidDBusClient::setSuspendTask(const bool suspend)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->setSuspend(suspend); co_await m_interface->setSuspend(suspend);
} }
@ -163,6 +303,10 @@ bool WaydroidDBusClient::suspend() const
QCoro::Task<void> WaydroidDBusClient::setUeventTask(const bool uevent) QCoro::Task<void> WaydroidDBusClient::setUeventTask(const bool uevent)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->setUevent(uevent); co_await m_interface->setUevent(uevent);
} }
@ -173,6 +317,10 @@ QCoro::QmlTask WaydroidDBusClient::setUevent(const bool uevent)
QCoro::Task<void> WaydroidDBusClient::refreshSessionInfoTask() QCoro::Task<void> WaydroidDBusClient::refreshSessionInfoTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->refreshSessionInfo(); co_await m_interface->refreshSessionInfo();
} }
@ -183,6 +331,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshSessionInfo()
QCoro::Task<void> WaydroidDBusClient::refreshAndroidIdTask() QCoro::Task<void> WaydroidDBusClient::refreshAndroidIdTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->refreshAndroidId(); co_await m_interface->refreshAndroidId();
} }
@ -193,6 +345,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshAndroidId()
QCoro::Task<void> WaydroidDBusClient::refreshApplicationsTask() QCoro::Task<void> WaydroidDBusClient::refreshApplicationsTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->refreshApplications(); co_await m_interface->refreshApplications();
} }
@ -201,13 +357,87 @@ QCoro::QmlTask WaydroidDBusClient::refreshApplications()
return refreshApplicationsTask(); return refreshApplicationsTask();
} }
bool WaydroidDBusClient::gameShellEnabledForPackage(const QString &packageName) const
{
return m_gameShellPackages.contains(packageName);
}
void WaydroidDBusClient::setGameShellEnabledForPackage(const QString &packageName, bool enabled)
{
QStringList packages = m_gameShellPackages;
packages.removeAll(packageName);
if (enabled) {
packages.append(packageName);
}
packages.removeDuplicates();
packages.sort();
if (packages == m_gameShellPackages) {
return;
}
KConfigGroup group(m_config, s_waydroidGamingGroup);
group.writeEntry(s_gameShellPackagesKey, packages, KConfigGroup::Notify);
m_config->sync();
m_gameShellPackages = packages;
Q_EMIT gameShellPackagesChanged();
}
bool WaydroidDBusClient::uevent() const bool WaydroidDBusClient::uevent() const
{ {
return m_uevent; return m_uevent;
} }
QCoro::Task<void> WaydroidDBusClient::setFakeTouchTask(const QString &fakeTouch)
{
if (!m_connected) {
co_return;
}
co_await m_interface->setFakeTouch(fakeTouch);
}
QCoro::QmlTask WaydroidDBusClient::setFakeTouch(const QString &fakeTouch)
{
return setFakeTouchTask(fakeTouch);
}
QString WaydroidDBusClient::fakeTouch() const
{
return m_fakeTouch;
}
QCoro::Task<void> WaydroidDBusClient::setFakeWifiTask(const QString &fakeWifi)
{
if (!m_connected) {
co_return;
}
co_await m_interface->setFakeWifi(fakeWifi);
}
QCoro::QmlTask WaydroidDBusClient::setFakeWifi(const QString &fakeWifi)
{
return setFakeWifiTask(fakeWifi);
}
QString WaydroidDBusClient::fakeWifi() const
{
return m_fakeWifi;
}
QStringList WaydroidDBusClient::gameShellPackages() const
{
return m_gameShellPackages;
}
QCoro::Task<void> WaydroidDBusClient::initializeTask(const SystemType systemType, const RomType romType, const bool forced) QCoro::Task<void> WaydroidDBusClient::initializeTask(const SystemType systemType, const RomType romType, const bool forced)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->initialize(systemType, romType, forced); co_await m_interface->initialize(systemType, romType, forced);
} }
@ -218,6 +448,10 @@ QCoro::QmlTask WaydroidDBusClient::initialize(const SystemType systemType, const
QCoro::Task<void> WaydroidDBusClient::startSessionTask() QCoro::Task<void> WaydroidDBusClient::startSessionTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->startSession(); co_await m_interface->startSession();
} }
@ -228,6 +462,10 @@ QCoro::QmlTask WaydroidDBusClient::startSession()
QCoro::Task<void> WaydroidDBusClient::stopSessionTask() QCoro::Task<void> WaydroidDBusClient::stopSessionTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->stopSession(); co_await m_interface->stopSession();
} }
@ -238,6 +476,10 @@ QCoro::QmlTask WaydroidDBusClient::stopSession()
QCoro::Task<void> WaydroidDBusClient::resetWaydroidTask() QCoro::Task<void> WaydroidDBusClient::resetWaydroidTask()
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->resetWaydroid(); co_await m_interface->resetWaydroid();
} }
@ -248,6 +490,10 @@ QCoro::QmlTask WaydroidDBusClient::resetWaydroid()
QCoro::Task<void> WaydroidDBusClient::installApkTask(const QString apkFile) QCoro::Task<void> WaydroidDBusClient::installApkTask(const QString apkFile)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->installApk(apkFile); co_await m_interface->installApk(apkFile);
} }
@ -256,8 +502,26 @@ QCoro::QmlTask WaydroidDBusClient::installApk(const QString apkFile)
return installApkTask(apkFile); return installApkTask(apkFile);
} }
QCoro::Task<void> WaydroidDBusClient::launchApplicationTask(const QString appId)
{
if (!m_connected) {
co_return;
}
co_await m_interface->launchApplication(appId);
}
QCoro::QmlTask WaydroidDBusClient::launchApplication(const QString appId)
{
return launchApplicationTask(appId);
}
QCoro::Task<void> WaydroidDBusClient::deleteApplicationTask(const QString appId) QCoro::Task<void> WaydroidDBusClient::deleteApplicationTask(const QString appId)
{ {
if (!m_connected) {
co_return;
}
co_await m_interface->deleteApplication(appId); co_await m_interface->deleteApplication(appId);
} }
@ -266,6 +530,20 @@ QCoro::QmlTask WaydroidDBusClient::deleteApplication(const QString appId)
return deleteApplicationTask(appId); return deleteApplicationTask(appId);
} }
QCoro::Task<void> WaydroidDBusClient::refreshSupportsInfoTask()
{
if (!m_connected) {
co_return;
}
co_await m_interface->refreshSupportsInfo();
}
QCoro::QmlTask WaydroidDBusClient::refreshSupportsInfo()
{
return refreshSupportsInfoTask();
}
void WaydroidDBusClient::updateStatus() void WaydroidDBusClient::updateStatus()
{ {
auto reply = m_interface->status(); auto reply = m_interface->status();
@ -275,6 +553,7 @@ void WaydroidDBusClient::updateStatus()
QDBusPendingReply<int> reply = *watcher; QDBusPendingReply<int> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch status:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch status:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -299,6 +578,7 @@ void WaydroidDBusClient::updateSessionStatus()
QDBusPendingReply<int> reply = *watcher; QDBusPendingReply<int> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch sessionStatus:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch sessionStatus:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -323,6 +603,7 @@ void WaydroidDBusClient::updateSystemType()
QDBusPendingReply<int> reply = *watcher; QDBusPendingReply<int> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch systemType:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch systemType:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -347,6 +628,7 @@ void WaydroidDBusClient::updateIpAddress()
QDBusPendingReply<QString> reply = *watcher; QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch ipAddress:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch ipAddress:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -371,6 +653,7 @@ void WaydroidDBusClient::updateAndroidId()
QDBusPendingReply<QString> reply = *watcher; QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch androidId:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch androidId:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -395,6 +678,7 @@ void WaydroidDBusClient::updateMultiWindows()
QDBusPendingReply<bool> reply = *watcher; QDBusPendingReply<bool> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch multiWindows:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch multiWindows:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -419,6 +703,7 @@ void WaydroidDBusClient::updateSuspend()
QDBusPendingReply<bool> reply = *watcher; QDBusPendingReply<bool> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch suspend:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch suspend:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -443,6 +728,7 @@ void WaydroidDBusClient::updateUevent()
QDBusPendingReply<bool> reply = *watcher; QDBusPendingReply<bool> reply = *watcher;
if (!reply.isValid()) { if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch uevent:" << reply.error().message(); qDebug() << "WaydroidDBusClient: Failed to fetch uevent:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater(); watcher->deleteLater();
return; return;
} }
@ -458,7 +744,72 @@ void WaydroidDBusClient::updateUevent()
}); });
} }
void WaydroidDBusClient::updateFakeTouch()
{
auto reply = m_interface->fakeTouch();
auto watcher = new QDBusPendingCallWatcher(reply, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) {
QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch fakeTouch:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater();
return;
}
const QString fakeTouch = reply.argumentAt<0>();
if (m_fakeTouch != fakeTouch) {
m_fakeTouch = fakeTouch;
Q_EMIT fakeTouchChanged();
}
watcher->deleteLater();
});
}
void WaydroidDBusClient::updateFakeWifi()
{
auto reply = m_interface->fakeWifi();
auto watcher = new QDBusPendingCallWatcher(reply, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) {
QDBusPendingReply<QString> reply = *watcher;
if (!reply.isValid()) {
qDebug() << "WaydroidDBusClient: Failed to fetch fakeWifi:" << reply.error().message();
handleUnavailableReply();
watcher->deleteLater();
return;
}
const QString fakeWifi = reply.argumentAt<0>();
if (m_fakeWifi != fakeWifi) {
m_fakeWifi = fakeWifi;
Q_EMIT fakeWifiChanged();
}
watcher->deleteLater();
});
}
void WaydroidDBusClient::copyToClipboard(const QString text) void WaydroidDBusClient::copyToClipboard(const QString text)
{ {
qGuiApp->clipboard()->setText(text); qGuiApp->clipboard()->setText(text);
} }
void WaydroidDBusClient::reloadGameShellPackages()
{
const KConfigGroup group(m_config, s_waydroidGamingGroup);
QStringList packages = group.readEntry(s_gameShellPackagesKey, QStringList{});
packages.removeDuplicates();
packages.sort();
if (m_gameShellPackages == packages) {
return;
}
m_gameShellPackages = packages;
Q_EMIT gameShellPackagesChanged();
}

View file

@ -10,8 +10,12 @@
#include "waydroidapplicationlistmodel.h" #include "waydroidapplicationlistmodel.h"
#include "waydroiddbusobject.h" #include "waydroiddbusobject.h"
#include <KConfigWatcher>
#include <KSharedConfig>
#include <QCoroCore> #include <QCoroCore>
#include <QCoroQmlTask> #include <QCoroQmlTask>
#include <QDBusPendingCallWatcher>
#include <QDBusServiceWatcher> #include <QDBusServiceWatcher>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
@ -32,6 +36,9 @@ class WaydroidDBusClient : public QObject
Q_PROPERTY(bool multiWindows READ multiWindows WRITE setMultiWindows NOTIFY multiWindowsChanged) Q_PROPERTY(bool multiWindows READ multiWindows WRITE setMultiWindows NOTIFY multiWindowsChanged)
Q_PROPERTY(bool suspend READ suspend WRITE setSuspend NOTIFY suspendChanged) Q_PROPERTY(bool suspend READ suspend WRITE setSuspend NOTIFY suspendChanged)
Q_PROPERTY(bool uevent READ uevent WRITE setUevent NOTIFY ueventChanged) Q_PROPERTY(bool uevent READ uevent WRITE setUevent NOTIFY ueventChanged)
Q_PROPERTY(QString fakeTouch READ fakeTouch WRITE setFakeTouch NOTIFY fakeTouchChanged)
Q_PROPERTY(QString fakeWifi READ fakeWifi WRITE setFakeWifi NOTIFY fakeWifiChanged)
Q_PROPERTY(QStringList gameShellPackages READ gameShellPackages NOTIFY gameShellPackagesChanged)
Q_PROPERTY(WaydroidApplicationListModel *applicationListModel READ applicationListModel CONSTANT) Q_PROPERTY(WaydroidApplicationListModel *applicationListModel READ applicationListModel CONSTANT)
public: public:
@ -98,16 +105,25 @@ public:
QCoro::QmlTask setSuspend(const bool suspend); QCoro::QmlTask setSuspend(const bool suspend);
[[nodiscard]] bool uevent() const; [[nodiscard]] bool uevent() const;
QCoro::QmlTask setUevent(const bool uevent); QCoro::QmlTask setUevent(const bool uevent);
[[nodiscard]] QString fakeTouch() const;
QCoro::QmlTask setFakeTouch(const QString &fakeTouch);
[[nodiscard]] QString fakeWifi() const;
QCoro::QmlTask setFakeWifi(const QString &fakeWifi);
[[nodiscard]] QStringList gameShellPackages() const;
Q_INVOKABLE QCoro::QmlTask initialize(const SystemType systemType, const RomType romType, const bool forced = false); Q_INVOKABLE QCoro::QmlTask initialize(const SystemType systemType, const RomType romType, const bool forced = false);
Q_INVOKABLE QCoro::QmlTask startSession(); Q_INVOKABLE QCoro::QmlTask startSession();
Q_INVOKABLE QCoro::QmlTask stopSession(); Q_INVOKABLE QCoro::QmlTask stopSession();
Q_INVOKABLE QCoro::QmlTask resetWaydroid(); Q_INVOKABLE QCoro::QmlTask resetWaydroid();
Q_INVOKABLE QCoro::QmlTask installApk(const QString apkFile); Q_INVOKABLE QCoro::QmlTask installApk(const QString apkFile);
Q_INVOKABLE QCoro::QmlTask launchApplication(const QString appId);
Q_INVOKABLE QCoro::QmlTask deleteApplication(const QString appId); Q_INVOKABLE QCoro::QmlTask deleteApplication(const QString appId);
Q_INVOKABLE QCoro::QmlTask refreshSupportsInfo();
Q_INVOKABLE QCoro::QmlTask refreshSessionInfo(); Q_INVOKABLE QCoro::QmlTask refreshSessionInfo();
Q_INVOKABLE QCoro::QmlTask refreshAndroidId(); Q_INVOKABLE QCoro::QmlTask refreshAndroidId();
Q_INVOKABLE QCoro::QmlTask refreshApplications(); Q_INVOKABLE QCoro::QmlTask refreshApplications();
Q_INVOKABLE bool gameShellEnabledForPackage(const QString &packageName) const;
Q_INVOKABLE void setGameShellEnabledForPackage(const QString &packageName, bool enabled);
Q_INVOKABLE void copyToClipboard(const QString text); Q_INVOKABLE void copyToClipboard(const QString text);
@ -122,6 +138,9 @@ Q_SIGNALS:
void multiWindowsChanged(); void multiWindowsChanged();
void suspendChanged(); void suspendChanged();
void ueventChanged(); void ueventChanged();
void fakeTouchChanged();
void fakeWifiChanged();
void gameShellPackagesChanged();
void actionFinished(const QString message); void actionFinished(const QString message);
void actionFailed(const QString message); void actionFailed(const QString message);
@ -136,12 +155,14 @@ private Q_SLOTS:
void updateMultiWindows(); void updateMultiWindows();
void updateSuspend(); void updateSuspend();
void updateUevent(); void updateUevent();
void updateFakeTouch();
void updateFakeWifi();
private: private:
OrgKdePlasmashellWaydroidInterface *m_interface; OrgKdePlasmashellWaydroidInterface *m_interface;
QDBusServiceWatcher *m_watcher; QDBusServiceWatcher *m_watcher;
Status m_status{NotInitialized}; Status m_status{NotSupported};
SessionStatus m_sessionStatus{SessionStopped}; SessionStatus m_sessionStatus{SessionStopped};
SystemType m_systemType{UnknownSystemType}; SystemType m_systemType{UnknownSystemType};
QString m_ipAddress{""}; QString m_ipAddress{""};
@ -152,22 +173,40 @@ private:
bool m_multiWindows{false}; bool m_multiWindows{false};
bool m_suspend{false}; bool m_suspend{false};
bool m_uevent{false}; bool m_uevent{false};
QString m_fakeTouch;
QString m_fakeWifi;
KSharedConfig::Ptr m_config;
KConfigWatcher::Ptr m_configWatcher;
QStringList m_gameShellPackages;
bool m_connected{false}; bool m_connected{false};
bool m_signalsConnected{false};
bool m_connectionCheckPending{false};
void connectSignals(); void connectSignals();
void initializeApplicationListModel(); void initializeApplicationListModel();
void checkWaydroidObject();
void handleUnavailableReply();
void onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher);
void resetState();
void scheduleWaydroidObjectCheck();
QCoro::Task<void> initializeTask(const SystemType systemType, const RomType romType, const bool forced = false); QCoro::Task<void> initializeTask(const SystemType systemType, const RomType romType, const bool forced = false);
QCoro::Task<void> startSessionTask(); QCoro::Task<void> startSessionTask();
QCoro::Task<void> stopSessionTask(); QCoro::Task<void> stopSessionTask();
QCoro::Task<void> resetWaydroidTask(); QCoro::Task<void> resetWaydroidTask();
QCoro::Task<void> installApkTask(const QString apkFile); QCoro::Task<void> installApkTask(const QString apkFile);
QCoro::Task<void> launchApplicationTask(const QString appId);
QCoro::Task<void> deleteApplicationTask(const QString appId); QCoro::Task<void> deleteApplicationTask(const QString appId);
QCoro::Task<void> refreshSupportsInfoTask();
QCoro::Task<void> setMultiWindowsTask(const bool multiWindows); QCoro::Task<void> setMultiWindowsTask(const bool multiWindows);
QCoro::Task<void> setSuspendTask(const bool suspend); QCoro::Task<void> setSuspendTask(const bool suspend);
QCoro::Task<void> setUeventTask(const bool uevent); QCoro::Task<void> setUeventTask(const bool uevent);
QCoro::Task<void> setFakeTouchTask(const QString &fakeTouch);
QCoro::Task<void> setFakeWifiTask(const QString &fakeWifi);
QCoro::Task<void> refreshSessionInfoTask(); QCoro::Task<void> refreshSessionInfoTask();
QCoro::Task<void> refreshAndroidIdTask(); QCoro::Task<void> refreshAndroidIdTask();
QCoro::Task<void> refreshApplicationsTask(); QCoro::Task<void> refreshApplicationsTask();
void reloadGameShellPackages();
}; };

View file

@ -31,6 +31,8 @@ using namespace Qt::StringLiterals;
#define MULTI_WINDOWS_PROP_KEY "persist.waydroid.multi_windows" #define MULTI_WINDOWS_PROP_KEY "persist.waydroid.multi_windows"
#define SUSPEND_PROP_KEY "persist.waydroid.suspend" #define SUSPEND_PROP_KEY "persist.waydroid.suspend"
#define UEVENT_PROP_KEY "persist.waydroid.uevent" #define UEVENT_PROP_KEY "persist.waydroid.uevent"
#define FAKE_TOUCH_PROP_KEY "persist.waydroid.fake_touch"
#define FAKE_WIFI_PROP_KEY "persist.waydroid.fake_wifi"
static const QRegularExpression sessionRegExp(u"Session:\\s*(\\w+)"_s); static const QRegularExpression sessionRegExp(u"Session:\\s*(\\w+)"_s);
static const QRegularExpression ipAddressRegExp(u"IP address:\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)"_s); static const QRegularExpression ipAddressRegExp(u"IP address:\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)"_s);
@ -179,10 +181,12 @@ void WaydroidDBusObject::stopSession()
connect(process, &QProcess::finished, this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) { connect(process, &QProcess::finished, this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) {
Q_UNUSED(exitStatus); Q_UNUSED(exitStatus);
const QByteArray errorLog = process->readAllStandardError();
process->deleteLater(); process->deleteLater();
if (exitCode == 0) { if (exitCode != 0) {
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << process->readAllStandardError(); Q_EMIT errorOccurred(i18n("Failed to stop the Waydroid session."), QString::fromUtf8(errorLog));
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << errorLog;
return; return;
} }
@ -250,6 +254,27 @@ void WaydroidDBusObject::installApk(const QString apkFile)
process->start(WAYDROID_COMMAND, arguments); process->start(WAYDROID_COMMAND, arguments);
} }
void WaydroidDBusObject::launchApplication(const QString appId)
{
const QStringList arguments{u"app"_s, u"launch"_s, appId};
QProcess *process = new QProcess(this);
connect(process, &QProcess::finished, this, [this, appId, process](int exitCode, QProcess::ExitStatus exitStatus) {
const QByteArray errorLog = process->readAllStandardError();
process->deleteLater();
if (exitCode == 0 && exitStatus == QProcess::NormalExit) {
Q_EMIT actionFinished(i18n("Application has been launched"));
} else {
Q_EMIT actionFailed(i18n("Application launch failed"));
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Error occurred while launching " << appId << ": " << errorLog;
}
});
process->start(WAYDROID_COMMAND, arguments);
}
void WaydroidDBusObject::deleteApplication(const QString appId) void WaydroidDBusObject::deleteApplication(const QString appId)
{ {
const QStringList arguments{u"app"_s, u"remove"_s, appId}; const QStringList arguments{u"app"_s, u"remove"_s, appId};
@ -375,6 +400,52 @@ void WaydroidDBusObject::setUevent(const bool uevent)
coro(this, value, uevent); coro(this, value, uevent);
} }
QString WaydroidDBusObject::fakeTouch() const
{
return m_fakeTouch;
}
void WaydroidDBusObject::setFakeTouch(const QString &fakeTouch)
{
if (m_fakeTouch == fakeTouch) {
return;
}
auto coro = [](WaydroidDBusObject *self, QString fakeTouch) -> QCoro::Task<void> {
QPointer<WaydroidDBusObject> guard(self);
if (co_await self->writePropValue(FAKE_TOUCH_PROP_KEY, fakeTouch)) {
if (guard) {
self->m_fakeTouch = fakeTouch;
Q_EMIT self->fakeTouchChanged();
}
}
};
coro(this, fakeTouch);
}
QString WaydroidDBusObject::fakeWifi() const
{
return m_fakeWifi;
}
void WaydroidDBusObject::setFakeWifi(const QString &fakeWifi)
{
if (m_fakeWifi == fakeWifi) {
return;
}
auto coro = [](WaydroidDBusObject *self, QString fakeWifi) -> QCoro::Task<void> {
QPointer<WaydroidDBusObject> guard(self);
if (co_await self->writePropValue(FAKE_WIFI_PROP_KEY, fakeWifi)) {
if (guard) {
self->m_fakeWifi = fakeWifi;
Q_EMIT self->fakeWifiChanged();
}
}
};
coro(this, fakeWifi);
}
QList<QDBusObjectPath> WaydroidDBusObject::applications() const QList<QDBusObjectPath> WaydroidDBusObject::applications() const
{ {
QList<QDBusObjectPath> paths; QList<QDBusObjectPath> paths;
@ -513,6 +584,7 @@ void WaydroidDBusObject::refreshAndroidId()
} }
} else { } else {
m_androidId = ""; m_androidId = "";
Q_EMIT actionFailed(i18n("Failed to fetch Android ID"));
qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "KAuth returned an error code:" << executeJob->error(); qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "KAuth returned an error code:" << executeJob->error();
} }
@ -539,6 +611,12 @@ QCoro::Task<void> WaydroidDBusObject::refreshPropsInfo()
const QString ueventPropValue = co_await fetchPropValue(UEVENT_PROP_KEY, "false"); const QString ueventPropValue = co_await fetchPropValue(UEVENT_PROP_KEY, "false");
m_uevent = ueventPropValue == "true"; m_uevent = ueventPropValue == "true";
Q_EMIT ueventChanged(); Q_EMIT ueventChanged();
m_fakeTouch = co_await fetchPropValue(FAKE_TOUCH_PROP_KEY, QString{});
Q_EMIT fakeTouchChanged();
m_fakeWifi = co_await fetchPropValue(FAKE_WIFI_PROP_KEY, QString{});
Q_EMIT fakeWifiChanged();
} }
QCoro::Task<QString> WaydroidDBusObject::fetchPropValue(const QString key, const QString defaultValue) QCoro::Task<QString> WaydroidDBusObject::fetchPropValue(const QString key, const QString defaultValue)

View file

@ -96,6 +96,8 @@ Q_SIGNALS:
Q_SCRIPTABLE void multiWindowsChanged(); Q_SCRIPTABLE void multiWindowsChanged();
Q_SCRIPTABLE void suspendChanged(); Q_SCRIPTABLE void suspendChanged();
Q_SCRIPTABLE void ueventChanged(); Q_SCRIPTABLE void ueventChanged();
Q_SCRIPTABLE void fakeTouchChanged();
Q_SCRIPTABLE void fakeWifiChanged();
Q_SCRIPTABLE void applicationAdded(QDBusObjectPath path); Q_SCRIPTABLE void applicationAdded(QDBusObjectPath path);
Q_SCRIPTABLE void applicationRemoved(QDBusObjectPath path); Q_SCRIPTABLE void applicationRemoved(QDBusObjectPath path);
@ -119,6 +121,10 @@ public Q_SLOTS:
Q_SCRIPTABLE void setSuspend(const bool suspend); Q_SCRIPTABLE void setSuspend(const bool suspend);
Q_SCRIPTABLE bool uevent() const; Q_SCRIPTABLE bool uevent() const;
Q_SCRIPTABLE void setUevent(const bool uevent); Q_SCRIPTABLE void setUevent(const bool uevent);
Q_SCRIPTABLE QString fakeTouch() const;
Q_SCRIPTABLE void setFakeTouch(const QString &fakeTouch);
Q_SCRIPTABLE QString fakeWifi() const;
Q_SCRIPTABLE void setFakeWifi(const QString &fakeWifi);
Q_SCRIPTABLE QList<QDBusObjectPath> applications() const; Q_SCRIPTABLE QList<QDBusObjectPath> applications() const;
Q_SCRIPTABLE void initialize(const int systemType, const int romType, const bool forced = false); Q_SCRIPTABLE void initialize(const int systemType, const int romType, const bool forced = false);
@ -126,7 +132,9 @@ public Q_SLOTS:
Q_SCRIPTABLE void stopSession(); Q_SCRIPTABLE void stopSession();
Q_SCRIPTABLE void resetWaydroid(); Q_SCRIPTABLE void resetWaydroid();
Q_SCRIPTABLE void installApk(const QString apkFile); Q_SCRIPTABLE void installApk(const QString apkFile);
Q_SCRIPTABLE void launchApplication(const QString appId);
Q_SCRIPTABLE void deleteApplication(const QString appId); Q_SCRIPTABLE void deleteApplication(const QString appId);
Q_SCRIPTABLE void refreshSupportsInfo();
Q_SCRIPTABLE void refreshSessionInfo(); Q_SCRIPTABLE void refreshSessionInfo();
Q_SCRIPTABLE void refreshAndroidId(); Q_SCRIPTABLE void refreshAndroidId();
Q_SCRIPTABLE void refreshApplications(); Q_SCRIPTABLE void refreshApplications();
@ -143,8 +151,9 @@ private:
bool m_multiWindows{false}; bool m_multiWindows{false};
bool m_suspend{false}; bool m_suspend{false};
bool m_uevent{false}; bool m_uevent{false};
QString m_fakeTouch;
QString m_fakeWifi;
void refreshSupportsInfo();
void refreshInstallationInfo(); void refreshInstallationInfo();
QCoro::Task<void> refreshPropsInfo(); QCoro::Task<void> refreshPropsInfo();

View file

@ -76,6 +76,14 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
PATH settings PATH settings
) )
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
)
target_link_libraries(org.kde.plasma.mobile.homescreen.folio PRIVATE target_link_libraries(org.kde.plasma.mobile.homescreen.folio PRIVATE
Qt::Gui Qt::Gui
Qt::Qml Qt::Qml

View file

@ -706,12 +706,20 @@ MouseArea {
x: { x: {
if (!targetDelegate) return 0 if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
return Math.max(0, Math.min(Screen.width - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) var win = targetDelegate.Window.window
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: { y: {
if (!targetDelegate) return 0 if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
return Math.max(0, Math.min(Screen.height - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) var win = targetDelegate.Window.window
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: { onShowingChanged: {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,216 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
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 {
id: root
signal openRequested()
// Guard against startup timing where Kirigami units may briefly be 0/NaN.
// LayerShell surfaces must never be committed with zero size.
readonly property real safeGridUnit: ((Kirigami.Units.gridUnit || 0) > 0) ? Kirigami.Units.gridUnit : 16
property string toastMessage: ""
property bool toastError: false
readonly property bool toastActive: toastMessage.length > 0
// Most-recently-played game for quick resume. Populated from recentGames(1)
// and refreshed whenever the recent list changes.
property var quickResumeGame: null
readonly property bool hasQuickResume: quickResumeGame !== null
// Window grows leftward from top-right anchor:
// toast active widest (needs room for message text)
// quick resume medium (game name + controls)
// idle compact (controls only)
width: toastActive ? safeGridUnit * 16 : (hasQuickResume ? safeGridUnit * 14 : safeGridUnit * 4)
height: toastActive ? safeGridUnit * 4 : safeGridUnit * 2
color: "transparent"
flags: Qt.FramelessWindowHint
Behavior on width {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
Behavior on height {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
LayerShell.Window.scope: "gaming-hud"
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: 0
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
// Driven by the Loader in folio/qml/main.qml set false to fade out
// before the Loader destroys the window.
property bool showing: true
opacity: showing ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
Timer {
id: toastTimer
onTriggered: root.toastMessage = ""
}
Connections {
target: GamingShell.GameLauncherProvider
function onGameLaunched(name) {
root.toastMessage = i18n("Launching %1", name)
root.toastError = false
toastTimer.interval = 3000
toastTimer.restart()
}
function onGameLaunchFailed(name, error) {
root.toastMessage = error.length > 0 ? error : i18n("Failed to launch %1", name)
root.toastError = true
toastTimer.interval = 5000
toastTimer.restart()
}
function onRecentGamesChanged() {
const recent = GamingShell.GameLauncherProvider.recentGames(1)
root.quickResumeGame = recent.length > 0 ? recent[0] : null
}
}
Component.onCompleted: {
const recent = GamingShell.GameLauncherProvider.recentGames(1)
root.quickResumeGame = recent.length > 0 ? recent[0] : null
}
// ---- HUD pill (always visible, fills window width, grows leftward) ----
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Kirigami.Units.smallSpacing
height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2
radius: height / 2
color: Qt.rgba(0, 0, 0, 0.55)
RowLayout {
anchors {
fill: parent
leftMargin: Kirigami.Units.smallSpacing
rightMargin: Kirigami.Units.smallSpacing
}
spacing: 0
// Quick-resume section only visible when a recent game exists
QQC2.ToolButton {
visible: root.hasQuickResume
icon.name: "media-playback-start"
icon.color: "white"
display: QQC2.AbstractButton.IconOnly
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: root.quickResumeGame ? i18n("Resume %1", root.quickResumeGame.name) : ""
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: {
if (root.quickResumeGame) {
GamingShell.GameLauncherProvider.launchByStorageId(root.quickResumeGame.storageId)
}
}
}
QQC2.Label {
visible: root.hasQuickResume
Layout.fillWidth: true
text: root.quickResumeGame ? root.quickResumeGame.name : ""
color: "white"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
elide: Text.ElideRight
maximumLineCount: 1
leftPadding: Kirigami.Units.smallSpacing
}
// Separator between quick-resume and controls
Rectangle {
visible: root.hasQuickResume
width: 1
implicitHeight: Kirigami.Units.gridUnit
color: Qt.rgba(1, 1, 1, 0.25)
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
}
// Game Center button
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()
}
// Primary gamepad battery
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
rightPadding: Kirigami.Units.smallSpacing
}
}
}
// ---- toast pill (slides in below HUD pill when active) ----
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Kirigami.Units.smallSpacing
height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2
radius: height / 2
color: root.toastError ? Qt.rgba(0.75, 0.1, 0.05, 0.9) : Qt.rgba(0, 0, 0, 0.55)
opacity: root.toastActive ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
RowLayout {
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
leftMargin: Kirigami.Units.largeSpacing
rightMargin: Kirigami.Units.largeSpacing
}
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
source: root.toastError ? "dialog-warning" : "media-playback-start"
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
Layout.alignment: Qt.AlignVCenter
}
QQC2.Label {
Layout.fillWidth: true
text: root.toastMessage
color: "white"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
}

View file

@ -0,0 +1,692 @@
// 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.shellsettingsplugin as ShellSettings
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
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.quicksetting.nightcolor as NightColor
Item {
id: root
anchors.fill: parent
property bool opened: false
readonly property string acceptButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonA)
readonly property string closeButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonB)
function pulsePrimaryGamepad(lowIntensity, highIntensity, durationMs) {
var pad = GamingShell.GamepadManager.primaryGamepad
if (!pad || !pad.hasRumble) {
return
}
pad.rumble(lowIntensity, highIntensity, durationMs)
}
// Focusable controls for gamepad navigation
property var _controls: []
property int _focusIndex: 0
function _buildControlsList() {
var list = []
if (GamingShell.PowerProfileControl.available && performanceSection._availableProfiles.length > 0) list.push(profileRow)
if (screenBrightness.brightnessAvailable) list.push(brightnessSlider)
if (PreferredDevice.sink) list.push(volumeSlider)
list.push(dndSwitch)
list.push(launchHintSwitch)
list.push(nightColorSwitch)
list.push(overlaySwitch)
list.push(wifiSwitch)
list.push(btSwitch)
list.push(airplaneSwitch)
_controls = list
}
function open() {
opened = true
_buildControlsList()
_focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1))
_highlightCurrent()
pulsePrimaryGamepad(7000, 11000, 40)
}
function close() {
opened = false
pulsePrimaryGamepad(5000, 8000, 30)
}
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 (typeof ctrl.decrease === "function") {
ctrl.decrease()
if (typeof ctrl.moved === "function") ctrl.moved()
}
}
function gamepadRight() {
var ctrl = _controls[_focusIndex]
if (typeof ctrl.increase === "function") {
ctrl.increase()
if (typeof ctrl.moved === "function") ctrl.moved()
}
}
function gamepadAccept() {
var ctrl = _controls[_focusIndex]
if (ctrl === profileRow) {
ctrl.increase()
pulsePrimaryGamepad(6000, 9000, 35)
return
}
if (ctrl instanceof QQC2.Switch) {
ctrl.toggle()
ctrl.toggled()
pulsePrimaryGamepad(6000, 9000, 35)
}
}
onOpenedChanged: {
if (opened) {
_buildControlsList()
_focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1))
_highlightCurrent()
}
}
// 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 }
// ---- Performance Profile ----
ColumnLayout {
id: performanceSection
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: GamingShell.PowerProfileControl.available
&& _availableProfiles.length > 0
PC3.Label {
text: i18n("Performance")
font.bold: true
}
// Ordered low-to-high so gamepad left=slower, right=faster
readonly property var _profileOrder: ["power-saver", "balanced", "performance"]
readonly property var _availableProfiles: {
var ordered = []
for (var i = 0; i < _profileOrder.length; i++) {
if (GamingShell.PowerProfileControl.profiles.indexOf(_profileOrder[i]) >= 0) {
ordered.push(_profileOrder[i])
}
}
return ordered
}
Item {
id: profileRow
focus: true
Layout.fillWidth: true
Layout.preferredHeight: profileButtons.implicitHeight
function decrease() {
var profiles = parent._availableProfiles
var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile)
if (idx > 0) {
GamingShell.PowerProfileControl.activeProfile = profiles[idx - 1]
}
}
function increase() {
var profiles = parent._availableProfiles
var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile)
if (idx >= 0 && idx < profiles.length - 1) {
GamingShell.PowerProfileControl.activeProfile = profiles[idx + 1]
}
}
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
}
RowLayout {
id: profileButtons
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.smallSpacing
Repeater {
model: performanceSection._availableProfiles
QQC2.Button {
Layout.fillWidth: true
text: {
switch (modelData) {
case "performance": return i18n("Performance")
case "balanced": return i18n("Balanced")
case "power-saver": return i18n("Power Saver")
default: return modelData
}
}
icon.name: {
switch (modelData) {
case "performance": return "speedometer"
case "balanced": return "system-suspend-hibernate"
case "power-saver": return "battery-profile-powersave"
default: return ""
}
}
highlighted: GamingShell.PowerProfileControl.activeProfile === modelData
onClicked: GamingShell.PowerProfileControl.activeProfile = modelData
}
}
}
}
}
// ---- 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)
}
// Keep Plasma/Kirigami colors while using a cleaner rounded style.
background: Rectangle {
x: brightnessSlider.leftPadding
y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2
width: brightnessSlider.availableWidth
height: Kirigami.Units.smallSpacing + 2
radius: height / 2
color: Kirigami.Theme.alternateBackgroundColor
Rectangle {
width: parent.width * brightnessSlider.visualPosition
height: parent.height
radius: parent.radius
color: Kirigami.Theme.highlightColor
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Kirigami.Theme.highlightColor
border.width: brightnessSlider.activeFocus ? 1 : 0
}
}
handle: Rectangle {
x: brightnessSlider.leftPadding + brightnessSlider.visualPosition * (brightnessSlider.availableWidth - width)
y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
radius: width / 2
color: Kirigami.Theme.backgroundColor
border.color: brightnessSlider.pressed
? Kirigami.Theme.highlightColor
: Kirigami.Theme.disabledTextColor
border.width: brightnessSlider.activeFocus || brightnessSlider.pressed ? 2 : 1
}
}
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)
}
}
// Keep Plasma/Kirigami colors while using a cleaner rounded style.
background: Rectangle {
x: volumeSlider.leftPadding
y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2
width: volumeSlider.availableWidth
height: Kirigami.Units.smallSpacing + 2
radius: height / 2
color: Kirigami.Theme.alternateBackgroundColor
Rectangle {
width: parent.width * volumeSlider.visualPosition
height: parent.height
radius: parent.radius
color: Kirigami.Theme.highlightColor
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Kirigami.Theme.highlightColor
border.width: volumeSlider.activeFocus ? 1 : 0
}
}
handle: Rectangle {
x: volumeSlider.leftPadding + volumeSlider.visualPosition * (volumeSlider.availableWidth - width)
y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
radius: width / 2
color: Kirigami.Theme.backgroundColor
border.color: volumeSlider.pressed
? Kirigami.Theme.highlightColor
: Kirigami.Theme.disabledTextColor
border.width: volumeSlider.activeFocus || volumeSlider.pressed ? 2 : 1
}
}
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: "audio-volume-high"
}
}
}
Kirigami.Separator { Layout.fillWidth: true }
// ---- Gaming Tweaks ----
PC3.Label {
text: i18n("Gaming")
font.bold: true
}
GridLayout {
Layout.fillWidth: true
columns: 2
rowSpacing: Kirigami.Units.smallSpacing
columnSpacing: Kirigami.Units.largeSpacing
QQC2.Switch {
id: dndSwitch
text: i18n("Do Not Disturb")
checked: MobileShellState.ShellDBusClient.doNotDisturb
onToggled: MobileShellState.ShellDBusClient.doNotDisturb = checked
}
QQC2.Switch {
id: launchHintSwitch
text: i18n("Launch Hint")
checked: ShellSettings.Settings.gamingDismissHintEnabled
onToggled: ShellSettings.Settings.gamingDismissHintEnabled = checked
}
QQC2.Switch {
id: nightColorSwitch
text: i18n("Night Color")
checked: NightColor.NightColorUtil.enabled
onToggled: NightColor.NightColorUtil.enabled = checked
}
QQC2.Switch {
id: overlaySwitch
text: i18n("Perf Overlay")
checked: GamingShell.GameLauncherProvider.overlayEnabled
enabled: GamingShell.GameLauncherProvider.mangohudAvailable
opacity: enabled ? 1.0 : 0.5
onToggled: GamingShell.GameLauncherProvider.overlayEnabled = checked
QQC2.ToolTip.visible: !GamingShell.GameLauncherProvider.mangohudAvailable && hovered
QQC2.ToolTip.text: i18n("MangoHud is not installed")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
// FPS cap spans both columns, driven by MangoHud fps_limit
QQC2.ButtonGroup { id: fpsCap; exclusive: true }
RowLayout {
Layout.columnSpan: 2
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
enabled: GamingShell.GameLauncherProvider.mangohudAvailable
opacity: enabled ? 1.0 : 0.5
PC3.Label { text: i18n("FPS Cap") }
Item { Layout.fillWidth: true }
Repeater {
model: [
{ label: i18nc("@action:button FPS cap off", "Off"), fps: 0 },
{ label: "30", fps: 30 },
{ label: "40", fps: 40 },
{ label: "60", fps: 60 }
]
delegate: QQC2.Button {
required property var modelData
text: modelData.label
flat: true
checkable: true
checked: GamingShell.GameLauncherProvider.fpsLimit === modelData.fps
QQC2.ButtonGroup.group: fpsCap
onClicked: GamingShell.GameLauncherProvider.fpsLimit = modelData.fps
}
}
}
// GameMode status (auto-managed, read-only indicator)
RowLayout {
spacing: Kirigami.Units.smallSpacing
visible: GamingShell.GameModeControl.available
Kirigami.Icon {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: "games-achievements"
}
PC3.Label {
text: GamingShell.GameModeControl.active
? i18n("GameMode requested")
: i18n("GameMode not requested")
opacity: 0.7
}
}
}
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 %1: Toggle %2: Close",
acceptButtonLabel, closeButtonLabel)
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8
opacity: 0.5
horizontalAlignment: Text.AlignHCenter
}
Item { Layout.fillHeight: true }
}
}
}
}

View file

@ -0,0 +1,208 @@
// 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.taskmanager as TaskManager
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
Item {
id: root
implicitHeight: taskList.count > 0 ? column.implicitHeight : 0
readonly property bool hasTasks: taskList.count > 0
readonly property int taskCount: taskList.count
signal taskActivated()
signal moveDownRequested()
function focusFirstTask() {
if (!hasTasks) {
return;
}
taskList.currentIndex = Math.max(0, taskList.currentIndex)
taskList.positionViewAtIndex(taskList.currentIndex, ListView.Visible)
taskList.forceActiveFocus()
}
function activateCurrent() {
if (taskList.currentItem) {
taskList.currentItem.activate()
}
}
function closeCurrent() {
if (taskList.currentItem) {
var idx = taskList.currentIndex
tasks.requestClose(tasks.makeModelIndex(idx))
}
}
TaskManager.VirtualDesktopInfo { id: vdInfo }
TaskManager.ActivityInfo { id: actInfo }
TaskManager.TasksModel {
id: tasks
filterByVirtualDesktop: true
filterByActivity: true
filterNotMaximized: false
filterByScreen: true
filterHidden: false
virtualDesktop: vdInfo.currentDesktop
activity: actInfo.currentActivity
groupMode: TaskManager.TasksModel.GroupApplications
}
Behavior on implicitHeight {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}
ColumnLayout {
id: column
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.smallSpacing
visible: taskList.count > 0
Kirigami.Heading {
level: 2
text: i18n("Running")
}
ListView {
id: taskList
Layout.fillWidth: true
implicitHeight: contentHeight
model: tasks
orientation: ListView.Horizontal
spacing: Kirigami.Units.largeSpacing
clip: true
keyNavigationEnabled: true
currentIndex: 0
onActiveFocusChanged: {
if (activeFocus && count > 0 && currentIndex < 0) {
currentIndex = 0
}
}
Keys.onLeftPressed: {
if (count <= 0) {
return
}
currentIndex = Math.max(0, currentIndex - 1)
positionViewAtIndex(currentIndex, ListView.Contain)
}
Keys.onRightPressed: {
if (count <= 0) {
return
}
currentIndex = Math.min(count - 1, currentIndex + 1)
positionViewAtIndex(currentIndex, ListView.Contain)
}
Keys.onDownPressed: root.moveDownRequested()
Keys.onReturnPressed: currentItem && currentItem.activate()
Keys.onEnterPressed: currentItem && currentItem.activate()
delegate: QQC2.ItemDelegate {
id: taskItem
required property var decoration
required property var winIdList
width: Kirigami.Units.gridUnit * 8
height: Kirigami.Units.gridUnit * 5
readonly property var modelIndex: tasks.makeModelIndex(index)
readonly property bool isCurrent: ListView.isCurrentItem && taskList.activeFocus
readonly property string titleText: typeof model !== "undefined" && model.display ? model.display : ""
function activate() {
tasks.requestActivate(taskItem.modelIndex)
root.taskActivated()
}
onClicked: {
taskList.currentIndex = index
activate()
}
Keys.onReturnPressed: activate()
Keys.onEnterPressed: activate()
Rectangle {
anchors.fill: parent
radius: Kirigami.Units.cornerRadius
color: taskItem.isCurrent
? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g,
Kirigami.Theme.highlightColor.b, 0.25)
: resumeArea.containsPress
? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g,
Kirigami.Theme.highlightColor.b, 0.3)
: resumeArea.containsMouse
? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.1)
: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.06)
Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } }
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.large
implicitHeight: Kirigami.Units.iconSizes.large
source: taskItem.decoration
}
PC3.Label {
Layout.fillWidth: true
text: taskItem.titleText
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
}
}
// Close button (top-right corner)
QQC2.ToolButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Kirigami.Units.smallSpacing / 2
width: Kirigami.Units.iconSizes.small
height: width
icon.name: "window-close-symbolic"
icon.width: Kirigami.Units.iconSizes.small
icon.height: Kirigami.Units.iconSizes.small
display: QQC2.AbstractButton.IconOnly
onClicked: tasks.requestClose(taskItem.modelIndex)
}
MouseArea {
id: resumeArea
anchors.fill: parent
hoverEnabled: true
z: -1
// Leave room for the close button
onClicked: {
taskList.currentIndex = index
taskItem.activate()
}
}
}
}
}
}

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.state as MobileShellState
import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin
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.layershell 1.0 as LayerShell import org.kde.layershell 1.0 as LayerShell
import org.kde.plasma.private.sessions 2.0 import org.kde.plasma.private.sessions 2.0
@ -25,13 +26,84 @@ import org.kde.kirigamiaddons.components as KirigamiAddonsComponents
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
import "./gaming"
import "./private" import "./private"
ContainmentItem { ContainmentItem {
id: root id: root
property Folio.HomeScreen folio: root.plasmoid property Folio.HomeScreen folio: root.plasmoid
// Tracks whether the Game Center grid is visible within gaming mode.
// If gaming mode is already enabled at startup, open it immediately so
// the user is never left without controls.
property bool gameCenterOpen: ShellSettings.Settings.gamingModeEnabled
property bool showGameCenterHint: false
// State saved when gaming mode activates, restored when it deactivates
property string _savedPowerProfile: ""
property bool _savedDnd: false
property bool _gamingSessionActive: false
function _applyGamingModeState(enabled) {
root.gameCenterOpen = enabled
GamingShell.GamepadManager.active = enabled
if (enabled === root._gamingSessionActive) {
return
}
if (enabled) {
// Save current state and apply gaming optimizations
root._savedDnd = MobileShellState.ShellDBusClient.doNotDisturb
MobileShellState.ShellDBusClient.doNotDisturb = true
if (GamingShell.PowerProfileControl.available) {
root._savedPowerProfile = GamingShell.PowerProfileControl.activeProfile
GamingShell.PowerProfileControl.activeProfile = "performance"
}
GamingShell.GameModeControl.requestStart()
root._gamingSessionActive = true
} else {
// Restore previous state
MobileShellState.ShellDBusClient.doNotDisturb = root._savedDnd
if (GamingShell.PowerProfileControl.available && root._savedPowerProfile.length > 0) {
GamingShell.PowerProfileControl.activeProfile = root._savedPowerProfile
}
GamingShell.GameModeControl.requestEnd()
root._gamingSessionActive = false
}
}
Timer {
id: gameCenterHintTimer
interval: 2600
onTriggered: root.showGameCenterHint = false
}
Connections {
target: ShellSettings.Settings
function onGamingModeEnabledChanged() {
root._applyGamingModeState(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: { Component.onCompleted: {
root._applyGamingModeState(ShellSettings.Settings.gamingModeEnabled)
folio.FolioSettings.load(); folio.FolioSettings.load();
folio.FavouritesModel.load(); folio.FavouritesModel.load();
folio.PageListModel.load(); folio.PageListModel.load();
@ -71,6 +143,18 @@ ContainmentItem {
screenGeometry: Plasmoid.containment.screenGeometry screenGeometry: Plasmoid.containment.screenGeometry
} }
// In gaming mode, reopen Game Center when the last window goes away
// so the user is never stranded on a bare wallpaper.
Connections {
target: windowMaximizedTracker
enabled: ShellSettings.Settings.gamingModeEnabled
function onShowingWindowChanged() {
if (!windowMaximizedTracker.showingWindow && !root.gameCenterOpen) {
root.gameCenterOpen = true
}
}
}
// Close app drawer when a new window appears // Close app drawer when a new window appears
Connections { Connections {
target: WindowPlugin.WindowUtil target: WindowPlugin.WindowUtil
@ -89,6 +173,12 @@ ContainmentItem {
MobileShellState.ShellDBusClient.closeActionDrawer(); MobileShellState.ShellDBusClient.closeActionDrawer();
} }
if (ShellSettings.Settings.gamingModeEnabled) {
// In gaming mode Home/Menu should reopen the Game Center overlay.
root.gameCenterOpen = true;
return;
}
if (ShellSettings.Settings.convergenceModeEnabled) { if (ShellSettings.Settings.convergenceModeEnabled) {
// Convergence: toggle the app drawer as a layer-shell overlay // Convergence: toggle the app drawer as a layer-shell overlay
// without disturbing open windows. // without disturbing open windows.
@ -186,7 +276,7 @@ ContainmentItem {
// task panel containment; this window only provides the visible dock. // task panel containment; this window only provides the visible dock.
Window { Window {
id: dockOverlay id: dockOverlay
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled
color: "transparent" color: "transparent"
width: Screen.width width: Screen.width
height: Kirigami.Units.gridUnit * 3 height: Kirigami.Units.gridUnit * 3
@ -286,6 +376,7 @@ ContainmentItem {
Window { Window {
id: drawerOverlay id: drawerOverlay
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled
&& !ShellSettings.Settings.gamingModeEnabled
&& folio.HomeScreenState.appDrawerOpenProgress > 0 && folio.HomeScreenState.appDrawerOpenProgress > 0
color: "transparent" color: "transparent"
width: Screen.width width: Screen.width
@ -645,6 +736,74 @@ ContainmentItem {
} }
} }
// Game Center overlay full-screen grid of games shown when gaming mode
// is active. Sits at LayerTop so it covers running application windows
// without going above system notifications.
GameCenterOverlay {
id: gameCenterOverlay
folio: root.folio
visible: ShellSettings.Settings.gamingModeEnabled && root.gameCenterOpen
onGameStarted: root.gameCenterOpen = false
onDismissRequested: {
root.gameCenterOpen = false
if (ShellSettings.Settings.gamingDismissHintEnabled) {
root.showGameCenterHint = true
gameCenterHintTimer.restart()
}
}
}
// Small persistent button at the top-right corner of the screen that lets
// the user return to the Game Center after launching a game.
// Keep the Loader active for the full duration of gaming mode so the
// opacity Behavior in GamingHUD can animate both fade-in and fade-out.
//
// Hide the HUD while a game window covers the screen. A mapped LayerShell
// surface prevents KWin from using DRM direct scanout for the fullscreen
// game window. Setting showing=false triggers the opacity fade-out and then
// sets visible=false, which unmaps the Wayland surface and lets KWin bypass
// the compositor render loop entirely for the game frame.
Loader {
active: ShellSettings.Settings.gamingModeEnabled
sourceComponent: GamingHUD {
visible: showing
showing: !root.gameCenterOpen && !windowMaximizedTracker.showingWindow
onOpenRequested: root.gameCenterOpen = true
}
}
Rectangle {
id: gameCenterHint
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: Kirigami.Units.gridUnit * 2
visible: root.showGameCenterHint && ShellSettings.Settings.gamingDismissHintEnabled
opacity: visible ? 1 : 0
z: 2000
radius: Kirigami.Units.cornerRadius
color: Qt.rgba(0, 0, 0, 0.65)
border.width: 1
border.color: Qt.rgba(1, 1, 1, 0.2)
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
implicitWidth: hintText.implicitWidth + Kirigami.Units.gridUnit * 2
implicitHeight: hintText.implicitHeight + Kirigami.Units.largeSpacing
PlasmaComponents.Label {
id: hintText
anchors.centerIn: parent
text: i18n("Gaming mode is still on. Use Home or the gamepad icon to reopen Game Center.")
color: "white"
wrapMode: Text.WordWrap
width: Math.min(root.width * 0.8, Kirigami.Units.gridUnit * 30)
horizontalAlignment: Text.AlignHCenter
}
}
MobileShell.HomeScreen { MobileShell.HomeScreen {
id: homeScreen id: homeScreen
anchors.fill: parent anchors.fill: parent

View file

@ -131,6 +131,7 @@ Item {
context: Qt.ApplicationShortcut context: Qt.ApplicationShortcut
onActivated: { onActivated: {
if (drawer.actionDrawer.intendedToBeVisible) { if (drawer.actionDrawer.intendedToBeVisible) {
drawer.actionDrawer.intendedToBeVisible = false;
drawer.actionDrawer.close(); drawer.actionDrawer.close();
} else { } else {
drawer.actionDrawer.openToPinnedMode = false; drawer.actionDrawer.openToPinnedMode = false;

View file

@ -34,11 +34,17 @@ ContainmentItem {
// Whether the startup feedback is showing // Whether the startup feedback is showing
readonly property bool showingStartupFeedback: MobileShellState.ShellDBusObject.startupFeedbackModel.activeWindowIsStartupFeedback readonly property bool showingStartupFeedback: MobileShellState.ShellDBusObject.startupFeedbackModel.activeWindowIsStartupFeedback
readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled
// Whether an app is maximized and showing (does not include startup feedback) // Whether an app is maximized and showing (does not include startup feedback)
readonly property bool showingApp: windowMaximizedTracker.showingWindow && !showingStartupFeedback readonly property bool showingApp: windowMaximizedTracker.showingWindow && !showingStartupFeedback
// Whether the currently showing app is in "fullscreen" // Whether the currently showing app is in "fullscreen"
readonly property bool fullscreen: { readonly property bool fullscreen: {
if (gamingMode) {
return true;
}
// In convergence mode the status bar is always visible, like a desktop panel. // In convergence mode the status bar is always visible, like a desktop panel.
if (ShellSettings.Settings.convergenceModeEnabled) { if (ShellSettings.Settings.convergenceModeEnabled) {
return false; return false;
@ -69,7 +75,7 @@ ContainmentItem {
} }
} }
readonly property real panelHeight: MobileShell.Constants.topPanelHeight readonly property real panelHeight: gamingMode ? 0 : MobileShell.Constants.topPanelHeight
onPanelHeightChanged: setWindowProperties() onPanelHeightChanged: setWindowProperties()
function setWindowProperties() { function setWindowProperties() {
@ -123,6 +129,11 @@ ContainmentItem {
function onConvergenceModeEnabledChanged() { function onConvergenceModeEnabledChanged() {
root.setWindowProperties(); root.setWindowProperties();
} }
function onGamingModeEnabledChanged() {
root.setWindowProperties();
MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : (fullscreen ? "hidden" : "default");
}
} }
Component.onCompleted: { Component.onCompleted: {
@ -136,22 +147,23 @@ ContainmentItem {
// MaximizeArea by the panel height. // MaximizeArea by the panel height.
Window { Window {
id: topBarSpaceReserver id: topBarSpaceReserver
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled
color: "transparent" color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput
height: root.panelHeight height: Math.max(1, root.panelHeight)
width: 1 width: 1
LayerShell.Window.scope: "topbar-space" LayerShell.Window.scope: "topbar-space"
LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.layer: LayerShell.Window.LayerBottom
LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: root.panelHeight LayerShell.Window.exclusionZone: Math.max(1, root.panelHeight)
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
} }
// Visual panel component // Visual panel component
StatusPanel { StatusPanel {
id: statusPanel id: statusPanel
visible: !ShellSettings.Settings.gamingModeEnabled
anchors.fill: parent anchors.fill: parent
containmentItem: root containmentItem: root
} }

View file

@ -38,12 +38,14 @@ ContainmentItem {
readonly property bool inLandscape: MobileShell.Constants.navigationPanelOnSide(Screen.width, Screen.height) readonly property bool inLandscape: MobileShell.Constants.navigationPanelOnSide(Screen.width, Screen.height)
readonly property real navigationPanelHeight: MobileShell.Constants.navigationPanelThickness readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled
readonly property real navigationPanelHeight: gamingMode ? 0 : MobileShell.Constants.navigationPanelThickness
onNavigationPanelHeightChanged: setWindowProperties() onNavigationPanelHeightChanged: setWindowProperties()
readonly property real intendedWindowThickness: navigationPanelHeight readonly property real intendedWindowThickness: navigationPanelHeight
readonly property real intendedWindowLength: inLandscape ? Screen.height : Screen.width readonly property real intendedWindowLength: inLandscape ? Screen.height : Screen.width
readonly property real intendedWindowOffset: inLandscape ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel readonly property real intendedWindowOffset: (inLandscape && !gamingMode) ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel
readonly property int intendedWindowLocation: inLandscape ? PlasmaCore.Types.RightEdge : PlasmaCore.Types.BottomEdge readonly property int intendedWindowLocation: inLandscape ? PlasmaCore.Types.RightEdge : PlasmaCore.Types.BottomEdge
onIntendedWindowLengthChanged: maximizeTimer.restart() // ensure it always takes up the full length of the screen onIntendedWindowLengthChanged: maximizeTimer.restart() // ensure it always takes up the full length of the screen
@ -136,6 +138,11 @@ ContainmentItem {
function onConvergenceModeEnabledChanged() { function onConvergenceModeEnabledChanged() {
root.setWindowProperties(); root.setWindowProperties();
} }
function onGamingModeEnabledChanged() {
root.setWindowProperties();
navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? MobileShell.Constants.navigationPanelThickness : 0;
}
} }
Component.onCompleted: setWindowProperties(); Component.onCompleted: setWindowProperties();
@ -153,18 +160,19 @@ ContainmentItem {
Window { Window {
id: dockSpaceReserver id: dockSpaceReserver
visible: ShellSettings.Settings.convergenceModeEnabled visible: ShellSettings.Settings.convergenceModeEnabled
&& !ShellSettings.Settings.gamingModeEnabled
&& !(ShellSettings.Settings.autoHidePanelsEnabled && !(ShellSettings.Settings.autoHidePanelsEnabled
&& windowMaximizedTracker.showingWindow) && windowMaximizedTracker.showingWindow)
color: "transparent" color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput
// height is set by layer-shell anchoring; provide a fallback. // height is set by layer-shell anchoring; provide a fallback.
height: Kirigami.Units.gridUnit * 3 height: Math.max(1, MobileShell.Constants.navigationPanelThickness)
width: 1 // layer-shell stretches it via AnchorLeft|AnchorRight width: 1 // layer-shell stretches it via AnchorLeft|AnchorRight
LayerShell.Window.scope: "dock-space" LayerShell.Window.scope: "dock-space"
LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.layer: LayerShell.Window.LayerBottom
LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: Kirigami.Units.gridUnit * 3 LayerShell.Window.exclusionZone: Math.max(1, MobileShell.Constants.navigationPanelThickness)
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
} }
@ -177,7 +185,9 @@ ContainmentItem {
return (windowMaximizedTracker.showingWindow || isCurrentWindowFullscreen) && !showingStartupFeedback return (windowMaximizedTracker.showingWindow || isCurrentWindowFullscreen) && !showingStartupFeedback
} }
readonly property alias isCurrentWindowFullscreen: windowMaximizedTracker.isCurrentWindowFullscreen readonly property alias isCurrentWindowFullscreen: windowMaximizedTracker.isCurrentWindowFullscreen
readonly property bool fullscreen: isCurrentWindowFullscreen || (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar) readonly property bool fullscreen: ShellSettings.Settings.gamingModeEnabled
|| isCurrentWindowFullscreen
|| (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar)
WindowPlugin.WindowMaximizedTracker { WindowPlugin.WindowMaximizedTracker {
id: windowMaximizedTracker id: windowMaximizedTracker
@ -205,6 +215,7 @@ ContainmentItem {
Item { Item {
id: navigationPanel id: navigationPanel
visible: !ShellSettings.Settings.gamingModeEnabled
anchors.fill: parent anchors.fill: parent
property real offset: 0 property real offset: 0

View file

@ -17,6 +17,45 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP
KCM.SimpleKCM { KCM.SimpleKCM {
id: root id: root
function packagePatterns(value: string): var {
if (value === "") {
return []
}
return value.split(",")
.map(pattern => pattern.trim())
.filter(pattern => pattern !== "")
}
function wildcardRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
return new RegExp("^" + escaped.replace(/\*/g, ".*") + "$")
}
function hasExactPackage(value: string, packageName: string): bool {
return packagePatterns(value).includes(packageName)
}
function hasWildcardPackage(value: string, packageName: string): bool {
return packagePatterns(value)
.filter(pattern => pattern.includes("*"))
.some(pattern => wildcardRegExp(pattern).test(packageName))
}
function hasEffectivePackage(value: string, packageName: string): bool {
return hasExactPackage(value, packageName) || hasWildcardPackage(value, packageName)
}
function updateExactPackage(value: string, packageName: string, enabled: bool): string {
const updatedPatterns = packagePatterns(value).filter(pattern => pattern !== packageName)
if (enabled) {
updatedPatterns.push(packageName)
}
return updatedPatterns.join(",")
}
topPadding: Kirigami.Units.largeSpacing topPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing bottomPadding: Kirigami.Units.largeSpacing
leftPadding: 0 leftPadding: 0
@ -74,6 +113,7 @@ KCM.SimpleKCM {
} }
ColumnLayout { ColumnLayout {
visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
Kirigami.InlineMessage { Kirigami.InlineMessage {
@ -85,6 +125,11 @@ KCM.SimpleKCM {
showCloseButton: true showCloseButton: true
} }
Kirigami.PlaceholderMessage {
Layout.fillWidth: true
explanation: i18n("This page manages the launchers exported by Waydroid. Enable Show in Game Shell for Android apps you want listed in Game Center's Waydroid tab. Touch and Wi-Fi toggles add or remove exact package names from Waydroid's documented compatibility property lists. Wildcard rules remain in the main Waydroid properties page.")
}
FormCard.FormCard { FormCard.FormCard {
Repeater { Repeater {
model: AIP.WaydroidDBusClient.applicationListModel model: AIP.WaydroidDBusClient.applicationListModel
@ -95,13 +140,30 @@ KCM.SimpleKCM {
width: ListView.view.width width: ListView.view.width
background: null background: null
contentItem: RowLayout { contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
RowLayout {
Layout.fillWidth: true
QQC2.Label { QQC2.Label {
Layout.fillWidth: true Layout.fillWidth: true
text: model.name text: model.name
elide: Text.ElideRight elide: Text.ElideRight
} }
QQC2.ToolButton {
display: QQC2.AbstractButton.IconOnly
text: i18nc("@action:button", "Launch the application")
icon.name: "media-playback-start"
onClicked: AIP.WaydroidDBusClient.launchApplication(model.id)
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton { QQC2.ToolButton {
display: QQC2.AbstractButton.IconOnly display: QQC2.AbstractButton.IconOnly
text: i18nc("@action:button", "Delete the application") text: i18nc("@action:button", "Delete the application")
@ -114,6 +176,71 @@ KCM.SimpleKCM {
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
} }
} }
RowLayout {
Layout.fillWidth: true
QQC2.CheckBox {
text: i18n("Show in Game Shell")
checked: AIP.WaydroidDBusClient.gameShellPackages.indexOf(model.id) !== -1
onClicked: AIP.WaydroidDBusClient.setGameShellEnabledForPackage(model.id, checked)
}
Item {
Layout.fillWidth: true
}
}
QQC2.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Kirigami.Theme.disabledTextColor
text: i18n("Adds this app to the Game Shell allowlist so it appears in Game Center under Waydroid.")
}
RowLayout {
Layout.fillWidth: true
QQC2.CheckBox {
id: fakeTouchToggle
text: i18n("Touch")
checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeTouch, model.id)
enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeTouch, model.id)
onClicked: {
AIP.WaydroidDBusClient.fakeTouch = root.updateExactPackage(AIP.WaydroidDBusClient.fakeTouch, model.id, checked)
}
QQC2.ToolTip.visible: hovered && !enabled
QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.CheckBox {
id: fakeWifiToggle
text: i18n("Wi-Fi")
checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeWifi, model.id)
enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeWifi, model.id)
onClicked: {
AIP.WaydroidDBusClient.fakeWifi = root.updateExactPackage(AIP.WaydroidDBusClient.fakeWifi, model.id, checked)
}
QQC2.ToolTip.visible: hovered && !enabled
QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
QQC2.Label {
Layout.fillWidth: true
visible: !fakeTouchToggle.enabled || !fakeWifiToggle.enabled
wrapMode: Text.WordWrap
color: Kirigami.Theme.disabledTextColor
text: i18n("One or more compatibility settings for this app come from a wildcard rule. Edit the global Waydroid property to change that rule.")
}
}
} }
Layout.fillWidth: true Layout.fillWidth: true
@ -121,4 +248,22 @@ KCM.SimpleKCM {
} }
} }
} }
ColumnLayout {
visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized
anchors.centerIn: parent
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Waydroid is unavailable")
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
}
PC3.Button {
text: i18n("Check again")
Layout.alignment: Qt.AlignHCenter
onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo()
}
}
} }

View file

@ -15,6 +15,13 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP
ColumnLayout { ColumnLayout {
id: root id: root
visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
&& AIP.WaydroidDBusClient.sessionStatus === AIP.WaydroidDBusClient.SessionRunning
function packagePatternSummary(value: string): string {
return value === "" ? i18n("Not set") : value
}
FormCard.FormHeader { FormCard.FormHeader {
title: i18n("General information") title: i18n("General information")
} }
@ -66,6 +73,64 @@ ColumnLayout {
onAccepted: AIP.WaydroidDBusClient.resetWaydroid() onAccepted: AIP.WaydroidDBusClient.resetWaydroid()
} }
Kirigami.PromptDialog {
id: fakeTouchDialog
title: i18n("Touch input override")
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
onOpened: {
fakeTouchField.text = AIP.WaydroidDBusClient.fakeTouch
fakeTouchField.forceActiveFocus()
}
onAccepted: AIP.WaydroidDBusClient.fakeTouch = fakeTouchField.text.trim()
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: i18n("Comma-separated package names for apps where mouse input should be interpreted as touch. Supports * wildcards. Leave empty to clear the override.")
}
QQC2.TextField {
id: fakeTouchField
Layout.fillWidth: true
placeholderText: "com.rovio.*"
}
}
}
Kirigami.PromptDialog {
id: fakeWifiDialog
title: i18n("Wi-Fi override")
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
onOpened: {
fakeWifiField.text = AIP.WaydroidDBusClient.fakeWifi
fakeWifiField.forceActiveFocus()
}
onAccepted: AIP.WaydroidDBusClient.fakeWifi = fakeWifiField.text.trim()
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: i18n("Comma-separated package names for apps that should always appear to be on Wi-Fi. Supports * wildcards. Leave empty to clear the override.")
}
QQC2.TextField {
id: fakeWifiField
Layout.fillWidth: true
placeholderText: "com.gameloft.*"
}
}
}
} }
// Some information such as IP address can take time to be set by Waydroid // Some information such as IP address can take time to be set by Waydroid
@ -140,5 +205,29 @@ ColumnLayout {
infoMessage.visible = true infoMessage.visible = true
} }
} }
FormCard.FormDelegateSeparator { above: uevent; below: fakeTouch }
FormCard.FormTextDelegate {
id: fakeTouch
text: i18n("Touch input override")
description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeTouch)
trailing: PC3.Button {
text: i18n("Edit")
onClicked: fakeTouchDialog.open()
}
}
FormCard.FormDelegateSeparator { above: fakeTouch; below: fakeWifi }
FormCard.FormTextDelegate {
id: fakeWifi
text: i18n("Wi-Fi override")
description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeWifi)
trailing: PC3.Button {
text: i18n("Edit")
onClicked: fakeWifiDialog.open()
}
}
} }
} }

View file

@ -29,7 +29,9 @@ KCM.SimpleKCM {
} }
WaydroidLoader { WaydroidLoader {
visible: AIP.WaydroidDBusClient.androidId === "" visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
&& AIP.WaydroidDBusClient.androidId === ""
&& !inlineMessage.visible
text: i18n("Fetching your Android ID.\nIt can take a few seconds.") text: i18n("Fetching your Android ID.\nIt can take a few seconds.")
} }
@ -53,7 +55,8 @@ KCM.SimpleKCM {
} }
ColumnLayout { ColumnLayout {
visible: AIP.WaydroidDBusClient.androidId !== "" visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized
&& AIP.WaydroidDBusClient.androidId !== ""
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.left: parent anchors.left: parent
anchors.leftMargin: Kirigami.Units.largeSpacing anchors.leftMargin: Kirigami.Units.largeSpacing
@ -76,4 +79,22 @@ KCM.SimpleKCM {
} }
} }
} }
ColumnLayout {
visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized
anchors.centerIn: parent
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Waydroid is unavailable")
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
}
QQC2.Button {
text: i18n("Check again")
Layout.alignment: Qt.AlignHCenter
onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo()
}
}
} }

View file

@ -29,14 +29,14 @@ KCM.SimpleKCM {
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
QQC2.Label { QQC2.Label {
text: i18n("Waydroid is not installed") text: i18n("Waydroid is unavailable")
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
PC3.Button { PC3.Button {
text: i18n("Check installation") text: i18n("Check again")
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo()
} }

View file

@ -11,9 +11,9 @@ Loader {
property var currentWindow property var currentWindow
// Window that needs geometry clamping after un-maximize in convergence // Windows awaiting geometry clamping after un-maximize in convergence
// mode. Set in onMaximizedChanged and consumed by the timer below. // mode. Using an array so concurrent un-maximizes are not lost.
property var pendingConstrainWindow: null property var pendingConstrainWindows: []
// After a window is un-maximized in convergence mode, the dockSpaceReserver // After a window is un-maximized in convergence mode, the dockSpaceReserver
// LayerShell surface needs one Wayland roundtrip to (re)commit its exclusive // LayerShell surface needs one Wayland roundtrip to (re)commit its exclusive
@ -24,15 +24,17 @@ Loader {
id: constrainAfterRestoreTimer id: constrainAfterRestoreTimer
interval: 200 interval: 200
onTriggered: { onTriggered: {
const window = root.pendingConstrainWindow const windows = root.pendingConstrainWindows.slice()
root.pendingConstrainWindow = null root.pendingConstrainWindows = []
if (!window || window.deleted || !window.normalWindow) return for (const window of windows) {
if (!ShellSettings.Settings.convergenceModeEnabled) return if (!window || window.deleted || !window.normalWindow) continue
if (!ShellSettings.Settings.convergenceModeEnabled) continue
if (ShellSettings.Settings.gamingModeEnabled) continue
const output = window.output const output = window.output
const desktop = window.desktops[0] const desktop = window.desktops[0]
if (!output) return if (!output) continue
if (!desktop) return if (!desktop) continue
const maxRect = KWinComponents.Workspace.clientArea( const maxRect = KWinComponents.Workspace.clientArea(
KWinComponents.Workspace.MaximizeArea, output, desktop) KWinComponents.Workspace.MaximizeArea, output, desktop)
@ -48,6 +50,7 @@ Loader {
} }
} }
} }
}
function run(window) { function run(window) {
// HACK: don't maximize xwaylandvideobridge // HACK: don't maximize xwaylandvideobridge
@ -60,6 +63,12 @@ Loader {
return; return;
} }
if (ShellSettings.Settings.gamingModeEnabled) {
window.noBorder = true;
window.setMaximize(true, true);
return;
}
if (ShellSettings.Settings.convergenceModeEnabled) { if (ShellSettings.Settings.convergenceModeEnabled) {
window.noBorder = false; window.noBorder = false;
} else { } else {
@ -91,6 +100,9 @@ Loader {
target: currentWindow target: currentWindow
function onFullScreenChanged() { function onFullScreenChanged() {
if (!currentWindow) {
return;
}
currentWindow.interactiveMoveResizeFinished.connect((currentWindow) => { currentWindow.interactiveMoveResizeFinished.connect((currentWindow) => {
root.run(currentWindow); root.run(currentWindow);
}); });
@ -98,6 +110,9 @@ Loader {
} }
function onMaximizedChanged() { function onMaximizedChanged() {
if (!currentWindow) {
return;
}
if (!currentWindow.maximizable) { if (!currentWindow.maximizable) {
return; return;
} }
@ -110,7 +125,7 @@ Loader {
// zone is re-committed over a Wayland roundtrip. // zone is re-committed over a Wayland roundtrip.
if (ShellSettings.Settings.convergenceModeEnabled if (ShellSettings.Settings.convergenceModeEnabled
&& ShellSettings.Settings.autoHidePanelsEnabled) { && ShellSettings.Settings.autoHidePanelsEnabled) {
root.pendingConstrainWindow = currentWindow root.pendingConstrainWindows.push(currentWindow)
constrainAfterRestoreTimer.restart() constrainAfterRestoreTimer.restart()
} }
} }
@ -128,12 +143,25 @@ Loader {
} }
} }
} }
function onGamingModeEnabledChanged() {
const windows = KWinComponents.Workspace.windows;
for (let i = 0; i < windows.length; i++) {
if (windows[i].normalWindow) {
root.run(windows[i]);
}
}
}
} }
Connections { Connections {
target: KWinComponents.Workspace target: KWinComponents.Workspace
function onWindowAdded(window) { function onWindowAdded(window) {
if (!window) {
return;
}
if (window.normalWindow) { if (window.normalWindow) {
window.interactiveMoveResizeFinished.connect((window) => { window.interactiveMoveResizeFinished.connect((window) => {
root.run(window); root.run(window);
@ -143,6 +171,9 @@ Loader {
} }
function onWindowActivated(window) { function onWindowActivated(window) {
if (!window) {
return;
}
if (window.normalWindow) { if (window.normalWindow) {
currentWindow = window; currentWindow = window;
window.interactiveMoveResizeFinished.connect((window) => { window.interactiveMoveResizeFinished.connect((window) => {

View file

@ -8,6 +8,8 @@ plasma_install_package(bluetooth org.kde.plasma.quicksetting.bluetooth quicksett
plasma_install_package(caffeine org.kde.plasma.quicksetting.caffeine quicksettings) plasma_install_package(caffeine org.kde.plasma.quicksetting.caffeine quicksettings)
plasma_install_package(docked org.kde.plasma.quicksetting.docked quicksettings) plasma_install_package(docked org.kde.plasma.quicksetting.docked quicksettings)
plasma_install_package(donotdisturb org.kde.plasma.quicksetting.donotdisturb quicksettings) plasma_install_package(donotdisturb org.kde.plasma.quicksetting.donotdisturb quicksettings)
plasma_install_package(gaming org.kde.plasma.quicksetting.gaming quicksettings)
plasma_install_package(gaminghint org.kde.plasma.quicksetting.gaminghint quicksettings)
plasma_install_package(autohidepanels org.kde.plasma.quicksetting.autohidepanels quicksettings) plasma_install_package(autohidepanels org.kde.plasma.quicksetting.autohidepanels quicksettings)
plasma_install_package(keyboardtoggle org.kde.plasma.quicksetting.keyboardtoggle quicksettings) plasma_install_package(keyboardtoggle org.kde.plasma.quicksetting.keyboardtoggle quicksettings)
plasma_install_package(kscreenosd org.kde.plasma.quicksetting.kscreenosd quicksettings) plasma_install_package(kscreenosd org.kde.plasma.quicksetting.kscreenosd quicksettings)

View file

@ -0,0 +1,16 @@
#! /usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
set -e
: "${XGETTEXT:?XGETTEXT is not set}"
: "${podir:?podir is not set}"
mapfile -t files < <(find . \( -name '*.js' -o -name '*.qml' -o -name '*.cpp' \) -print)
if [[ ${#files[@]} -eq 0 ]]; then
echo "Messages.sh: no input files found" >&2
exit 1
fi
"$XGETTEXT" "${files[@]}" -o "$podir/plasma_org.kde.plasma.quicksetting.gaming.pot"

View file

@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick 2.15
import org.kde.kirigami as Kirigami
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS
QS.QuickSetting {
id: root
text: i18n("Gaming Mode")
icon: "input-gaming"
status: enabled ? i18n("Active") : i18n("Inactive")
enabled: ShellSettings.Settings.gamingModeEnabled
function requestDisable() {
confirmDisableDialog.active = true;
}
function toggle() {
if (ShellSettings.Settings.gamingModeEnabled) {
requestDisable();
return;
}
ShellSettings.Settings.gamingModeEnabled = true;
}
Loader {
id: confirmDisableDialog
active: false
onLoaded: item.open()
sourceComponent: Kirigami.PromptDialog {
id: theConfirmDialog
title: i18n("Leave gaming mode?")
subtitle: i18n("Your games will keep running in the background.")
standardButtons: Kirigami.Dialog.NoButton
customFooterActions: [
Kirigami.Action {
text: i18n("Keep Playing")
onTriggered: theConfirmDialog.close()
},
Kirigami.Action {
text: i18n("Leave")
onTriggered: {
ShellSettings.Settings.gamingModeEnabled = false
theConfirmDialog.close()
}
}
]
onClosed: confirmDisableDialog.active = false
}
}
}

View file

@ -0,0 +1,18 @@
{
"KPackageStructure": "KPackage/GenericQML",
"KPlugin": {
"Authors": [
{
"Email": "mightymarco4@gmail.com",
"Name": "Marco Allegretti"
}
],
"Description": "Quick setting to toggle gaming mode for Plasma Mobile",
"Icon": "input-gaming",
"Id": "org.kde.plasma.quicksetting.gaming",
"License": "EUPL-1.2",
"Name": "Gaming Mode",
"Version": "0.1",
"Website": ""
}
}

View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
set -e
: "${XGETTEXT:?XGETTEXT is not set}"
: "${podir:?podir is not set}"
shopt -s nullglob
files=(*.json contents/ui/*.qml)
if [[ ${#files[@]} -eq 0 ]]; then
echo "Messages.sh: no input files found" >&2
exit 1
fi
"$XGETTEXT" "${files[@]}" -o "$podir/plasma_mobile_qt.pot"

View file

@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Marco Allegretti
// SPDX-License-Identifier: EUPL-1.2
import QtQuick 2.15
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS
QS.QuickSetting {
text: i18n("Launch Hint")
icon: "dialog-information"
status: ShellSettings.Settings.gamingDismissHintEnabled ? i18n("On") : i18n("Off")
enabled: true
available: ShellSettings.Settings.gamingModeEnabled
function toggle() {
ShellSettings.Settings.gamingDismissHintEnabled = !ShellSettings.Settings.gamingDismissHintEnabled;
}
}

View file

@ -0,0 +1,18 @@
{
"KPackageStructure": "KPackage/GenericQML",
"KPlugin": {
"Authors": [
{
"Email": "mightymarco4@gmail.com",
"Name": "Marco Allegretti"
}
],
"Description": "Quick setting to toggle the Game Center dismiss hint banner",
"Icon": "dialog-information",
"Id": "org.kde.plasma.quicksetting.gaminghint",
"License": "EUPL-1.2",
"Name": "Game Center Hint",
"Version": "0.1",
"Website": ""
}
}

View file

@ -1,2 +1,2 @@
SPDX-FileCopyrightText: 2026 Marco Allegretti. SPDX-FileCopyrightText: 2026 Marco Allegretti
SPDX-License-Identifier: EUPL-1.2 SPDX-License-Identifier: EUPL-1.2