Add cover art, search, filters, and recently played

Grid tiles now show Steam library artwork when available,
falling back to icon+label for games without cover art.
Cell proportions adjusted to 2:3 for portrait covers.

Search bar filters the library by name. Source tabs filter
by All/Steam/Desktop. Both properties live in C++ so QML
just binds filterString and sourceFilter.

"Continue Playing" row shows the last 5 launched games
with artwork, persisted across sessions via plasmamobilerc.

Remove orphaned GameTile.qml (replaced by inline delegate).
This commit is contained in:
Marco Allegretti 2026-04-20 10:03:41 +02:00
parent 1bf8cfb2ba
commit 976c770af4
6 changed files with 393 additions and 138 deletions

View file

@ -18,6 +18,7 @@ target_link_libraries(gamingshellplugin PRIVATE
KF6::I18n KF6::I18n
KF6::Service KF6::Service
KF6::CoreAddons KF6::CoreAddons
KF6::ConfigCore
SDL3::SDL3 SDL3::SDL3
) )

View file

@ -3,7 +3,9 @@
#include "gamelauncherprovider.h" #include "gamelauncherprovider.h"
#include <KConfigGroup>
#include <KService> #include <KService>
#include <KSharedConfig>
#include <KShell> #include <KShell>
#include <KSycoca> #include <KSycoca>
@ -15,8 +17,11 @@
#include <QStandardPaths> #include <QStandardPaths>
#include <QTextStream> #include <QTextStream>
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
GameLauncherProvider::GameLauncherProvider(QObject *parent) GameLauncherProvider::GameLauncherProvider(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc")))
{ {
connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh);
refresh(); refresh();
@ -80,23 +85,22 @@ void GameLauncherProvider::refresh()
m_loading = true; m_loading = true;
Q_EMIT loadingChanged(); Q_EMIT loadingChanged();
beginResetModel(); m_allGames.clear();
m_games.clear();
loadDesktopGames(); loadDesktopGames();
loadSteamGames(); loadSteamGames();
loadFlatpakGames(); loadFlatpakGames();
loadRecentTimestamps();
// Sort alphabetically, case-insensitive // Sort alphabetically, case-insensitive
std::sort(m_games.begin(), m_games.end(), [](const GameEntry &a, const GameEntry &b) { std::sort(m_allGames.begin(), m_allGames.end(), [](const GameEntry &a, const GameEntry &b) {
return a.name.compare(b.name, Qt::CaseInsensitive) < 0; return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
}); });
endResetModel(); applyFilter();
m_loading = false; m_loading = false;
Q_EMIT loadingChanged(); Q_EMIT loadingChanged();
Q_EMIT countChanged();
} }
void GameLauncherProvider::launch(int index) void GameLauncherProvider::launch(int index)
@ -128,6 +132,54 @@ void GameLauncherProvider::launch(int index)
} }
Q_EMIT gameLaunched(g.name); Q_EMIT gameLaunched(g.name);
// Record timestamp for "recently played"
saveRecentTimestamp(g.storageId, QDateTime::currentDateTime());
// Update the in-memory entry so recentGames() picks it up immediately
for (auto &entry : m_allGames) {
if (entry.storageId == g.storageId) {
entry.lastPlayed = QDateTime::currentDateTime();
break;
}
}
}
void GameLauncherProvider::launchByStorageId(const QString &storageId)
{
for (int i = 0; i < m_allGames.size(); ++i) {
if (m_allGames.at(i).storageId == storageId) {
// Find the index in the filtered model, or launch from allGames directly
for (int j = 0; j < m_games.size(); ++j) {
if (m_games.at(j).storageId == storageId) {
launch(j);
return;
}
}
// Not in filtered view — launch directly from allGames
const auto &g = m_allGames.at(i);
if (g.source == QLatin1String("desktop")) {
auto service = KService::serviceByStorageId(g.storageId);
if (service) {
QStringList args = KShell::splitArgs(service->exec());
if (!args.isEmpty()) {
QString program = args.takeFirst();
QProcess::startDetached(program, args);
}
}
} else {
QStringList parts = g.launchCommand.split(QLatin1Char(' '));
if (!parts.isEmpty()) {
QString program = parts.takeFirst();
QProcess::startDetached(program, parts);
}
}
Q_EMIT gameLaunched(g.name);
saveRecentTimestamp(g.storageId, QDateTime::currentDateTime());
m_allGames[i].lastPlayed = QDateTime::currentDateTime();
return;
}
}
} }
// --- XDG .desktop games --- // --- XDG .desktop games ---
@ -158,7 +210,7 @@ void GameLauncherProvider::loadDesktopGames()
entry.storageId = service->storageId(); entry.storageId = service->storageId();
entry.launchCommand = service->exec(); entry.launchCommand = service->exec();
entry.installed = true; entry.installed = true;
m_games.append(entry); m_allGames.append(entry);
} }
} }
@ -267,7 +319,7 @@ void GameLauncherProvider::loadSteamGames()
} }
} }
m_games.append(entry); m_allGames.append(entry);
} }
} }
} }
@ -282,3 +334,103 @@ void GameLauncherProvider::loadFlatpakGames()
// (e.g. querying flatpak metadata for games that don't set // (e.g. querying flatpak metadata for games that don't set
// the Game category properly). // the Game category properly).
} }
QString GameLauncherProvider::filterString() const
{
return m_filterString;
}
void GameLauncherProvider::setFilterString(const QString &filter)
{
if (m_filterString == filter) {
return;
}
m_filterString = filter;
Q_EMIT filterStringChanged();
applyFilter();
}
QString GameLauncherProvider::sourceFilter() const
{
return m_sourceFilter;
}
void GameLauncherProvider::setSourceFilter(const QString &source)
{
if (m_sourceFilter == source) {
return;
}
m_sourceFilter = source;
Q_EMIT sourceFilterChanged();
applyFilter();
}
void GameLauncherProvider::applyFilter()
{
beginResetModel();
m_games.clear();
for (const auto &g : std::as_const(m_allGames)) {
if (!m_sourceFilter.isEmpty() && g.source != m_sourceFilter) {
continue;
}
if (!m_filterString.isEmpty() && !g.name.contains(m_filterString, Qt::CaseInsensitive)) {
continue;
}
m_games.append(g);
}
endResetModel();
Q_EMIT countChanged();
}
void GameLauncherProvider::loadRecentTimestamps()
{
const KConfigGroup group(m_config, s_recentGroup);
for (auto &entry : m_allGames) {
const QString key = entry.storageId;
if (group.hasKey(key)) {
entry.lastPlayed = group.readEntry(key, QDateTime());
}
}
}
void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const QDateTime &when)
{
KConfigGroup group(m_config, s_recentGroup);
group.writeEntry(storageId, when);
group.sync();
}
QVariantList GameLauncherProvider::recentGames(int limit) const
{
// Gather entries that have been launched at least once
QList<const GameEntry *> recent;
for (const auto &g : m_allGames) {
if (g.lastPlayed.isValid()) {
recent.append(&g);
}
}
// Most recent first
std::sort(recent.begin(), recent.end(), [](const GameEntry *a, const GameEntry *b) {
return a->lastPlayed > b->lastPlayed;
});
if (recent.size() > limit) {
recent.resize(limit);
}
QVariantList result;
result.reserve(recent.size());
for (const auto *g : recent) {
QVariantMap map;
map[QStringLiteral("name")] = g->name;
map[QStringLiteral("icon")] = g->icon;
map[QStringLiteral("source")] = g->source;
map[QStringLiteral("storageId")] = g->storageId;
map[QStringLiteral("artwork")] = g->artwork;
result.append(map);
}
return result;
}

