From 3ec2badcd7bfebfb48f393cc9387d312760a965b Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 25 Jan 2026 14:30:02 +0100 Subject: [PATCH] Media: add per-game screenshots manager and gallery --- src/CMakeLists.txt | 2 + src/app.cpp | 6 ++ src/app.h | 4 + src/mediamanager.cpp | 145 +++++++++++++++++++++++++++++++++++ src/mediamanager.h | 41 ++++++++++ src/qml/GameDetailsSheet.qml | 91 ++++++++++++++++++++++ src/screenshotmodel.cpp | 135 ++++++++++++++++++++++++++++++++ src/screenshotmodel.h | 59 ++++++++++++++ 8 files changed, 483 insertions(+) create mode 100644 src/mediamanager.cpp create mode 100644 src/mediamanager.h create mode 100644 src/screenshotmodel.cpp create mode 100644 src/screenshotmodel.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 869199c..c3e902d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -8,6 +8,8 @@ add_executable(alakarte gamesortfiltermodel.cpp game.cpp gamepadmanager.cpp + mediamanager.cpp + screenshotmodel.cpp platformimporter.cpp steamimporter.cpp lutrisimporter.cpp diff --git a/src/app.cpp b/src/app.cpp index c4f446f..c0e3c2c 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -27,6 +27,7 @@ App::App(QObject *parent) , m_gameModel(new GameModel(this)) , m_launcher(new GameLauncher(this)) , m_steamGridDB(new SteamGridDB(this)) + , m_mediaManager(new MediaManager(this)) , m_config(new Config(this)) { loadLibrary(); @@ -172,6 +173,11 @@ SteamGridDB *App::steamGridDB() const return m_steamGridDB; } +MediaManager *App::mediaManager() const +{ + return m_mediaManager; +} + Config *App::config() const { return m_config; diff --git a/src/app.h b/src/app.h index de71a41..e48342c 100644 --- a/src/app.h +++ b/src/app.h @@ -11,6 +11,7 @@ #include "config.h" #include "gamelauncher.h" #include "gamemodel.h" +#include "mediamanager.h" #include "steamgriddb.h" class App : public QObject @@ -22,6 +23,7 @@ class App : public QObject Q_PROPERTY(GameModel *gameModel READ gameModel CONSTANT) Q_PROPERTY(GameLauncher *launcher READ launcher CONSTANT) Q_PROPERTY(SteamGridDB *steamGridDB READ steamGridDB CONSTANT) + Q_PROPERTY(MediaManager *mediaManager READ mediaManager CONSTANT) Q_PROPERTY(Config *config READ config CONSTANT) Q_PROPERTY(bool importing READ importing NOTIFY importingChanged) Q_PROPERTY(QString importStatus READ importStatus NOTIFY importStatusChanged) @@ -33,6 +35,7 @@ public: GameModel *gameModel() const; GameLauncher *launcher() const; SteamGridDB *steamGridDB() const; + MediaManager *mediaManager() const; Config *config() const; bool importing() const; @@ -72,6 +75,7 @@ private: GameModel *m_gameModel; GameLauncher *m_launcher; SteamGridDB *m_steamGridDB; + MediaManager *m_mediaManager; Config *m_config; bool m_importing = false; diff --git a/src/mediamanager.cpp b/src/mediamanager.cpp new file mode 100644 index 0000000..20c45ed --- /dev/null +++ b/src/mediamanager.cpp @@ -0,0 +1,145 @@ +#include "mediamanager.h" + +#include "game.h" +#include "screenshotmodel.h" + +#include +#include +#include +#include +#include + +MediaManager::MediaManager(QObject *parent) + : QObject(parent) +{ +} + +QString MediaManager::screenshotsBasePath() const +{ + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/media/screenshots"); +} + +QString MediaManager::gameId(Game *game) const +{ + return game ? game->id() : QString(); +} + +QString MediaManager::ensureDirectory(const QString &path) const +{ + if (path.isEmpty()) { + return {}; + } + + QDir dir(path); + if (!dir.exists()) { + QDir parent; + parent.mkpath(path); + } + + return QDir(path).absolutePath(); +} + +QString MediaManager::gameScreenshotsFolderPath(Game *game) const +{ + const QString id = gameId(game); + if (id.isEmpty()) { + return {}; + } + + return screenshotsBasePath() + QLatin1Char('/') + id; +} + +QString MediaManager::screenshotsFolderPath(Game *game) const +{ + const QString path = gameScreenshotsFolderPath(game); + return ensureDirectory(path); +} + +QUrl MediaManager::screenshotsFolderUrl(Game *game) const +{ + const QString path = screenshotsFolderPath(game); + if (path.isEmpty()) { + return {}; + } + return QUrl::fromLocalFile(path); +} + +QObject *MediaManager::screenshotsModel(Game *game) +{ + const QString id = gameId(game); + if (id.isEmpty()) { + return nullptr; + } + + if (m_models.contains(id) && m_models.value(id)) { + return m_models.value(id); + } + + auto *model = new ScreenshotModel(this); + model->setDirectoryPath(screenshotsFolderPath(game)); + m_models.insert(id, model); + return model; +} + +QString MediaManager::uniqueDestinationPath(const QString &folderPath, const QString &fileName) const +{ + const QString baseName = QFileInfo(fileName).completeBaseName(); + const QString suffix = QFileInfo(fileName).suffix(); + + const QString cleanBase = baseName.isEmpty() ? QStringLiteral("screenshot") : baseName; + const QString cleanSuffix = suffix.isEmpty() ? QStringLiteral("png") : suffix; + + QString candidate = folderPath + QLatin1Char('/') + cleanBase + QLatin1Char('.') + cleanSuffix; + if (!QFileInfo::exists(candidate)) { + return candidate; + } + + for (int i = 1; i < 10000; ++i) { + candidate = folderPath + QLatin1Char('/') + cleanBase + QLatin1Char('_') + QString::number(i) + QLatin1Char('.') + cleanSuffix; + if (!QFileInfo::exists(candidate)) { + return candidate; + } + } + + return folderPath + QLatin1Char('/') + cleanBase + QLatin1Char('_') + QString::number(QDateTime::currentMSecsSinceEpoch()) + QLatin1Char('.') + cleanSuffix; +} + +bool MediaManager::importScreenshot(Game *game, const QUrl &sourceUrl) +{ + if (!game) { + return false; + } + + if (!sourceUrl.isLocalFile()) { + Q_EMIT importError(game, tr("Only local files are supported")); + return false; + } + + const QString srcPath = sourceUrl.toLocalFile(); + QFileInfo srcInfo(srcPath); + if (!srcInfo.exists() || !srcInfo.isFile()) { + Q_EMIT importError(game, tr("Source file does not exist")); + return false; + } + + const QString folder = screenshotsFolderPath(game); + if (folder.isEmpty()) { + Q_EMIT importError(game, tr("Failed to create screenshots folder")); + return false; + } + + const QString destPath = uniqueDestinationPath(folder, srcInfo.fileName()); + + if (!QFile::copy(srcPath, destPath)) { + Q_EMIT importError(game, tr("Failed to copy file")); + return false; + } + + const QString id = gameId(game); + if (m_models.contains(id) && m_models.value(id)) { + m_models.value(id)->refresh(); + } + + Q_EMIT screenshotsImported(game, 1); + return true; +} diff --git a/src/mediamanager.h b/src/mediamanager.h new file mode 100644 index 0000000..3603106 --- /dev/null +++ b/src/mediamanager.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + +class Game; +class ScreenshotModel; + +class MediaManager : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(QString screenshotsBasePath READ screenshotsBasePath CONSTANT) + +public: + explicit MediaManager(QObject *parent = nullptr); + + QString screenshotsBasePath() const; + + Q_INVOKABLE QString screenshotsFolderPath(Game *game) const; + Q_INVOKABLE QUrl screenshotsFolderUrl(Game *game) const; + + Q_INVOKABLE QObject *screenshotsModel(Game *game); + + Q_INVOKABLE bool importScreenshot(Game *game, const QUrl &sourceUrl); + +Q_SIGNALS: + void screenshotsImported(Game *game, int count); + void importError(Game *game, const QString &error); + +private: + QString gameId(Game *game) const; + QString gameScreenshotsFolderPath(Game *game) const; + QString ensureDirectory(const QString &path) const; + QString uniqueDestinationPath(const QString &folderPath, const QString &fileName) const; + + QHash> m_models; +}; diff --git a/src/qml/GameDetailsSheet.qml b/src/qml/GameDetailsSheet.qml index a2b6122..be737ab 100644 --- a/src/qml/GameDetailsSheet.qml +++ b/src/qml/GameDetailsSheet.qml @@ -3,6 +3,7 @@ import QtQuick import QtQuick.Controls as QQC2 +import QtQuick.Dialogs import QtQuick.Layouts import QtQuick.Effects import org.kde.kirigami as Kirigami @@ -13,6 +14,8 @@ Kirigami.OverlaySheet { property var game + readonly property var screenshotsModel: game ? App.mediaManager.screenshotsModel(game) : null + readonly property bool isMobile: { let w = applicationWindow() if (w && w.isMobile !== undefined) return w.isMobile @@ -422,6 +425,94 @@ Kirigami.OverlaySheet { Kirigami.FormData.label: i18n("Install path:") } } + + Kirigami.Heading { + Layout.fillWidth: true + level: 2 + text: i18n("Screenshots") + visible: !!game + } + + RowLayout { + Layout.fillWidth: true + visible: !!game + + QQC2.Button { + text: i18n("Open Folder") + icon.name: "folder-open" + enabled: !!game + onClicked: if (game) { + Qt.openUrlExternally(App.mediaManager.screenshotsFolderUrl(game)) + } + } + + QQC2.Button { + text: i18n("Import Screenshot") + icon.name: "document-open" + enabled: !!game + onClicked: screenshotImportDialog.open() + } + + Item { Layout.fillWidth: true } + } + + QQC2.Label { + Layout.fillWidth: true + visible: !!game && (!detailsSheet.screenshotsModel || detailsSheet.screenshotsModel.count === 0) + text: i18n("No screenshots yet") + color: Kirigami.Theme.disabledTextColor + } + + GridView { + id: screenshotsGrid + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 12 + visible: detailsSheet.screenshotsModel && detailsSheet.screenshotsModel.count > 0 + clip: true + + cellWidth: Kirigami.Units.gridUnit * 6 + cellHeight: Kirigami.Units.gridUnit * 4 + model: detailsSheet.screenshotsModel + + delegate: Item { + width: screenshotsGrid.cellWidth + height: screenshotsGrid.cellHeight + + Kirigami.ShadowedRectangle { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + radius: Kirigami.Units.smallSpacing + color: Kirigami.Theme.alternateBackgroundColor + clip: true + + Image { + anchors.fill: parent + source: url + asynchronous: true + fillMode: Image.PreserveAspectCrop + smooth: true + mipmap: App.config.highQualityImages + } + + MouseArea { + anchors.fill: parent + onClicked: Qt.openUrlExternally(url) + } + } + } + } + } + + FileDialog { + id: screenshotImportDialog + title: i18n("Import Screenshot") + fileMode: FileDialog.OpenFile + nameFilters: [i18n("Image files (*.png *.jpg *.jpeg *.webp)"), i18n("All files (*)")] + onAccepted: { + if (detailsSheet.game) { + App.mediaManager.importScreenshot(detailsSheet.game, selectedFile) + } + } } function getPlatformColor(platform) { diff --git a/src/screenshotmodel.cpp b/src/screenshotmodel.cpp new file mode 100644 index 0000000..10b75a3 --- /dev/null +++ b/src/screenshotmodel.cpp @@ -0,0 +1,135 @@ +#include "screenshotmodel.h" + +#include +#include +#include + +ScreenshotModel::ScreenshotModel(QObject *parent) + : QAbstractListModel(parent) +{ + connect(&m_watcher, &QFutureWatcher>::finished, this, [this]() { + const int token = m_watcher.property("refreshToken").toInt(); + if (token != m_refreshToken) { + return; + } + setEntries(m_watcher.result()); + }); +} + +ScreenshotModel::~ScreenshotModel() +{ + m_refreshToken++; + m_watcher.cancel(); + m_watcher.waitForFinished(); +} + +QString ScreenshotModel::directoryPath() const +{ + return m_directoryPath; +} + +void ScreenshotModel::setDirectoryPath(const QString &path) +{ + if (m_directoryPath == path) { + return; + } + + m_directoryPath = path; + Q_EMIT directoryPathChanged(); + refresh(); +} + +int ScreenshotModel::count() const +{ + return rowCount(); +} + +int ScreenshotModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_entries.size(); +} + +QVariant ScreenshotModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) { + return {}; + } + + const ScreenshotEntry &e = m_entries.at(index.row()); + switch (role) { + case UrlRole: + return QUrl::fromLocalFile(e.filePath); + case FileNameRole: + return e.fileName; + case CreatedRole: + return e.created; + case SizeRole: + return e.size; + default: + return {}; + } +} + +QHash ScreenshotModel::roleNames() const +{ + QHash roles; + roles[UrlRole] = "url"; + roles[FileNameRole] = "fileName"; + roles[CreatedRole] = "created"; + roles[SizeRole] = "size"; + return roles; +} + +void ScreenshotModel::refresh() +{ + const QString path = m_directoryPath; + const int token = ++m_refreshToken; + + m_watcher.setProperty("refreshToken", token); + + auto future = QtConcurrent::run([path]() -> QVector { + QVector result; + if (path.isEmpty()) { + return result; + } + + QDir dir(path); + if (!dir.exists()) { + return result; + } + + const QStringList nameFilters = { + QStringLiteral("*.png"), + QStringLiteral("*.jpg"), + QStringLiteral("*.jpeg"), + QStringLiteral("*.webp"), + }; + + const QFileInfoList files = dir.entryInfoList(nameFilters, QDir::Files | QDir::Readable, QDir::Time | QDir::Reversed); + result.reserve(files.size()); + + for (const QFileInfo &fi : files) { + ScreenshotEntry e; + e.filePath = fi.absoluteFilePath(); + e.fileName = fi.fileName(); + e.created = fi.lastModified(); + e.size = fi.size(); + result.push_back(std::move(e)); + } + + return result; + }); + + m_watcher.setFuture(future); +} + +void ScreenshotModel::setEntries(QVector entries) +{ + beginResetModel(); + m_entries = std::move(entries); + endResetModel(); + Q_EMIT countChanged(); +} diff --git a/src/screenshotmodel.h b/src/screenshotmodel.h new file mode 100644 index 0000000..7c4e4d7 --- /dev/null +++ b/src/screenshotmodel.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class ScreenshotModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(QString directoryPath READ directoryPath WRITE setDirectoryPath NOTIFY directoryPathChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + enum Roles { + UrlRole = Qt::UserRole + 1, + FileNameRole, + CreatedRole, + SizeRole, + }; + + explicit ScreenshotModel(QObject *parent = nullptr); + ~ScreenshotModel() override; + + QString directoryPath() const; + void setDirectoryPath(const QString &path); + + int count() const; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + QHash roleNames() const override; + + Q_INVOKABLE void refresh(); + +Q_SIGNALS: + void directoryPathChanged(); + void countChanged(); + +private: + struct ScreenshotEntry { + QString filePath; + QString fileName; + QDateTime created; + qint64 size = 0; + }; + + void setEntries(QVector entries); + + QString m_directoryPath; + QVector m_entries; + + QFutureWatcher> m_watcher; + int m_refreshToken = 0; +};