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::Service
KF6::CoreAddons
KF6::ConfigCore
SDL3::SDL3
)

View file

@ -3,7 +3,9 @@
#include "gamelauncherprovider.h"
#include <KConfigGroup>
#include <KService>
#include <KSharedConfig>
#include <KShell>
#include <KSycoca>
@ -15,8 +17,11 @@
#include <QStandardPaths>
#include <QTextStream>
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
GameLauncherProvider::GameLauncherProvider(QObject *parent)
: QAbstractListModel(parent)
, m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc")))
{
connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh);
refresh();
@ -80,23 +85,22 @@ void GameLauncherProvider::refresh()
m_loading = true;
Q_EMIT loadingChanged();
beginResetModel();
m_games.clear();
m_allGames.clear();
loadDesktopGames();
loadSteamGames();
loadFlatpakGames();
loadRecentTimestamps();
// 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;
});
endResetModel();
applyFilter();
m_loading = false;
Q_EMIT loadingChanged();
Q_EMIT countChanged();
}
void GameLauncherProvider::launch(int index)
@ -128,6 +132,54 @@ void GameLauncherProvider::launch(int index)
}
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 ---
@ -158,7 +210,7 @@ void GameLauncherProvider::loadDesktopGames()
entry.storageId = service->storageId();
entry.launchCommand = service->exec();
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
// 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
#include <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QString>
#include <qqmlregistration.h>
#include <KSharedConfig>
class GameLauncherProvider : public QAbstractListModel
{
Q_OBJECT
@ -16,6 +19,8 @@ class GameLauncherProvider : public QAbstractListModel
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)
public:
explicit GameLauncherProvider(QObject *parent = nullptr);
@ -37,13 +42,21 @@ public:
int count() 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 launch(int index);
Q_INVOKABLE void launchByStorageId(const QString &storageId);
Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
Q_SIGNALS:
void countChanged();
void loadingChanged();
void filterStringChanged();
void sourceFilterChanged();
void gameLaunched(const QString &name);
private:
@ -54,13 +67,21 @@ private:
QString storageId;
QString launchCommand;
QString artwork;
QDateTime lastPlayed;
bool installed = true;
};
void loadDesktopGames();
void loadSteamGames();
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;
};

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
qml/gaming/GameCenterOverlay.qml
qml/gaming/GameTile.qml
qml/gaming/GamingHUD.qml
qml/gaming/RunningGamesView.qml
PATH gaming

View file

@ -49,6 +49,8 @@ Window {
onVisibleChanged: {
if (visible) {
GamingShell.GameLauncherProvider.filterString = ""
GamingShell.GameLauncherProvider.sourceFilter = ""
GamingShell.GameLauncherProvider.refresh()
if (runningGames.hasTasks) {
runningGames.focusFirstTask()
@ -155,12 +157,123 @@ Window {
onMoveDownRequested: grid.forceActiveFocus()
}
// ---- game grid ----
Kirigami.Heading {
level: 2
text: i18n("Library")
// ---- continue playing ----
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: recentList.count > 0 && !runningGames.hasTasks
Kirigami.Heading {
level: 2
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 {
id: grid
@ -169,11 +282,11 @@ Window {
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))
cellWidth: Math.floor(width / columns)
cellHeight: cellWidth + Kirigami.Units.gridUnit * 2
cellHeight: Math.floor(cellWidth * 1.5) + Kirigami.Units.gridUnit * 2
keyNavigationEnabled: true
highlightMoveDuration: 0
@ -208,9 +321,14 @@ Window {
required property string name
required property string icon
required property string source
required property string artwork
readonly property bool hasArt: artwork.length > 0
QQC2.ItemDelegate {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
padding: 0
readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus
@ -223,43 +341,99 @@ Window {
Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } }
}
contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
contentItem: Item {
// ---- cover art tile ----
ColumnLayout {
anchors.fill: parent
spacing: 0
visible: hasArt
Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.huge
implicitHeight: Kirigami.Units.iconSizes.huge
source: icon
Image {
Layout.fillWidth: true
Layout.fillHeight: true
source: hasArt ? "file://" + artwork : ""
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: true
scale: parent.parent.isCurrent ? 1.08 : 1.0
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
// 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
}
}
PC3.Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
text: name
maximumLineCount: 2
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
color: parent.parent.isCurrent
? Kirigami.Theme.highlightedTextColor
: Kirigami.Theme.textColor
}
// ---- fallback icon tile ----
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
visible: !hasArt
spacing: Kirigami.Units.smallSpacing
// Source badge
PC3.Label {
Layout.alignment: Qt.AlignHCenter
text: source === "steam" ? "Steam"
: source === "flatpak" ? "Flatpak"
: ""
visible: source !== "desktop"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.6
Item { Layout.fillHeight: true }
Kirigami.Icon {
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.huge
implicitHeight: Kirigami.Units.iconSizes.huge
source: icon
scale: parent.parent.parent.isCurrent ? 1.08 : 1.0
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad }
}
}
PC3.Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
text: name
maximumLineCount: 2
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
color: parent.parent.parent.isCurrent
? Kirigami.Theme.highlightedTextColor
: Kirigami.Theme.textColor
}
// Source badge
PC3.Label {
Layout.alignment: Qt.AlignHCenter
text: source === "steam" ? "Steam"
: source === "flatpak" ? "Flatpak"
: ""
visible: source !== "desktop"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.6
}
Item { Layout.fillHeight: true }
}
}

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