diff --git a/components/gamingshellplugin/CMakeLists.txt b/components/gamingshellplugin/CMakeLists.txt index 0e823be6..803d5d99 100644 --- a/components/gamingshellplugin/CMakeLists.txt +++ b/components/gamingshellplugin/CMakeLists.txt @@ -18,6 +18,7 @@ target_link_libraries(gamingshellplugin PRIVATE KF6::I18n KF6::Service KF6::CoreAddons + KF6::ConfigCore SDL3::SDL3 ) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 1b1fafb5..287c7c10 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -3,7 +3,9 @@ #include "gamelauncherprovider.h" +#include #include +#include #include #include @@ -15,8 +17,11 @@ #include #include +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 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; +} diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index b570bc81..8fc96539 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -4,10 +4,13 @@ #pragma once #include +#include #include #include #include +#include + 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 m_games; + QList m_allGames; + QList m_games; // filtered view + QString m_filterString; + QString m_sourceFilter; // empty = all, or "desktop"/"steam"/"flatpak" + KSharedConfigPtr m_config; bool m_loading = false; }; diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index 4262ca97..ce9cf8e6 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -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 diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index e19b2de5..61d8436c 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -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 } } } diff --git a/containments/homescreens/folio/qml/gaming/GameTile.qml b/containments/homescreens/folio/qml/gaming/GameTile.qml deleted file mode 100644 index ef821540..00000000 --- a/containments/homescreens/folio/qml/gaming/GameTile.qml +++ /dev/null @@ -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 } - } - } - } -}