View file

@ -4,10 +4,13 @@
#pragma once #pragma once
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QDateTime>
#include <QList> #include <QList>
#include <QString> #include <QString>
#include <qqmlregistration.h> #include <qqmlregistration.h>
#include <KSharedConfig>
class GameLauncherProvider : public QAbstractListModel class GameLauncherProvider : public QAbstractListModel
{ {
Q_OBJECT Q_OBJECT
@ -16,6 +19,8 @@ class GameLauncherProvider : public QAbstractListModel
Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) 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)
public: public:
explicit GameLauncherProvider(QObject *parent = nullptr); explicit GameLauncherProvider(QObject *parent = nullptr);
@ -37,13 +42,21 @@ public:
int count() const; int count() const;
bool loading() const; bool loading() const;
QString filterString() const;
void setFilterString(const QString &filter);
QString sourceFilter() const;
void setSourceFilter(const QString &source);
Q_INVOKABLE void refresh(); Q_INVOKABLE void refresh();
Q_INVOKABLE void launch(int index); Q_INVOKABLE void launch(int index);
Q_INVOKABLE void launchByStorageId(const QString &storageId);
Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
Q_SIGNALS: Q_SIGNALS:
void countChanged(); void countChanged();
void loadingChanged(); void loadingChanged();
void filterStringChanged();
void sourceFilterChanged();
void gameLaunched(const QString &name); void gameLaunched(const QString &name);
private: private:
@ -54,13 +67,21 @@ private:
QString storageId; QString storageId;
QString launchCommand; QString launchCommand;
QString artwork; QString artwork;
QDateTime lastPlayed;
bool installed = true; bool installed = true;
}; };
void loadDesktopGames(); void loadDesktopGames();
void loadSteamGames(); void loadSteamGames();
void loadFlatpakGames(); void loadFlatpakGames();
void loadRecentTimestamps();
void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
void applyFilter();
QList<GameEntry> m_games; QList<GameEntry> m_allGames;
QList<GameEntry> m_games; // filtered view
QString m_filterString;
QString m_sourceFilter; // empty = all, or "desktop"/"steam"/"flatpak"
KSharedConfigPtr m_config;
bool m_loading = false; bool m_loading = false;
}; };

