mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-04-29 15:03:09 +00:00
Track launch state in Game Center
Record recent launches only after the launcher reports success instead of optimistically on every attempt. For desktop entries, wait for the ApplicationLauncherJob result; for detached commands, use startDetached's return value and show an inline error when startup fails. Parse Steam libraryfolders and app manifests as KeyValues instead of matching individual lines. This makes Steam discovery less brittle when the files contain comments, nested blocks, or different formatting. Keep Continue Playing visible alongside Running and show a temporary launching state so the overlay does not pretend a task already exists. Rename the GameMode status text to say what the shell actually knows.
This commit is contained in:
parent
d913c2ab0a
commit
4dd0e17afb
5 changed files with 419 additions and 49 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
#include <KConfigGroup>
|
#include <KConfigGroup>
|
||||||
#include <KIO/ApplicationLauncherJob>
|
#include <KIO/ApplicationLauncherJob>
|
||||||
|
#include <KJob>
|
||||||
#include <KService>
|
#include <KService>
|
||||||
#include <KSharedConfig>
|
#include <KSharedConfig>
|
||||||
#include <KShell>
|
#include <KShell>
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QHash>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
|
@ -23,13 +25,203 @@
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
|
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct VdfNode {
|
||||||
|
QHash<QString, QString> values;
|
||||||
|
QHash<QString, std::shared_ptr<VdfNode>> children;
|
||||||
|
};
|
||||||
|
|
||||||
|
class VdfTokenizer
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
enum class TokenType {
|
||||||
|
End,
|
||||||
|
String,
|
||||||
|
OpenBrace,
|
||||||
|
CloseBrace,
|
||||||
|
Invalid,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Token {
|
||||||
|
TokenType type = TokenType::End;
|
||||||
|
QString text;
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit VdfTokenizer(QStringView input)
|
||||||
|
: m_input(input)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Token next()
|
||||||
|
{
|
||||||
|
skipWhitespaceAndComments();
|
||||||
|
|
||||||
|
if (m_pos >= m_input.size()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QChar current = m_input.at(m_pos);
|
||||||
|
if (current == QLatin1Char('{')) {
|
||||||
|
++m_pos;
|
||||||
|
return {TokenType::OpenBrace, {}};
|
||||||
|
}
|
||||||
|
if (current == QLatin1Char('}')) {
|
||||||
|
++m_pos;
|
||||||
|
return {TokenType::CloseBrace, {}};
|
||||||
|
}
|
||||||
|
if (current == QLatin1Char('"')) {
|
||||||
|
return {TokenType::String, readQuotedString()};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {TokenType::String, readBareString()};
|
||||||
|
}
|
||||||
|
|
||||||
|
int position() const
|
||||||
|
{
|
||||||
|
return m_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void skipWhitespaceAndComments()
|
||||||
|
{
|
||||||
|
while (m_pos < m_input.size()) {
|
||||||
|
const QChar current = m_input.at(m_pos);
|
||||||
|
if (current.isSpace()) {
|
||||||
|
++m_pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) {
|
||||||
|
m_pos += 2;
|
||||||
|
while (m_pos < m_input.size() && m_input.at(m_pos) != QLatin1Char('\n')) {
|
||||||
|
++m_pos;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString readQuotedString()
|
||||||
|
{
|
||||||
|
QString result;
|
||||||
|
++m_pos;
|
||||||
|
|
||||||
|
while (m_pos < m_input.size()) {
|
||||||
|
const QChar current = m_input.at(m_pos++);
|
||||||
|
if (current == QLatin1Char('"')) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (current == QLatin1Char('\\') && m_pos < m_input.size()) {
|
||||||
|
const QChar escaped = m_input.at(m_pos++);
|
||||||
|
switch (escaped.unicode()) {
|
||||||
|
case 'n':
|
||||||
|
result.append(QLatin1Char('\n'));
|
||||||
|
break;
|
||||||
|
case 't':
|
||||||
|
result.append(QLatin1Char('\t'));
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
result.append(QLatin1Char('\r'));
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
case '"':
|
||||||
|
result.append(escaped);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result.append(escaped);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.append(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString readBareString()
|
||||||
|
{
|
||||||
|
const int start = m_pos;
|
||||||
|
while (m_pos < m_input.size()) {
|
||||||
|
const QChar current = m_input.at(m_pos);
|
||||||
|
if (current.isSpace() || current == QLatin1Char('{') || current == QLatin1Char('}') || current == QLatin1Char('"')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++m_pos;
|
||||||
|
}
|
||||||
|
return m_input.sliced(start, m_pos - start).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringView m_input;
|
||||||
|
int m_pos = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool parseVdf(const QString &input, VdfNode &root, QString *error)
|
||||||
|
{
|
||||||
|
VdfTokenizer tokenizer(input);
|
||||||
|
QList<VdfNode *> stack = {&root};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const auto key = tokenizer.next();
|
||||||
|
if (key.type == VdfTokenizer::TokenType::End) {
|
||||||
|
if (stack.size() != 1 && error) {
|
||||||
|
*error = QStringLiteral("unexpected end of file");
|
||||||
|
}
|
||||||
|
return stack.size() == 1;
|
||||||
|
}
|
||||||
|
if (key.type == VdfTokenizer::TokenType::CloseBrace) {
|
||||||
|
if (stack.size() == 1) {
|
||||||
|
if (error) {
|
||||||
|
*error = QStringLiteral("unexpected closing brace at position %1").arg(tokenizer.position());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
stack.removeLast();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key.type != VdfTokenizer::TokenType::String || key.text.isEmpty()) {
|
||||||
|
if (error) {
|
||||||
|
*error = QStringLiteral("invalid key at position %1").arg(tokenizer.position());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto value = tokenizer.next();
|
||||||
|
if (value.type == VdfTokenizer::TokenType::String) {
|
||||||
|
stack.last()->values.insert(key.text, value.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value.type == VdfTokenizer::TokenType::OpenBrace) {
|
||||||
|
auto child = std::make_shared<VdfNode>();
|
||||||
|
stack.last()->children.insert(key.text, child);
|
||||||
|
stack.append(child.get());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
*error = QStringLiteral("expected value for key '%1'").arg(key.text);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
GameLauncherProvider::GameLauncherProvider(QObject *parent)
|
GameLauncherProvider::GameLauncherProvider(QObject *parent)
|
||||||
: QAbstractListModel(parent)
|
: QAbstractListModel(parent)
|
||||||
, m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc")))
|
, m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc")))
|
||||||
{
|
{
|
||||||
connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh);
|
connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh);
|
||||||
|
m_pendingLaunchTimer.setInterval(15000);
|
||||||
|
m_pendingLaunchTimer.setSingleShot(true);
|
||||||
|
connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch);
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +278,21 @@ bool GameLauncherProvider::loading() const
|
||||||
return m_loading;
|
return m_loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool GameLauncherProvider::launchPending() const
|
||||||
|
{
|
||||||
|
return m_launchPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString GameLauncherProvider::pendingLaunchName() const
|
||||||
|
{
|
||||||
|
return m_pendingLaunchName;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString GameLauncherProvider::lastLaunchError() const
|
||||||
|
{
|
||||||
|
return m_lastLaunchError;
|
||||||
|
}
|
||||||
|
|
||||||
void GameLauncherProvider::refresh()
|
void GameLauncherProvider::refresh()
|
||||||
{
|
{
|
||||||
m_loading = true;
|
m_loading = true;
|
||||||
|
|
@ -142,27 +349,45 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId)
|
||||||
|
|
||||||
void GameLauncherProvider::launchEntry(GameEntry &entry)
|
void GameLauncherProvider::launchEntry(GameEntry &entry)
|
||||||
{
|
{
|
||||||
|
clearLastLaunchError();
|
||||||
|
|
||||||
if (entry.source == QLatin1String("desktop")) {
|
if (entry.source == QLatin1String("desktop")) {
|
||||||
auto service = KService::serviceByStorageId(entry.storageId);
|
auto service = KService::serviceByStorageId(entry.storageId);
|
||||||
if (service) {
|
if (!service) {
|
||||||
auto *job = new KIO::ApplicationLauncherJob(service);
|
markLaunchFailed(entry.name, QStringLiteral("Desktop entry is no longer available"));
|
||||||
job->start();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto *job = new KIO::ApplicationLauncherJob(service);
|
||||||
|
connect(job, &KJob::result, this, [this, job, storageId = entry.storageId, name = entry.name]() {
|
||||||
|
if (job->error() != 0) {
|
||||||
|
markLaunchFailed(name, job->errorString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markLaunchSucceeded(storageId, name);
|
||||||
|
});
|
||||||
|
job->start();
|
||||||
} else if (entry.launchCommand.contains(QStringLiteral("://"))) {
|
} else if (entry.launchCommand.contains(QStringLiteral("://"))) {
|
||||||
// Protocol handler (e.g. heroic://launch/...) — open via xdg-open
|
// Protocol handler (e.g. heroic://launch/...) — open via xdg-open
|
||||||
QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand});
|
if (!QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand})) {
|
||||||
|
markLaunchFailed(entry.name, QStringLiteral("Unable to start xdg-open"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markLaunchSucceeded(entry.storageId, entry.name);
|
||||||
} else {
|
} else {
|
||||||
QStringList parts = KShell::splitArgs(entry.launchCommand);
|
QStringList parts = KShell::splitArgs(entry.launchCommand);
|
||||||
if (!parts.isEmpty()) {
|
if (parts.isEmpty()) {
|
||||||
QString program = parts.takeFirst();
|
markLaunchFailed(entry.name, QStringLiteral("Launch command is empty"));
|
||||||
QProcess::startDetached(program, parts);
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Q_EMIT gameLaunched(entry.name);
|
QString program = parts.takeFirst();
|
||||||
const auto now = QDateTime::currentDateTime();
|
if (!QProcess::startDetached(program, parts)) {
|
||||||
saveRecentTimestamp(entry.storageId, now);
|
markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program));
|
||||||
entry.lastPlayed = now;
|
return;
|
||||||
|
}
|
||||||
|
markLaunchSucceeded(entry.storageId, entry.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameLauncherProvider::deduplicateGames()
|
void GameLauncherProvider::deduplicateGames()
|
||||||
|
|
@ -237,21 +462,30 @@ void GameLauncherProvider::loadSteamGames()
|
||||||
if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Simple parse: look for "path" lines
|
const QString content = QString::fromUtf8(vdf.readAll());
|
||||||
static const QRegularExpression pathRe(QStringLiteral("\"path\"\\s+\"([^\"]+)\""));
|
VdfNode document;
|
||||||
QTextStream stream(&vdf);
|
QString error;
|
||||||
while (!stream.atEnd()) {
|
if (!parseVdf(content, document, &error)) {
|
||||||
const QString line = stream.readLine();
|
qWarning() << "GameLauncherProvider: cannot parse Steam libraryfolders" << vdfPath << error;
|
||||||
auto match = pathRe.match(line);
|
continue;
|
||||||
if (match.hasMatch()) {
|
}
|
||||||
libraryPaths.append(match.captured(1));
|
|
||||||
|
const VdfNode *libraries = nullptr;
|
||||||
|
if (document.children.contains(QStringLiteral("libraryfolders"))) {
|
||||||
|
libraries = document.children.value(QStringLiteral("libraryfolders")).get();
|
||||||
|
} else {
|
||||||
|
libraries = &document;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto it = libraries->children.cbegin(); it != libraries->children.cend(); ++it) {
|
||||||
|
const QString path = it.value()->values.value(QStringLiteral("path"));
|
||||||
|
if (!path.isEmpty()) {
|
||||||
|
libraryPaths.append(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan each library path for appmanifest_*.acf
|
libraryPaths.removeDuplicates();
|
||||||
static const QRegularExpression nameRe(QStringLiteral("\"name\"\\s+\"([^\"]+)\""));
|
|
||||||
static const QRegularExpression appidRe(QStringLiteral("\"appid\"\\s+\"(\\d+)\""));
|
|
||||||
|
|
||||||
for (const auto &libPath : std::as_const(libraryPaths)) {
|
for (const auto &libPath : std::as_const(libraryPaths)) {
|
||||||
QDir steamapps(libPath + QStringLiteral("/steamapps"));
|
QDir steamapps(libPath + QStringLiteral("/steamapps"));
|
||||||
|
|
@ -264,28 +498,19 @@ void GameLauncherProvider::loadSteamGames()
|
||||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
QString appName;
|
VdfNode manifestData;
|
||||||
QString appId;
|
QString error;
|
||||||
QTextStream ts(&f);
|
if (!parseVdf(QString::fromUtf8(f.readAll()), manifestData, &error)) {
|
||||||
while (!ts.atEnd()) {
|
qWarning() << "GameLauncherProvider: cannot parse Steam manifest" << manifest << error;
|
||||||
const QString line = ts.readLine();
|
continue;
|
||||||
if (appName.isEmpty()) {
|
|
||||||
auto m = nameRe.match(line);
|
|
||||||
if (m.hasMatch()) {
|
|
||||||
appName = m.captured(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (appId.isEmpty()) {
|
|
||||||
auto m = appidRe.match(line);
|
|
||||||
if (m.hasMatch()) {
|
|
||||||
appId = m.captured(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!appName.isEmpty() && !appId.isEmpty()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VdfNode *appState =
|
||||||
|
manifestData.children.contains(QStringLiteral("AppState")) ? manifestData.children.value(QStringLiteral("AppState")).get() : &manifestData;
|
||||||
|
|
||||||
|
const QString appName = appState->values.value(QStringLiteral("name"));
|
||||||
|
const QString appId = appState->values.value(QStringLiteral("appid"));
|
||||||
|
|
||||||
if (appName.isEmpty() || appId.isEmpty()) {
|
if (appName.isEmpty() || appId.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -593,6 +818,76 @@ void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const Q
|
||||||
group.sync();
|
group.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameLauncherProvider::clearPendingLaunch()
|
||||||
|
{
|
||||||
|
if (!m_launchPending && m_pendingLaunchName.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_pendingLaunchTimer.stop();
|
||||||
|
m_launchPending = false;
|
||||||
|
m_pendingLaunchName.clear();
|
||||||
|
Q_EMIT launchPendingChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncherProvider::clearLastLaunchError()
|
||||||
|
{
|
||||||
|
if (m_lastLaunchError.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_lastLaunchError.clear();
|
||||||
|
Q_EMIT lastLaunchErrorChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
GameLauncherProvider::GameEntry *GameLauncherProvider::findEntryByStorageId(const QString &storageId)
|
||||||
|
{
|
||||||
|
for (auto &entry : m_allGames) {
|
||||||
|
if (entry.storageId == storageId) {
|
||||||
|
return &entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const QString &name)
|
||||||
|
{
|
||||||
|
if (auto *entry = findEntryByStorageId(storageId)) {
|
||||||
|
const auto now = QDateTime::currentDateTime();
|
||||||
|
saveRecentTimestamp(entry->storageId, now);
|
||||||
|
entry->lastPlayed = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingLaunch(name);
|
||||||
|
Q_EMIT gameLaunched(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncherProvider::markLaunchFailed(const QString &name, const QString &error)
|
||||||
|
{
|
||||||
|
clearPendingLaunch();
|
||||||
|
|
||||||
|
const QString message = error.isEmpty() ? tr("Unable to launch %1").arg(name) : tr("Unable to launch %1: %2").arg(name, error);
|
||||||
|
|
||||||
|
if (m_lastLaunchError != message) {
|
||||||
|
m_lastLaunchError = message;
|
||||||
|
Q_EMIT lastLaunchErrorChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
Q_EMIT gameLaunchFailed(name, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncherProvider::setPendingLaunch(const QString &name)
|
||||||
|
{
|
||||||
|
const bool changed = !m_launchPending || m_pendingLaunchName != name;
|
||||||
|
m_launchPending = true;
|
||||||
|
m_pendingLaunchName = name;
|
||||||
|
m_pendingLaunchTimer.start();
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
Q_EMIT launchPendingChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QVariantList GameLauncherProvider::recentGames(int limit) const
|
QVariantList GameLauncherProvider::recentGames(int limit) const
|
||||||
{
|
{
|
||||||
// Gather entries that have been launched at least once
|
// Gather entries that have been launched at least once
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QTimer>
|
||||||
#include <qqmlregistration.h>
|
#include <qqmlregistration.h>
|
||||||
|
|
||||||
#include <KSharedConfig>
|
#include <KSharedConfig>
|
||||||
|
|
@ -22,6 +23,9 @@ class GameLauncherProvider : public QAbstractListModel
|
||||||
Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged)
|
Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged)
|
||||||
Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged)
|
Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged)
|
||||||
Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged)
|
Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged)
|
||||||
|
Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged)
|
||||||
|
Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged)
|
||||||
|
Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit GameLauncherProvider(QObject *parent = nullptr);
|
explicit GameLauncherProvider(QObject *parent = nullptr);
|
||||||
|
|
@ -49,11 +53,16 @@ public:
|
||||||
void setSourceFilter(const QString &source);
|
void setSourceFilter(const QString &source);
|
||||||
bool overlayEnabled() const;
|
bool overlayEnabled() const;
|
||||||
void setOverlayEnabled(bool enabled);
|
void setOverlayEnabled(bool enabled);
|
||||||
|
bool launchPending() const;
|
||||||
|
QString pendingLaunchName() const;
|
||||||
|
QString lastLaunchError() const;
|
||||||
|
|
||||||
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 void launchByStorageId(const QString &storageId);
|
||||||
Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
|
Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
|
||||||
|
Q_INVOKABLE void clearPendingLaunch();
|
||||||
|
Q_INVOKABLE void clearLastLaunchError();
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void countChanged();
|
void countChanged();
|
||||||
|
|
@ -61,7 +70,10 @@ Q_SIGNALS:
|
||||||
void filterStringChanged();
|
void filterStringChanged();
|
||||||
void sourceFilterChanged();
|
void sourceFilterChanged();
|
||||||
void overlayEnabledChanged();
|
void overlayEnabledChanged();
|
||||||
|
void launchPendingChanged();
|
||||||
|
void lastLaunchErrorChanged();
|
||||||
void gameLaunched(const QString &name);
|
void gameLaunched(const QString &name);
|
||||||
|
void gameLaunchFailed(const QString &name, const QString &error);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct GameEntry {
|
struct GameEntry {
|
||||||
|
|
@ -85,6 +97,10 @@ private:
|
||||||
void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
|
void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
|
||||||
void applyFilter();
|
void applyFilter();
|
||||||
void launchEntry(GameEntry &entry);
|
void launchEntry(GameEntry &entry);
|
||||||
|
GameEntry *findEntryByStorageId(const QString &storageId);
|
||||||
|
void markLaunchSucceeded(const QString &storageId, const QString &name);
|
||||||
|
void markLaunchFailed(const QString &name, const QString &error);
|
||||||
|
void setPendingLaunch(const QString &name);
|
||||||
|
|
||||||
QList<GameEntry> m_allGames;
|
QList<GameEntry> m_allGames;
|
||||||
QList<GameEntry> m_games; // filtered view
|
QList<GameEntry> m_games; // filtered view
|
||||||
|
|
@ -93,4 +109,8 @@ private:
|
||||||
KSharedConfigPtr m_config;
|
KSharedConfigPtr m_config;
|
||||||
bool m_loading = false;
|
bool m_loading = false;
|
||||||
bool m_overlayEnabled = false;
|
bool m_overlayEnabled = false;
|
||||||
|
bool m_launchPending = false;
|
||||||
|
QString m_pendingLaunchName;
|
||||||
|
QString m_lastLaunchError;
|
||||||
|
QTimer m_pendingLaunchTimer;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ Window {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
required property var folio
|
required property var folio
|
||||||
|
property int launchTaskBaseline: 0
|
||||||
|
|
||||||
signal gameStarted()
|
signal gameStarted()
|
||||||
signal dismissRequested()
|
signal dismissRequested()
|
||||||
|
|
@ -51,14 +52,14 @@ Window {
|
||||||
|
|
||||||
function launchGame(index) {
|
function launchGame(index) {
|
||||||
pulsePrimaryGamepad(14000, 22000, 80)
|
pulsePrimaryGamepad(14000, 22000, 80)
|
||||||
|
launchTaskBaseline = runningGames.taskCount
|
||||||
GamingShell.GameLauncherProvider.launch(index)
|
GamingShell.GameLauncherProvider.launch(index)
|
||||||
launchFade.restart()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchGameByStorageId(storageId) {
|
function launchGameByStorageId(storageId) {
|
||||||
pulsePrimaryGamepad(14000, 22000, 80)
|
pulsePrimaryGamepad(14000, 22000, 80)
|
||||||
|
launchTaskBaseline = runningGames.taskCount
|
||||||
GamingShell.GameLauncherProvider.launchByStorageId(storageId)
|
GamingShell.GameLauncherProvider.launchByStorageId(storageId)
|
||||||
launchFade.restart()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
width: Screen.width
|
width: Screen.width
|
||||||
|
|
@ -79,6 +80,25 @@ Window {
|
||||||
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
|
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamingShell.GameLauncherProvider
|
||||||
|
|
||||||
|
function onGameLaunched(name) {
|
||||||
|
launchFade.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGameLaunchFailed(name, error) {
|
||||||
|
launchErrorTimer.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: launchErrorTimer
|
||||||
|
interval: 6000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: GamingShell.GameLauncherProvider.clearLastLaunchError()
|
||||||
|
}
|
||||||
|
|
||||||
onVisibleChanged: {
|
onVisibleChanged: {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
GamingShell.GameLauncherProvider.filterString = ""
|
GamingShell.GameLauncherProvider.filterString = ""
|
||||||
|
|
@ -376,15 +396,49 @@ Window {
|
||||||
RunningGamesView {
|
RunningGamesView {
|
||||||
id: runningGames
|
id: runningGames
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
onTaskActivated: root.gameStarted()
|
onTaskActivated: {
|
||||||
|
GamingShell.GameLauncherProvider.clearPendingLaunch()
|
||||||
|
root.gameStarted()
|
||||||
|
}
|
||||||
onMoveDownRequested: grid.forceActiveFocus()
|
onMoveDownRequested: grid.forceActiveFocus()
|
||||||
|
onTaskCountChanged: {
|
||||||
|
if (GamingShell.GameLauncherProvider.launchPending
|
||||||
|
&& taskCount > root.launchTaskBaseline) {
|
||||||
|
GamingShell.GameLauncherProvider.clearPendingLaunch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.InlineMessage {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
type: Kirigami.MessageType.Error
|
||||||
|
text: GamingShell.GameLauncherProvider.lastLaunchError
|
||||||
|
visible: text.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
visible: GamingShell.GameLauncherProvider.launchPending
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
implicitWidth: Kirigami.Units.iconSizes.small
|
||||||
|
implicitHeight: Kirigami.Units.iconSizes.small
|
||||||
|
source: "system-run"
|
||||||
|
}
|
||||||
|
|
||||||
|
PC3.Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: i18n("Launching %1…", GamingShell.GameLauncherProvider.pendingLaunchName)
|
||||||
|
opacity: 0.75
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- continue playing ----
|
// ---- continue playing ----
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: Kirigami.Units.smallSpacing
|
spacing: Kirigami.Units.smallSpacing
|
||||||
visible: recentList.count > 0 && !runningGames.hasTasks
|
visible: recentList.count > 0
|
||||||
|
|
||||||
Kirigami.Heading {
|
Kirigami.Heading {
|
||||||
level: 2
|
level: 2
|
||||||
|
|
|
||||||
|
|
@ -520,8 +520,8 @@ Item {
|
||||||
}
|
}
|
||||||
PC3.Label {
|
PC3.Label {
|
||||||
text: GamingShell.GameModeControl.active
|
text: GamingShell.GameModeControl.active
|
||||||
? i18n("GameMode active")
|
? i18n("GameMode requested")
|
||||||
: i18n("GameMode standby")
|
: i18n("GameMode not requested")
|
||||||
opacity: 0.7
|
opacity: 0.7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ Item {
|
||||||
|
|
||||||
implicitHeight: taskList.count > 0 ? column.implicitHeight : 0
|
implicitHeight: taskList.count > 0 ? column.implicitHeight : 0
|
||||||
readonly property bool hasTasks: taskList.count > 0
|
readonly property bool hasTasks: taskList.count > 0
|
||||||
|
readonly property int taskCount: taskList.count
|
||||||
|
|
||||||
signal taskActivated()
|
signal taskActivated()
|
||||||
signal moveDownRequested()
|
signal moveDownRequested()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue