Media: add per-game screenshots manager and gallery

This commit is contained in:
Marco Allegretti 2026-01-25 14:30:02 +01:00
parent 27f5a31561
commit 3ec2badcd7
8 changed files with 483 additions and 0 deletions

View file

@ -8,6 +8,8 @@ add_executable(alakarte
gamesortfiltermodel.cpp
game.cpp
gamepadmanager.cpp
mediamanager.cpp
screenshotmodel.cpp
platformimporter.cpp
steamimporter.cpp
lutrisimporter.cpp

View file

@ -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;

View file

@ -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;

145
src/mediamanager.cpp Normal file
View file

@ -0,0 +1,145 @@
#include "mediamanager.h"
#include "game.h"
#include "screenshotmodel.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
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;
}

41
src/mediamanager.h Normal file
View file

@ -0,0 +1,41 @@
#pragma once
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QQmlEngine>
#include <QUrl>
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<QString, QPointer<ScreenshotModel>> m_models;
};

View file

@ -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) {

135
src/screenshotmodel.cpp Normal file
View file

@ -0,0 +1,135 @@
#include "screenshotmodel.h"
#include <QDir>
#include <QFileInfo>
#include <QtConcurrent>
ScreenshotModel::ScreenshotModel(QObject *parent)
: QAbstractListModel(parent)
{
connect(&m_watcher, &QFutureWatcher<QVector<ScreenshotEntry>>::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<int, QByteArray> ScreenshotModel::roleNames() const
{
QHash<int, QByteArray> 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<ScreenshotEntry> {
QVector<ScreenshotEntry> 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<ScreenshotEntry> entries)
{
beginResetModel();
m_entries = std::move(entries);
endResetModel();
Q_EMIT countChanged();
}

59
src/screenshotmodel.h Normal file
View file

@ -0,0 +1,59 @@
#pragma once
#include <QAbstractListModel>
#include <QDateTime>
#include <QFutureWatcher>
#include <QQmlEngine>
#include <QUrl>
#include <QVector>
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<int, QByteArray> 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<ScreenshotEntry> entries);
QString m_directoryPath;
QVector<ScreenshotEntry> m_entries;
QFutureWatcher<QVector<ScreenshotEntry>> m_watcher;
int m_refreshToken = 0;
};