View file

@ -78,7 +78,6 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES
qml/gaming/GameCenterOverlay.qml qml/gaming/GameCenterOverlay.qml
qml/gaming/GameTile.qml
qml/gaming/GamingHUD.qml qml/gaming/GamingHUD.qml
qml/gaming/RunningGamesView.qml qml/gaming/RunningGamesView.qml
PATH gaming PATH gaming

View file

@ -49,6 +49,8 @@ Window {
onVisibleChanged: { onVisibleChanged: {
if (visible) { if (visible) {
GamingShell.GameLauncherProvider.filterString = ""
GamingShell.GameLauncherProvider.sourceFilter = ""
GamingShell.GameLauncherProvider.refresh() GamingShell.GameLauncherProvider.refresh()
if (runningGames.hasTasks) { if (runningGames.hasTasks) {
runningGames.focusFirstTask() runningGames.focusFirstTask()
@ -155,12 +157,123 @@ Window {
onMoveDownRequested: grid.forceActiveFocus() onMoveDownRequested: grid.forceActiveFocus()
} }
// ---- game grid ---- // ---- continue playing ----
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: recentList.count > 0 && !runningGames.hasTasks
Kirigami.Heading { Kirigami.Heading {
level: 2 level: 2
text: i18n("Library") text: i18n("Continue Playing")
} }
ListView {
id: recentList
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
orientation: ListView.Horizontal
spacing: Kirigami.Units.largeSpacing
clip: true
model: root.visible ? GamingShell.GameLauncherProvider.recentGames(5) : []
delegate: QQC2.ItemDelegate {
width: Kirigami.Units.gridUnit * 7
height: recentList.height
required property var modelData
readonly property bool hasArt: modelData.artwork && modelData.artwork.length > 0
background: Rectangle {
radius: Kirigami.Units.cornerRadius
color: parent.hovered ? Kirigami.Theme.hoverColor : "transparent"
}
contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
Image {
Layout.fillWidth: true
Layout.fillHeight: true
source: hasArt ? "file://" + modelData.artwork : ""
fillMode: Image.PreserveAspectCrop
visible: hasArt
asynchronous: true
}
Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.large
implicitHeight: Kirigami.Units.iconSizes.large
source: modelData.icon
visible: !hasArt
}
PC3.Label {
Layout.fillWidth: true
text: modelData.name
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
}
}
onClicked: {
GamingShell.GameLauncherProvider.launchByStorageId(modelData.storageId)
root.gameStarted()
}
}
}
}
// ---- search + filter ----
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
Kirigami.SearchField {
id: searchField
Layout.fillWidth: true
placeholderText: i18n("Search games…")
onTextChanged: GamingShell.GameLauncherProvider.filterString = text
Keys.onEscapePressed: {
if (text.length > 0) {
clear()
} else {
root.dismissRequested()
}
}
Keys.onDownPressed: grid.forceActiveFocus()
}
QQC2.TabBar {
id: sourceFilterBar
Layout.alignment: Qt.AlignVCenter
QQC2.TabButton {
text: i18n("All")
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = ""
}
QQC2.TabButton {
text: "Steam"
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "steam"
}
QQC2.TabButton {
text: i18n("Desktop")
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop"
}
}
}
// ---- game grid ----
GridView { GridView {
id: grid id: grid
@ -169,11 +282,11 @@ Window {
model: GamingShell.GameLauncherProvider model: GamingShell.GameLauncherProvider
readonly property real minCellSize: Kirigami.Units.gridUnit * 7 readonly property real minCellSize: Kirigami.Units.gridUnit * 8
readonly property int columns: Math.max(2, Math.floor(width / minCellSize)) readonly property int columns: Math.max(2, Math.floor(width / minCellSize))
cellWidth: Math.floor(width / columns) cellWidth: Math.floor(width / columns)
cellHeight: cellWidth + Kirigami.Units.gridUnit * 2 cellHeight: Math.floor(cellWidth * 1.5) + Kirigami.Units.gridUnit * 2
keyNavigationEnabled: true keyNavigationEnabled: true
highlightMoveDuration: 0 highlightMoveDuration: 0
@ -208,9 +321,14 @@ Window {
required property string name required property string name
required property string icon required property string icon
required property string source required property string source
required property string artwork
readonly property bool hasArt: artwork.length > 0
QQC2.ItemDelegate { QQC2.ItemDelegate {
anchors.fill: parent anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
padding: 0
readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus
@ -223,16 +341,69 @@ Window {
Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } }
} }
contentItem: ColumnLayout { contentItem: Item {
// ---- cover art tile ----
ColumnLayout {
anchors.fill: parent
spacing: 0
visible: hasArt
Image {
Layout.fillWidth: true
Layout.fillHeight: true
source: hasArt ? "file://" + artwork : ""
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: true
// Rounded top corners via layer
layer.enabled: true
layer.effect: Item {
Rectangle {
anchors.fill: parent
radius: Kirigami.Units.cornerRadius
}
}
scale: parent.parent.parent.isCurrent ? 1.03 : 1.0
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
}
// Title beneath artwork
PC3.Label {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
text: name
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: Kirigami.Units.smallSpacing
rightPadding: Kirigami.Units.smallSpacing
color: parent.parent.parent.isCurrent
? Kirigami.Theme.highlightedTextColor
: Kirigami.Theme.textColor
}
}
// ---- fallback icon tile ----
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
visible: !hasArt
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
Item { Layout.fillHeight: true }
Kirigami.Icon { Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.huge implicitWidth: Kirigami.Units.iconSizes.huge
implicitHeight: Kirigami.Units.iconSizes.huge implicitHeight: Kirigami.Units.iconSizes.huge
source: icon source: icon
scale: parent.parent.isCurrent ? 1.08 : 1.0 scale: parent.parent.parent.isCurrent ? 1.08 : 1.0
Behavior on scale { Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
} }
@ -246,7 +417,7 @@ Window {
wrapMode: Text.Wrap wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight elide: Text.ElideRight
color: parent.parent.isCurrent color: parent.parent.parent.isCurrent
? Kirigami.Theme.highlightedTextColor ? Kirigami.Theme.highlightedTextColor
: Kirigami.Theme.textColor : Kirigami.Theme.textColor
} }
@ -261,6 +432,9 @@ Window {
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.6 opacity: 0.6
} }
Item { Layout.fillHeight: true }
}
} }
onClicked: { onClicked: {

View file

@ -1,92 +0,0 @@
// 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.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.plasmoid
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
import org.kde.plasma.components 3.0 as PC3
QQC2.ItemDelegate {
id: root
required property var folio
required property Folio.FolioApplication application
required property bool isCurrent
signal launchRequested()
Keys.onReturnPressed: clicked()
Keys.onEnterPressed: clicked()
onClicked: {
if (!application) return
if (application.icon && typeof application.icon === "string" && application.icon.length > 0 && !application.running) {
MobileShellState.ShellDBusClient.openAppLaunchAnimationWithPosition(
Plasmoid.screen,
application.icon,
application.name,
application.storageId,
iconItem.Kirigami.ScenePosition.x + iconItem.width / 2,
iconItem.Kirigami.ScenePosition.y + iconItem.height / 2,
Math.min(iconItem.width, iconItem.height))
}
MobileShell.AppLaunch.launchOrActivateApp(application.storageId)
launchRequested()
}
function launch() {
clicked()
}
background: Rectangle {
Kirigami.Theme.colorSet: Kirigami.Theme.Button
color: root.isCurrent
? Kirigami.Theme.highlightColor
: (root.hovered ? Kirigami.Theme.hoverColor : "transparent")
radius: Kirigami.Units.cornerRadius
Behavior on color {
ColorAnimation { duration: Kirigami.Units.shortDuration }
}
}
contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
id: iconItem
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.huge
implicitHeight: Kirigami.Units.iconSizes.huge
source: root.application ? root.application.icon : ""
scale: root.isCurrent ? 1.08 : 1.0
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
}
PC3.Label {
id: nameLabel
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
text: root.application ? root.application.name : ""
maximumLineCount: 2
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
color: root.isCurrent ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
Behavior on color {
ColorAnimation { duration: Kirigami.Units.shortDuration }
}
}
}
}