mirror of
https://invent.kde.org/marcoa/a-la-karte.git
synced 2026-02-09 13:03:09 +00:00
alakarte: Initial import
Initial release of A-La-Karte, a unified game launcher for KDE Plasma. Includes the QML UI, platform importers, AppStream metadata, icons, and developer documentation.
This commit is contained in:
commit
747b02035a
70 changed files with 9739 additions and 0 deletions
95
.clang-format
Normal file
95
.clang-format
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
---
|
||||||
|
# SPDX-FileCopyrightText: 2019 Christoph Cullmann <cullmann@kde.org>
|
||||||
|
# SPDX-FileCopyrightText: 2019 Gernot Gebhard <gebhard@absint.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
# This file got automatically created by ECM, do not edit
|
||||||
|
# See https://clang.llvm.org/docs/ClangFormatStyleOptions.html for the config options
|
||||||
|
# and https://community.kde.org/Policies/Frameworks_Coding_Style#Clang-format_automatic_code_formatting
|
||||||
|
# for clang-format tips & tricks
|
||||||
|
---
|
||||||
|
Language: JavaScript
|
||||||
|
DisableFormat: true
|
||||||
|
---
|
||||||
|
Language: Json
|
||||||
|
DisableFormat: false
|
||||||
|
IndentWidth: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Style for C++
|
||||||
|
Language: Cpp
|
||||||
|
|
||||||
|
# base is WebKit coding style: https://webkit.org/code-style-guidelines/
|
||||||
|
# below are only things set that diverge from this style!
|
||||||
|
BasedOnStyle: WebKit
|
||||||
|
|
||||||
|
# enforce C++11 (e.g. for std::vector<std::vector<lala>>
|
||||||
|
Standard: Cpp11
|
||||||
|
|
||||||
|
# 4 spaces indent
|
||||||
|
TabWidth: 4
|
||||||
|
|
||||||
|
# 2 * 80 wide lines
|
||||||
|
ColumnLimit: 160
|
||||||
|
|
||||||
|
# sort includes inside line separated groups
|
||||||
|
SortIncludes: true
|
||||||
|
|
||||||
|
# break before braces on function, namespace and class definitions.
|
||||||
|
BreakBeforeBraces: Linux
|
||||||
|
|
||||||
|
# CrlInstruction *a;
|
||||||
|
PointerAlignment: Right
|
||||||
|
|
||||||
|
# horizontally aligns arguments after an open bracket.
|
||||||
|
AlignAfterOpenBracket: Align
|
||||||
|
|
||||||
|
# don't move all parameters to new line
|
||||||
|
AllowAllParametersOfDeclarationOnNextLine: false
|
||||||
|
|
||||||
|
# no single line functions
|
||||||
|
AllowShortFunctionsOnASingleLine: None
|
||||||
|
|
||||||
|
# no single line enums
|
||||||
|
AllowShortEnumsOnASingleLine: false
|
||||||
|
|
||||||
|
# always break before you encounter multi line strings
|
||||||
|
AlwaysBreakBeforeMultilineStrings: true
|
||||||
|
|
||||||
|
# don't move arguments to own lines if they are not all on the same
|
||||||
|
BinPackArguments: false
|
||||||
|
|
||||||
|
# don't move parameters to own lines if they are not all on the same
|
||||||
|
BinPackParameters: false
|
||||||
|
|
||||||
|
# In case we have an if statement with multiple lines the operator should be at the beginning of the line
|
||||||
|
# but we do not want to break assignments
|
||||||
|
BreakBeforeBinaryOperators: NonAssignment
|
||||||
|
|
||||||
|
# format C++11 braced lists like function calls
|
||||||
|
Cpp11BracedListStyle: true
|
||||||
|
|
||||||
|
# do not put a space before C++11 braced lists
|
||||||
|
SpaceBeforeCpp11BracedList: false
|
||||||
|
|
||||||
|
# remove empty lines
|
||||||
|
KeepEmptyLinesAtTheStartOfBlocks: false
|
||||||
|
|
||||||
|
# no namespace indentation to keep indent level low
|
||||||
|
NamespaceIndentation: None
|
||||||
|
|
||||||
|
# we use template< without space.
|
||||||
|
SpaceAfterTemplateKeyword: false
|
||||||
|
|
||||||
|
# Always break after template declaration
|
||||||
|
AlwaysBreakTemplateDeclarations: true
|
||||||
|
|
||||||
|
# macros for which the opening brace stays attached.
|
||||||
|
ForEachMacros: [ foreach, Q_FOREACH, BOOST_FOREACH, forever, Q_FOREVER, QBENCHMARK, QBENCHMARK_ONCE , wl_resource_for_each, wl_resource_for_each_safe ]
|
||||||
|
|
||||||
|
# keep lambda formatting multi-line if not empty
|
||||||
|
AllowShortLambdasOnASingleLine: Empty
|
||||||
|
|
||||||
|
# We do not want clang-format to put all arguments on a new line
|
||||||
|
AllowAllArgumentsOnNextLine: false
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/build/
|
||||||
|
/build-*/
|
||||||
|
/.cache/
|
||||||
|
/compile_commands.json
|
||||||
|
/CMakeUserPresets.json
|
||||||
|
/.clangd
|
||||||
|
/.vscode/
|
||||||
|
/.idea/
|
||||||
|
|
||||||
|
/FEATURE_GAP_ANALYSIS.md
|
||||||
23
.kde-builder.yaml
Normal file
23
.kde-builder.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# kde-builder configuration for A-La-Karte
|
||||||
|
# See https://kde-builder.kde.org/en/configuration/config-file-overview.html
|
||||||
|
|
||||||
|
global:
|
||||||
|
branch-group: kf6-qt6
|
||||||
|
install-dir: ~/kde/usr
|
||||||
|
source-dir: ~/kde/src
|
||||||
|
build-dir: ~/kde/build
|
||||||
|
log-dir: ~/kde/log
|
||||||
|
|
||||||
|
cmake-options:
|
||||||
|
- -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
- -DQT_MAJOR_VERSION=6
|
||||||
|
|
||||||
|
num-cores: auto
|
||||||
|
num-cores-low-mem: 2
|
||||||
|
|
||||||
|
install-after-build: true
|
||||||
|
|
||||||
|
project alakarte:
|
||||||
|
repository: .
|
||||||
|
cmake-options:
|
||||||
|
- -DCMAKE_INSTALL_PREFIX=${install-dir}
|
||||||
82
CMakeLists.txt
Normal file
82
CMakeLists.txt
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
project(alakarte VERSION 0.1.0)
|
||||||
|
|
||||||
|
set(PROJECT_DEP_VERSION "6.0.0")
|
||||||
|
set(QT_MIN_VERSION "6.6.0")
|
||||||
|
set(KF_MIN_VERSION "6.0.0")
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
find_package(ECM ${KF_MIN_VERSION} REQUIRED NO_MODULE)
|
||||||
|
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
|
||||||
|
|
||||||
|
include(KDEInstallDirs)
|
||||||
|
include(KDECMakeSettings)
|
||||||
|
include(KDECompilerSettings NO_POLICY_SCOPE)
|
||||||
|
include(KDEClangFormat)
|
||||||
|
include(KDEGitCommitHooks)
|
||||||
|
include(ECMSetupVersion)
|
||||||
|
include(ECMGenerateHeaders)
|
||||||
|
include(ECMPoQmTools)
|
||||||
|
include(ECMDeprecationSettings)
|
||||||
|
include(ECMQmlModule)
|
||||||
|
include(ECMAddAppIcon)
|
||||||
|
include(FeatureSummary)
|
||||||
|
|
||||||
|
ecm_setup_version(${PROJECT_VERSION}
|
||||||
|
VARIABLE_PREFIX ALAKARTE
|
||||||
|
VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/src/alakarte-version.h"
|
||||||
|
)
|
||||||
|
|
||||||
|
ecm_set_disabled_deprecation_versions(
|
||||||
|
QT 6.7
|
||||||
|
KF 6.4
|
||||||
|
)
|
||||||
|
|
||||||
|
find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS
|
||||||
|
Core
|
||||||
|
Quick
|
||||||
|
QuickControls2
|
||||||
|
Gui
|
||||||
|
Network
|
||||||
|
Concurrent
|
||||||
|
DBus
|
||||||
|
Svg
|
||||||
|
Sql
|
||||||
|
)
|
||||||
|
|
||||||
|
find_package(Qt6 ${QT_MIN_VERSION} QUIET COMPONENTS Gamepad)
|
||||||
|
|
||||||
|
find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS
|
||||||
|
Kirigami
|
||||||
|
I18n
|
||||||
|
CoreAddons
|
||||||
|
Config
|
||||||
|
Crash
|
||||||
|
DBusAddons
|
||||||
|
WindowSystem
|
||||||
|
)
|
||||||
|
|
||||||
|
find_package(KF6KirigamiAddons 1.0.0 REQUIRED)
|
||||||
|
|
||||||
|
qt_policy(SET QTP0001 NEW)
|
||||||
|
|
||||||
|
add_subdirectory(src)
|
||||||
|
add_subdirectory(src/krunner)
|
||||||
|
add_subdirectory(icons)
|
||||||
|
|
||||||
|
ki18n_install(po)
|
||||||
|
|
||||||
|
install(FILES org.kde.alakarte.desktop DESTINATION ${KDE_INSTALL_APPDIR})
|
||||||
|
install(FILES org.kde.alakarte.metainfo.xml DESTINATION ${KDE_INSTALL_METAINFODIR})
|
||||||
|
|
||||||
|
file(GLOB_RECURSE ALL_CLANG_FORMAT_SOURCE_FILES src/*.cpp src/*.h)
|
||||||
|
kde_clang_format(${ALL_CLANG_FORMAT_SOURCE_FILES})
|
||||||
|
kde_configure_git_pre_commit_hook(CHECKS CLANG_FORMAT)
|
||||||
|
|
||||||
|
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
|
||||||
85
HACKING.md
Normal file
85
HACKING.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Hacking A-La-Karte
|
||||||
|
|
||||||
|
This document is intended for contributors who want to build, run, and debug A-La-Karte locally.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
### Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run from the build directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build/bin/alakarte
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Map
|
||||||
|
|
||||||
|
- `src/main.cpp`
|
||||||
|
- Application entry point, `KAboutData`, and QML engine setup.
|
||||||
|
- `src/app.*`
|
||||||
|
- Singleton exposed to QML. Owns `GameModel`, `GameLauncher`, `Config`, and `SteamGridDB`.
|
||||||
|
- `src/game.*`
|
||||||
|
- In-memory game object + JSON serialization.
|
||||||
|
- `src/gamemodel.*`, `src/gamesortfiltermodel.*`
|
||||||
|
- Model + filtering/sorting used by the UI.
|
||||||
|
- `src/*importer.*`
|
||||||
|
- Importers for different sources (Steam, Lutris, Heroic, Bottles, Desktop Entries, Flatpak, etc.).
|
||||||
|
- `src/gamelauncher.*`
|
||||||
|
- Launches games and records last played on launch.
|
||||||
|
- `src/qml/`
|
||||||
|
- Kirigami/Qt Quick UI.
|
||||||
|
|
||||||
|
## Repeatable Local Smoke Tests (No real library required)
|
||||||
|
|
||||||
|
### Run with isolated XDG dirs
|
||||||
|
|
||||||
|
This avoids touching your real config and library:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export XDG_CONFIG_HOME="$(mktemp -d)"
|
||||||
|
export XDG_DATA_HOME="$(mktemp -d)"
|
||||||
|
./build/bin/alakarte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desktop Entries importer fixture
|
||||||
|
|
||||||
|
You can create a temporary desktop entry under the isolated `$XDG_DATA_HOME`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p "$XDG_DATA_HOME/applications"
|
||||||
|
cat > "$XDG_DATA_HOME/applications/alakarte-test-game.desktop" <<'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=A-La-Karte Test Game
|
||||||
|
Exec=sh -lc 'sleep 1'
|
||||||
|
Icon=applications-games
|
||||||
|
Categories=Game;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
- Use the import sheet and run the Desktop Entries import.
|
||||||
|
- Verify the entry appears and launches.
|
||||||
|
- Verify `Last played` updates after launch.
|
||||||
|
|
||||||
|
### Manual entry / launcher smoke test
|
||||||
|
|
||||||
|
- Create a manual entry with a harmless command, e.g. `sh -lc 'sleep 2'`.
|
||||||
|
- Launch it.
|
||||||
|
- Confirm the app stays responsive and the game transitions out of the running state.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- SteamGridDB cover fetching requires an API key.
|
||||||
|
- KRunner integration and gamepad navigation are optional at build time (depending on available dependencies).
|
||||||
7
LICENSES/CC0-1.0.txt
Normal file
7
LICENSES/CC0-1.0.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
CC0 1.0 Universal
|
||||||
|
|
||||||
|
This project bundles a small set of brand icons sourced from the Simple Icons project:
|
||||||
|
https://github.com/simple-icons/simple-icons
|
||||||
|
|
||||||
|
Those icons are provided under CC0-1.0. The upstream license text can be found here:
|
||||||
|
https://raw.githubusercontent.com/simple-icons/simple-icons/develop/LICENSE.md
|
||||||
24
LICENSES/GPL-3.0-or-later.txt
Normal file
24
LICENSES/GPL-3.0-or-later.txt
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
This file contains the full text of the GNU General Public License v3.0.
|
||||||
|
For the complete license text, see: https://www.gnu.org/licenses/gpl-3.0.txt
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
228
README.md
Normal file
228
README.md
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
# A-La-Karte
|
||||||
|
|
||||||
|
[](https://develop.kde.org/frameworks/)
|
||||||
|
[](https://www.qt.io/)
|
||||||
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
|
|
||||||
|
A unified game launcher for KDE Plasma 6, inspired by [KDE Apps Initiative issue#26](https://invent.kde.org/teams/apps-initiative/tasks/-/issues/26).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Import games from Steam, Lutris, Heroic, Bottles, itch.io, Legendary, RetroArch playlists, Flatpak applications, and desktop entries
|
||||||
|
- Manual game entries with editable metadata (name, developer/publisher, executable, working directory)
|
||||||
|
- Grid and list views
|
||||||
|
- Search by name/developer/publisher and filter by source
|
||||||
|
- Favorites and hidden games views
|
||||||
|
- Last-played timestamp
|
||||||
|
- Optional SteamGridDB cover fetching (requires API key)
|
||||||
|
- Optional KRunner integration (if KF6Runner is available at build time)
|
||||||
|
- Optional gamepad navigation (if Qt Gamepad is available at build time)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Build Dependencies
|
||||||
|
|
||||||
|
- CMake >= 3.20
|
||||||
|
- Qt >= 6.6
|
||||||
|
- KDE Frameworks >= 6.0
|
||||||
|
- Kirigami
|
||||||
|
- KI18n
|
||||||
|
- KCoreAddons
|
||||||
|
- KConfig
|
||||||
|
- Extra CMake Modules (ECM)
|
||||||
|
|
||||||
|
### Runtime Dependencies
|
||||||
|
|
||||||
|
- Qt 6 (Core, Quick, QuickControls2, Gui, Network, Concurrent)
|
||||||
|
- Kirigami 6
|
||||||
|
- KDE Frameworks 6
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
- Steam (for Steam game import)
|
||||||
|
- Lutris (for Lutris game import)
|
||||||
|
- Heroic Games Launcher (for Epic/GOG/Amazon game import)
|
||||||
|
- Bottles (for Windows apps via Wine)
|
||||||
|
- itch.io app (for itch.io games)
|
||||||
|
- Legendary CLI (for Epic Games)
|
||||||
|
- RetroArch (for retro games via playlists)
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Using kde-builder (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kde-builder alakarte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -B build -S . -DCMAKE_INSTALL_PREFIX=/usr
|
||||||
|
cmake --build build
|
||||||
|
cmake --install build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
cmake --build build
|
||||||
|
./build/bin/alakarte
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Launch A-La-Karte from your application menu or run `alakarte`
|
||||||
|
2. If your library is empty, the import dialog opens automatically
|
||||||
|
3. Import games from your supported platforms
|
||||||
|
4. Your games will appear in the library grid
|
||||||
|
5. Select a game to see details, double-click or press Play to launch
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
- **Ctrl+F**: Focus search field
|
||||||
|
- **Ctrl+N**: Add new game
|
||||||
|
- **Ctrl+I**: Open import dialog
|
||||||
|
- **Ctrl+,**: Open settings
|
||||||
|
- **Ctrl+H**: Toggle hidden games view
|
||||||
|
- **Ctrl+D**: Show game details
|
||||||
|
- **F5**: Refresh/import all games
|
||||||
|
- **Escape**: Close dialog/sheet or clear search
|
||||||
|
- **Enter/Space**: Select/launch game in grid
|
||||||
|
|
||||||
|
### Gamepad Controls
|
||||||
|
|
||||||
|
- **D-pad / Left Stick**: Navigate grid
|
||||||
|
- **A Button**: Select/launch game
|
||||||
|
- **B Button**: Back/close dialog
|
||||||
|
- **Start**: Open settings
|
||||||
|
- **Y Button**: Focus search
|
||||||
|
|
||||||
|
## Supported Platforms
|
||||||
|
|
||||||
|
### Steam
|
||||||
|
|
||||||
|
A-La-Karte automatically detects Steam installations at:
|
||||||
|
- `~/.steam/steam`
|
||||||
|
- `~/.local/share/Steam`
|
||||||
|
- Flatpak: `~/.var/app/com.valvesoftware.Steam/.local/share/Steam`
|
||||||
|
|
||||||
|
All library folders are scanned for installed games.
|
||||||
|
|
||||||
|
### Lutris
|
||||||
|
|
||||||
|
Games are imported from:
|
||||||
|
- `~/.local/share/lutris`
|
||||||
|
- Flatpak: `~/.var/app/net.lutris.Lutris/data/lutris`
|
||||||
|
|
||||||
|
Uses the Lutris CLI when available for best results.
|
||||||
|
|
||||||
|
### Heroic Games Launcher
|
||||||
|
|
||||||
|
Supports Epic Games, GOG, and Amazon Games via Heroic:
|
||||||
|
- `~/.config/heroic`
|
||||||
|
- Flatpak: `~/.var/app/com.heroicgameslauncher.hgl/config/heroic`
|
||||||
|
|
||||||
|
### Bottles
|
||||||
|
|
||||||
|
Windows applications via Wine:
|
||||||
|
- `~/.local/share/bottles`
|
||||||
|
- Flatpak: `~/.var/app/com.usebottles.bottles/data/bottles`
|
||||||
|
|
||||||
|
### itch.io
|
||||||
|
|
||||||
|
Games installed via the itch.io app:
|
||||||
|
- `~/.config/itch/db/butler.db`
|
||||||
|
- Flatpak: `~/.var/app/io.itch.itch/config/itch/db/butler.db`
|
||||||
|
|
||||||
|
### Legendary
|
||||||
|
|
||||||
|
Epic Games via Legendary CLI:
|
||||||
|
- `~/.config/legendary`
|
||||||
|
|
||||||
|
### RetroArch
|
||||||
|
|
||||||
|
Retro games from RetroArch playlists:
|
||||||
|
- `~/.config/retroarch/playlists`
|
||||||
|
- Flatpak: `~/.var/app/org.libretro.RetroArch/config/retroarch/playlists`
|
||||||
|
|
||||||
|
### Desktop Entries
|
||||||
|
|
||||||
|
Games from XDG .desktop files in standard application directories.
|
||||||
|
|
||||||
|
### Flatpak Games
|
||||||
|
|
||||||
|
Games installed as Flatpak applications (detected by category).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings are stored in `~/.config/alakarterc` and include:
|
||||||
|
|
||||||
|
- View mode (Grid/List)
|
||||||
|
- Grid size
|
||||||
|
- Platform badge visibility
|
||||||
|
- Auto-import on startup
|
||||||
|
- Enabled import sources
|
||||||
|
|
||||||
|
Game library is stored in `~/.local/share/alakarte/library.json`.
|
||||||
|
|
||||||
|
## Code Overview
|
||||||
|
|
||||||
|
- **Application entry point**: `src/main.cpp`
|
||||||
|
- **Core singleton**: `src/app.*` exposes models/services to QML (game model, launcher, config, SteamGridDB)
|
||||||
|
- **Data model**:
|
||||||
|
- `src/game.*` stores per-game metadata
|
||||||
|
- `src/gamemodel.*` and `src/gamesortfiltermodel.*` provide sorting/filtering for the UI
|
||||||
|
- **Importers**: `src/*importer.*` (Steam/Lutris/Heroic/etc.)
|
||||||
|
- **Launcher**: `src/gamelauncher.*` launches processes/URLs and records last-played on launch
|
||||||
|
- **UI**: `src/qml/` (Kirigami + Qt Quick)
|
||||||
|
|
||||||
|
## Local Smoke Testing (Repeatable)
|
||||||
|
|
||||||
|
If you want to test without affecting your real configuration/library, run with isolated XDG directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export XDG_CONFIG_HOME="$(mktemp -d)"
|
||||||
|
export XDG_DATA_HOME="$(mktemp -d)"
|
||||||
|
./build/bin/alakarte
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested quick checks:
|
||||||
|
|
||||||
|
1. Add a manual entry with a harmless command (e.g. `true` or `sleep 2`) and launch it.
|
||||||
|
2. Confirm `Last played` updates after launch.
|
||||||
|
3. Toggle favorite/hidden and confirm filtering works.
|
||||||
|
4. Open the import sheet and run an import; confirm it completes and does not crash even if sources are missing.
|
||||||
|
5. (Optional) Create a temporary `.desktop` file under `$XDG_DATA_HOME/applications/` and verify the Desktop Entries importer detects it.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please see the [KDE development guidelines](https://community.kde.org/Get_Involved/development).
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Set up your development environment: https://develop.kde.org/docs/getting-started/building/kde-builder-setup/
|
||||||
|
2. Clone the repository
|
||||||
|
3. Build and test your changes
|
||||||
|
4. Submit a merge request
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
This project follows KDE coding conventions:
|
||||||
|
- https://community.kde.org/Policies/Frameworks_Coding_Style
|
||||||
|
- https://community.kde.org/Policies/Library_Code_Policy
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
A-La-Karte is licensed under the GNU General Public License v3.0 or later.
|
||||||
|
|
||||||
|
See [LICENSES/GPL-3.0-or-later.txt](LICENSES/GPL-3.0-or-later.txt) for the full license text.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [KDE API Documentation](https://api.kde.org/)
|
||||||
|
- [Kirigami Documentation](https://develop.kde.org/frameworks/kirigami/)
|
||||||
|
- [KDE Development Guidelines](https://community.kde.org/Get_Involved/development)
|
||||||
|
- [Project Repository](https://invent.kde.org/marcoa/a-la-karte)
|
||||||
|
- [Issue Tracker](https://invent.kde.org/marcoa/a-la-karte/-/issues)
|
||||||
9
icons/CMakeLists.txt
Normal file
9
icons/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
include(ECMInstallIcons)
|
||||||
|
|
||||||
|
ecm_install_icons(ICONS
|
||||||
|
sc-apps-org.kde.alakarte.svg
|
||||||
|
DESTINATION ${KDE_INSTALL_ICONDIR}
|
||||||
|
)
|
||||||
30
icons/sc-apps-org.kde.alakarte.svg
Normal file
30
icons/sc-apps-org.kde.alakarte.svg
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_d_1_26)">
|
||||||
|
<rect x="106" y="61" width="300" height="384" fill="#2AA0A8"/>
|
||||||
|
</g>
|
||||||
|
<rect x="133" y="148" width="245" height="29" rx="6" fill="#D9D9D9"/>
|
||||||
|
<rect x="133" y="237" width="94" height="39" rx="6" fill="#D9D9D9"/>
|
||||||
|
<rect x="133" y="87" width="245" height="39" rx="6" fill="#D9D9D9"/>
|
||||||
|
<rect x="133" y="298" width="245" height="32" rx="6" fill="#D9D9D9"/>
|
||||||
|
<path d="M310 358H202C193.163 358 186 365.163 186 374V402C186 410.837 193.163 418 202 418H310C318.837 418 326 410.837 326 402V374C326 365.163 318.837 358 310 358Z" stroke="#D9D9D9" stroke-width="4"/>
|
||||||
|
<path d="M216 372V404ZM200 388H232Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M216 372V404M200 388H232" stroke="#D9D9D9" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M286 383C288.761 383 291 380.761 291 378C291 375.239 288.761 373 286 373C283.239 373 281 375.239 281 378C281 380.761 283.239 383 286 383Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M306 383C308.761 383 311 380.761 311 378C311 375.239 308.761 373 306 373C303.239 373 301 375.239 301 378C301 380.761 303.239 383 306 383Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M296 403C298.761 403 301 400.761 301 398C301 395.239 298.761 393 296 393C293.239 393 291 395.239 291 398C291 400.761 293.239 403 296 403Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M253 384H249C248.448 384 248 384.448 248 385V386C248 386.552 248.448 387 249 387H253C253.552 387 254 386.552 254 386V385C254 384.448 253.552 384 253 384Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M263 384H259C258.448 384 258 384.448 258 385V386C258 386.552 258.448 387 259 387H263C263.552 387 264 386.552 264 386V385C264 384.448 263.552 384 263 384Z" fill="#D9D9D9"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_d_1_26" x="98" y="57" width="316" height="400" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feMorphology radius="4" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_1_26"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="2"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_26"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_26" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
16
org.kde.alakarte.desktop
Normal file
16
org.kde.alakarte.desktop
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=A-La-Karte
|
||||||
|
Name[x-test]=xxA-La-Kartexx
|
||||||
|
GenericName=Game Launcher
|
||||||
|
GenericName[x-test]=xxGame Launcherxx
|
||||||
|
Comment=A unified game launcher for KDE Plasma
|
||||||
|
Comment[x-test]=xxA unified game launcher for KDE Plasmaxx
|
||||||
|
Exec=alakarte
|
||||||
|
Icon=org.kde.alakarte
|
||||||
|
Type=Application
|
||||||
|
Terminal=false
|
||||||
|
Categories=Game;Qt;
|
||||||
|
Keywords=games;launcher;steam;lutris;heroic;epic;gog;bottles;flatpak;itch;legendary;retroarch;steamgriddb;
|
||||||
|
X-DBUS-StartupType=Unique
|
||||||
|
X-KDE-Wayland-VirtualKeyboard=true
|
||||||
|
StartupNotify=true
|
||||||
94
org.kde.alakarte.metainfo.xml
Normal file
94
org.kde.alakarte.metainfo.xml
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: CC0-1.0 -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 A-La-Karte Contributors -->
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>org.kde.alakarte</id>
|
||||||
|
<name>A-La-Karte</name>
|
||||||
|
<summary>A unified game launcher for KDE Plasma</summary>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>GPL-3.0-or-later</project_license>
|
||||||
|
<developer id="org.kde.alakarte">
|
||||||
|
<name>A-La-Karte Contributors</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<url type="homepage">https://invent.kde.org/marcoa/a-la-karte</url>
|
||||||
|
<url type="bugtracker">https://invent.kde.org/marcoa/a-la-karte/-/issues</url>
|
||||||
|
<url type="vcs-browser">https://invent.kde.org/marcoa/a-la-karte</url>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
A-La-Karte is a game launcher for KDE Plasma. It can import games from Steam, Lutris,
|
||||||
|
and Heroic Games Launcher, as well as Bottles, Flatpak, itch.io, Legendary, RetroArch
|
||||||
|
playlists, and desktop entries.
|
||||||
|
</p>
|
||||||
|
<p>Features:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Import games from Steam, Lutris, Heroic, Bottles, Flatpak, itch.io, Legendary, RetroArch playlists, and desktop entries</li>
|
||||||
|
<li>Grid and list views</li>
|
||||||
|
<li>Search and filter your game library</li>
|
||||||
|
<li>Mark games as favorites</li>
|
||||||
|
<li>Show last played timestamps</li>
|
||||||
|
<li>Launch games directly from the app</li>
|
||||||
|
<li>SteamGridDB cover art integration and custom cover upload</li>
|
||||||
|
<li>KRunner integration for launching games from Plasma search</li>
|
||||||
|
<li>Adaptive layout for small screens</li>
|
||||||
|
<li>Native Plasma 6 integration</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">org.kde.alakarte.desktop</launchable>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>alakarte</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<requires>
|
||||||
|
<display_length compare="ge">360</display_length>
|
||||||
|
</requires>
|
||||||
|
|
||||||
|
<recommends>
|
||||||
|
<control>keyboard</control>
|
||||||
|
<control>pointing</control>
|
||||||
|
<control>touch</control>
|
||||||
|
</recommends>
|
||||||
|
|
||||||
|
<categories>
|
||||||
|
<category>Game</category>
|
||||||
|
<category>Qt</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<keywords>
|
||||||
|
<keyword>games</keyword>
|
||||||
|
<keyword>launcher</keyword>
|
||||||
|
<keyword>steam</keyword>
|
||||||
|
<keyword>lutris</keyword>
|
||||||
|
<keyword>heroic</keyword>
|
||||||
|
<keyword>bottles</keyword>
|
||||||
|
<keyword>flatpak</keyword>
|
||||||
|
<keyword>itch</keyword>
|
||||||
|
<keyword>legendary</keyword>
|
||||||
|
<keyword>retroarch</keyword>
|
||||||
|
<keyword>steamgriddb</keyword>
|
||||||
|
<keyword>epic</keyword>
|
||||||
|
<keyword>gog</keyword>
|
||||||
|
<keyword>library</keyword>
|
||||||
|
</keywords>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.1.0" date="2026-01-17">
|
||||||
|
<description>
|
||||||
|
<p>First public preview release of A-La-Karte</p>
|
||||||
|
<ul>
|
||||||
|
<li>Import games from Steam, Lutris, Heroic, Bottles, Flatpak, itch.io, Legendary, RetroArch playlists, and desktop entries</li>
|
||||||
|
<li>Grid and list view modes</li>
|
||||||
|
<li>Search and filtering</li>
|
||||||
|
<li>Favorites support</li>
|
||||||
|
<li>Game launching</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
</component>
|
||||||
102
src/CMakeLists.txt
Normal file
102
src/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
add_executable(alakarte
|
||||||
|
main.cpp
|
||||||
|
app.cpp
|
||||||
|
gamemodel.cpp
|
||||||
|
gamesortfiltermodel.cpp
|
||||||
|
game.cpp
|
||||||
|
gamepadmanager.cpp
|
||||||
|
platformimporter.cpp
|
||||||
|
steamimporter.cpp
|
||||||
|
lutrisimporter.cpp
|
||||||
|
heroicimporter.cpp
|
||||||
|
desktopimporter.cpp
|
||||||
|
bottlesimporter.cpp
|
||||||
|
flatpakimporter.cpp
|
||||||
|
itchimporter.cpp
|
||||||
|
legendaryimporter.cpp
|
||||||
|
retroarchimporter.cpp
|
||||||
|
steamgriddb.cpp
|
||||||
|
gamelauncher.cpp
|
||||||
|
config.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
qt_add_resources(alakarte alakarte_app_icon
|
||||||
|
PREFIX "/icons"
|
||||||
|
FILES
|
||||||
|
qml/icons/app/org.kde.alakarte.svg
|
||||||
|
)
|
||||||
|
|
||||||
|
set_source_files_properties(qml/icons/app/org.kde.alakarte.svg PROPERTIES
|
||||||
|
QT_RESOURCE_ALIAS "sc-apps-org.kde.alakarte.svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(alakarte PRIVATE
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Quick
|
||||||
|
Qt6::QuickControls2
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Network
|
||||||
|
Qt6::Sql
|
||||||
|
Qt6::Concurrent
|
||||||
|
Qt6::DBus
|
||||||
|
Qt6::Svg
|
||||||
|
KF6::Kirigami
|
||||||
|
KF6::I18n
|
||||||
|
KF6::CoreAddons
|
||||||
|
KF6::ConfigCore
|
||||||
|
KF6::ConfigGui
|
||||||
|
KF6::Crash
|
||||||
|
KF6::DBusAddons
|
||||||
|
KF6::WindowSystem
|
||||||
|
)
|
||||||
|
|
||||||
|
if (TARGET Qt6::Gamepad)
|
||||||
|
target_link_libraries(alakarte PRIVATE Qt6::Gamepad)
|
||||||
|
target_compile_definitions(alakarte PRIVATE ALAKARTE_HAVE_QT_GAMEPAD=1)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
ecm_add_qml_module(alakarte URI org.kde.alakarte
|
||||||
|
VERSION 1.0
|
||||||
|
QML_FILES
|
||||||
|
qml/Main.qml
|
||||||
|
qml/LibraryView.qml
|
||||||
|
qml/GameCard.qml
|
||||||
|
qml/GameDetailsSheet.qml
|
||||||
|
qml/SettingsPage.qml
|
||||||
|
qml/SidebarView.qml
|
||||||
|
qml/GameEditDialog.qml
|
||||||
|
qml/components/FocusableCard.qml
|
||||||
|
qml/components/GameGridView.qml
|
||||||
|
qml/components/SearchHeader.qml
|
||||||
|
qml/components/IconWithResourceFallback.qml
|
||||||
|
qml/components/EmptyState.qml
|
||||||
|
RESOURCES
|
||||||
|
qml/icons/brand/steam-symbolic.svg
|
||||||
|
qml/icons/brand/itchdotio-symbolic.svg
|
||||||
|
qml/icons/brand/retroarch-symbolic.svg
|
||||||
|
qml/icons/app/org.kde.alakarte.svg
|
||||||
|
SOURCES
|
||||||
|
app.h
|
||||||
|
gamemodel.h
|
||||||
|
gamesortfiltermodel.h
|
||||||
|
game.h
|
||||||
|
gamepadmanager.h
|
||||||
|
platformimporter.h
|
||||||
|
steamimporter.h
|
||||||
|
lutrisimporter.h
|
||||||
|
heroicimporter.h
|
||||||
|
desktopimporter.h
|
||||||
|
bottlesimporter.h
|
||||||
|
flatpakimporter.h
|
||||||
|
itchimporter.h
|
||||||
|
legendaryimporter.h
|
||||||
|
retroarchimporter.h
|
||||||
|
steamgriddb.h
|
||||||
|
gamelauncher.h
|
||||||
|
config.h
|
||||||
|
)
|
||||||
|
|
||||||
|
install(TARGETS alakarte ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
|
||||||
685
src/app.cpp
Normal file
685
src/app.cpp
Normal file
|
|
@ -0,0 +1,685 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "app.h"
|
||||||
|
#include "bottlesimporter.h"
|
||||||
|
#include "desktopimporter.h"
|
||||||
|
#include "flatpakimporter.h"
|
||||||
|
#include "heroicimporter.h"
|
||||||
|
#include "itchimporter.h"
|
||||||
|
#include "legendaryimporter.h"
|
||||||
|
#include "lutrisimporter.h"
|
||||||
|
#include "retroarchimporter.h"
|
||||||
|
#include "steamimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
|
App *App::s_instance = nullptr;
|
||||||
|
|
||||||
|
App::App(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_gameModel(new GameModel(this))
|
||||||
|
, m_launcher(new GameLauncher(this))
|
||||||
|
, m_steamGridDB(new SteamGridDB(this))
|
||||||
|
, m_config(new Config(this))
|
||||||
|
{
|
||||||
|
loadLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
App *App::instance()
|
||||||
|
{
|
||||||
|
if (!s_instance) {
|
||||||
|
s_instance = new App();
|
||||||
|
}
|
||||||
|
return s_instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
App *App::create(QQmlEngine *engine, QJSEngine *scriptEngine)
|
||||||
|
{
|
||||||
|
Q_UNUSED(engine)
|
||||||
|
Q_UNUSED(scriptEngine)
|
||||||
|
return instance();
|
||||||
|
}
|
||||||
|
|
||||||
|
GameModel *App::gameModel() const
|
||||||
|
{
|
||||||
|
return m_gameModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameLauncher *App::launcher() const
|
||||||
|
{
|
||||||
|
return m_launcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
SteamGridDB *App::steamGridDB() const
|
||||||
|
{
|
||||||
|
return m_steamGridDB;
|
||||||
|
}
|
||||||
|
|
||||||
|
Config *App::config() const
|
||||||
|
{
|
||||||
|
return m_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::importing() const
|
||||||
|
{
|
||||||
|
return m_importing;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString App::importStatus() const
|
||||||
|
{
|
||||||
|
return m_importStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::setImporting(bool importing)
|
||||||
|
{
|
||||||
|
if (m_importing != importing) {
|
||||||
|
m_importing = importing;
|
||||||
|
Q_EMIT importingChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::setImportStatus(const QString &status)
|
||||||
|
{
|
||||||
|
if (m_importStatus != status) {
|
||||||
|
m_importStatus = status;
|
||||||
|
Q_EMIT importStatusChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importAllGames()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Importing games..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
int totalCount = 0;
|
||||||
|
|
||||||
|
// Import from Steam
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
setImportStatus(tr("Scanning Steam library..."));
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
SteamImporter steamImporter;
|
||||||
|
QList<Game *> steamGames = steamImporter.importGames();
|
||||||
|
for (Game *game : steamGames) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, steamGames]() {
|
||||||
|
for (Game *game : steamGames) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
totalCount += steamGames.count();
|
||||||
|
|
||||||
|
// Import from Lutris
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
setImportStatus(tr("Scanning Lutris library..."));
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
LutrisImporter lutrisImporter;
|
||||||
|
QList<Game *> lutrisGames = lutrisImporter.importGames();
|
||||||
|
for (Game *game : lutrisGames) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, lutrisGames]() {
|
||||||
|
for (Game *game : lutrisGames) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
totalCount += lutrisGames.count();
|
||||||
|
|
||||||
|
// Import from Heroic
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
setImportStatus(tr("Scanning Heroic library..."));
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
HeroicImporter heroicImporter;
|
||||||
|
QList<Game *> heroicGames = heroicImporter.importGames();
|
||||||
|
for (Game *game : heroicGames) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, heroicGames]() {
|
||||||
|
for (Game *game : heroicGames) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
totalCount += heroicGames.count();
|
||||||
|
|
||||||
|
// Complete
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, totalCount]() {
|
||||||
|
setImportStatus(tr("Import complete: %1 games found").arg(totalCount));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(totalCount);
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromSteam()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning Steam library..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
SteamImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("Steam import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromLutris()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning Lutris library..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
LutrisImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("Lutris import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromHeroic()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning Heroic library..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
HeroicImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("Heroic import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromDesktop()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning desktop entries..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
DesktopImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("Desktop import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromBottles()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning Bottles..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
BottlesImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("Bottles import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromFlatpak()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning Flatpak games..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
FlatpakImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("Flatpak import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromItch()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning itch.io library..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
ItchImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("itch.io import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromLegendary()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning Legendary library..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
LegendaryImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("Legendary import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::importFromRetroArch()
|
||||||
|
{
|
||||||
|
if (m_importing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportStatus(tr("Scanning RetroArch playlists..."));
|
||||||
|
|
||||||
|
QtConcurrent::run([this]() {
|
||||||
|
RetroArchImporter importer;
|
||||||
|
QList<Game *> games = importer.importGames();
|
||||||
|
|
||||||
|
for (Game *game : games) {
|
||||||
|
game->moveToThread(this->thread());
|
||||||
|
game->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, games]() {
|
||||||
|
for (Game *game : games) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
setImportStatus(tr("RetroArch import complete: %1 games found").arg(games.count()));
|
||||||
|
setImporting(false);
|
||||||
|
saveLibrary();
|
||||||
|
Q_EMIT importCompleted(games.count());
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::clearLibrary()
|
||||||
|
{
|
||||||
|
m_gameModel->clear();
|
||||||
|
saveLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::removeMissingGames()
|
||||||
|
{
|
||||||
|
QList<Game *> gamesToRemove;
|
||||||
|
|
||||||
|
for (int i = 0; i < m_gameModel->rowCount(); ++i) {
|
||||||
|
Game *game = m_gameModel->gameAt(i);
|
||||||
|
if (!game)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
QString launchCommand = game->launchCommand();
|
||||||
|
|
||||||
|
// Skip URL-based launchers (Steam, Lutris, etc.)
|
||||||
|
if (launchCommand.startsWith(QLatin1String("steam://")) || launchCommand.startsWith(QLatin1String("lutris:"))
|
||||||
|
|| launchCommand.startsWith(QLatin1String("xdg-open")) || launchCommand.startsWith(QLatin1String("flatpak run"))
|
||||||
|
|| launchCommand.startsWith(QLatin1String("legendary launch")) || launchCommand.startsWith(QLatin1String("bottles"))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract executable path from command
|
||||||
|
QString executable = launchCommand;
|
||||||
|
|
||||||
|
// Handle quoted paths
|
||||||
|
if (executable.startsWith(QLatin1Char('"'))) {
|
||||||
|
int endQuote = executable.indexOf(QLatin1Char('"'), 1);
|
||||||
|
if (endQuote > 0) {
|
||||||
|
executable = executable.mid(1, endQuote - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Take first word as executable
|
||||||
|
int space = executable.indexOf(QLatin1Char(' '));
|
||||||
|
if (space > 0) {
|
||||||
|
executable = executable.left(space);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if executable exists
|
||||||
|
if (!executable.isEmpty() && !QFile::exists(executable)) {
|
||||||
|
gamesToRemove.append(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove missing games
|
||||||
|
for (Game *game : gamesToRemove) {
|
||||||
|
m_gameModel->removeGame(game->id());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gamesToRemove.isEmpty()) {
|
||||||
|
saveLibrary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::saveLibrary()
|
||||||
|
{
|
||||||
|
QString dataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
|
QDir dir(dataPath);
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkpath(dataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(dataPath + QStringLiteral("/library.json"));
|
||||||
|
if (!file.open(QIODevice::WriteOnly)) {
|
||||||
|
qWarning() << "Failed to save library:" << file.errorString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray gamesArray;
|
||||||
|
for (int i = 0; i < m_gameModel->rowCount(); ++i) {
|
||||||
|
Game *game = m_gameModel->gameAt(i);
|
||||||
|
if (game) {
|
||||||
|
gamesArray.append(game->toJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc(gamesArray);
|
||||||
|
file.write(doc.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::loadLibrary()
|
||||||
|
{
|
||||||
|
QString dataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
|
QFile file(dataPath + QStringLiteral("/library.json"));
|
||||||
|
|
||||||
|
if (!file.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
qWarning() << "Failed to load library:" << file.errorString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||||
|
if (!doc.isArray()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray gamesArray = doc.array();
|
||||||
|
for (const QJsonValue &value : gamesArray) {
|
||||||
|
if (value.isObject()) {
|
||||||
|
Game *game = Game::fromJson(value.toObject(), this);
|
||||||
|
if (game) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *App::createGame(const QString &name, const QString &launchCommand)
|
||||||
|
{
|
||||||
|
if (name.isEmpty() || launchCommand.isEmpty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = new Game(this);
|
||||||
|
game->setName(name);
|
||||||
|
game->setLaunchCommand(launchCommand);
|
||||||
|
game->setPlatform(QStringLiteral("Manual"));
|
||||||
|
game->setPlatformId(QStringLiteral("manual"));
|
||||||
|
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
saveLibrary();
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::removeGame(Game *game)
|
||||||
|
{
|
||||||
|
if (!game) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in removed games map for potential undo
|
||||||
|
m_removedGames[game->id()] = game->toJson();
|
||||||
|
|
||||||
|
m_gameModel->removeGame(game->id());
|
||||||
|
saveLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::restoreGame(const QString &gameId)
|
||||||
|
{
|
||||||
|
if (!m_removedGames.contains(gameId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = Game::fromJson(m_removedGames[gameId], this);
|
||||||
|
if (game) {
|
||||||
|
m_gameModel->addGame(game);
|
||||||
|
m_removedGames.remove(gameId);
|
||||||
|
saveLibrary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::setCoverFromFile(Game *game, const QString &filePath)
|
||||||
|
{
|
||||||
|
if (!game || filePath.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile sourceFile(filePath);
|
||||||
|
if (!sourceFile.exists()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create covers directory
|
||||||
|
QString coversPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/covers");
|
||||||
|
QDir dir(coversPath);
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkpath(coversPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file extension
|
||||||
|
QFileInfo fileInfo(filePath);
|
||||||
|
QString extension = fileInfo.suffix().toLower();
|
||||||
|
if (extension.isEmpty()) {
|
||||||
|
extension = QStringLiteral("jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString destFileName = game->id() + QStringLiteral(".") + extension;
|
||||||
|
QString destPath = coversPath + QStringLiteral("/") + destFileName;
|
||||||
|
|
||||||
|
// Remove existing cover if any
|
||||||
|
QFile destFile(destPath);
|
||||||
|
if (destFile.exists()) {
|
||||||
|
destFile.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file
|
||||||
|
if (!sourceFile.copy(destPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update game cover URL
|
||||||
|
game->setCoverUrl(QUrl::fromLocalFile(destPath));
|
||||||
|
saveLibrary();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
83
src/app.h
Normal file
83
src/app.h
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "gamelauncher.h"
|
||||||
|
#include "gamemodel.h"
|
||||||
|
#include "steamgriddb.h"
|
||||||
|
|
||||||
|
class App : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
|
||||||
|
Q_PROPERTY(GameModel *gameModel READ gameModel CONSTANT)
|
||||||
|
Q_PROPERTY(GameLauncher *launcher READ launcher CONSTANT)
|
||||||
|
Q_PROPERTY(SteamGridDB *steamGridDB READ steamGridDB CONSTANT)
|
||||||
|
Q_PROPERTY(Config *config READ config CONSTANT)
|
||||||
|
Q_PROPERTY(bool importing READ importing NOTIFY importingChanged)
|
||||||
|
Q_PROPERTY(QString importStatus READ importStatus NOTIFY importStatusChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
static App *instance();
|
||||||
|
static App *create(QQmlEngine *engine, QJSEngine *scriptEngine);
|
||||||
|
|
||||||
|
GameModel *gameModel() const;
|
||||||
|
GameLauncher *launcher() const;
|
||||||
|
SteamGridDB *steamGridDB() const;
|
||||||
|
Config *config() const;
|
||||||
|
|
||||||
|
bool importing() const;
|
||||||
|
QString importStatus() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE void importAllGames();
|
||||||
|
Q_INVOKABLE void importFromSteam();
|
||||||
|
Q_INVOKABLE void importFromLutris();
|
||||||
|
Q_INVOKABLE void importFromHeroic();
|
||||||
|
Q_INVOKABLE void importFromDesktop();
|
||||||
|
Q_INVOKABLE void importFromBottles();
|
||||||
|
Q_INVOKABLE void importFromFlatpak();
|
||||||
|
Q_INVOKABLE void importFromItch();
|
||||||
|
Q_INVOKABLE void importFromLegendary();
|
||||||
|
Q_INVOKABLE void importFromRetroArch();
|
||||||
|
Q_INVOKABLE void clearLibrary();
|
||||||
|
Q_INVOKABLE void removeMissingGames();
|
||||||
|
Q_INVOKABLE void saveLibrary();
|
||||||
|
Q_INVOKABLE void loadLibrary();
|
||||||
|
|
||||||
|
Q_INVOKABLE Game *createGame(const QString &name, const QString &launchCommand);
|
||||||
|
Q_INVOKABLE void removeGame(Game *game);
|
||||||
|
Q_INVOKABLE void restoreGame(const QString &gameId);
|
||||||
|
Q_INVOKABLE bool setCoverFromFile(Game *game, const QString &filePath);
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void importingChanged();
|
||||||
|
void importStatusChanged();
|
||||||
|
void importCompleted(int count);
|
||||||
|
void importError(const QString &error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
explicit App(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
static App *s_instance;
|
||||||
|
|
||||||
|
GameModel *m_gameModel;
|
||||||
|
GameLauncher *m_launcher;
|
||||||
|
SteamGridDB *m_steamGridDB;
|
||||||
|
Config *m_config;
|
||||||
|
|
||||||
|
bool m_importing = false;
|
||||||
|
QString m_importStatus;
|
||||||
|
QHash<QString, QJsonObject> m_removedGames;
|
||||||
|
|
||||||
|
void setImporting(bool importing);
|
||||||
|
void setImportStatus(const QString &status);
|
||||||
|
};
|
||||||
128
src/bottlesimporter.cpp
Normal file
128
src/bottlesimporter.cpp
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "bottlesimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
BottlesImporter::BottlesImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BottlesImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("Bottles");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BottlesImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("bottles");
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList BottlesImporter::getBottlesPaths() const
|
||||||
|
{
|
||||||
|
QStringList paths;
|
||||||
|
|
||||||
|
// Standard Bottles locations
|
||||||
|
paths << expandPath(QStringLiteral("~/.local/share/bottles"));
|
||||||
|
// Flatpak Bottles
|
||||||
|
paths << expandPath(QStringLiteral("~/.var/app/com.usebottles.bottles/data/bottles"));
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BottlesImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
for (const QString &path : getBottlesPaths()) {
|
||||||
|
if (directoryExists(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> BottlesImporter::parseBottle(const QString &bottlePath, const QString &bottleName) const
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
// Look for programs in the bottle
|
||||||
|
QString programsPath = bottlePath + QStringLiteral("/programs.json");
|
||||||
|
QFile programsFile(programsPath);
|
||||||
|
|
||||||
|
if (!programsFile.open(QIODevice::ReadOnly)) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(programsFile.readAll());
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject programs = doc.object();
|
||||||
|
for (auto it = programs.begin(); it != programs.end(); ++it) {
|
||||||
|
QString programName = it.key();
|
||||||
|
QJsonObject programData = it.value().toObject();
|
||||||
|
|
||||||
|
QString executable = programData[QStringLiteral("executable")].toString();
|
||||||
|
QString path = programData[QStringLiteral("path")].toString();
|
||||||
|
|
||||||
|
if (programName.isEmpty() || executable.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString gameId = QStringLiteral("bottles_%1_%2").arg(bottleName, programName).replace(QStringLiteral(" "), QStringLiteral("_")).toLower();
|
||||||
|
|
||||||
|
Game *game = new Game(gameId, programName);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(platformId());
|
||||||
|
|
||||||
|
// Build launch command using bottles CLI
|
||||||
|
QString launchCmd = QStringLiteral("flatpak run com.usebottles.bottles -e \"%1\" -b \"%2\"").arg(executable, bottleName);
|
||||||
|
|
||||||
|
// Check if native bottles is available
|
||||||
|
if (!findExecutable(QStringLiteral("bottles-cli")).isEmpty()) {
|
||||||
|
launchCmd = QStringLiteral("bottles-cli run -e \"%1\" -b \"%2\"").arg(executable, bottleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
game->setLaunchCommand(launchCmd);
|
||||||
|
game->setDescription(QStringLiteral("Wine application from %1 bottle").arg(bottleName));
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> BottlesImporter::importGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
for (const QString &basePath : getBottlesPaths()) {
|
||||||
|
QString bottlesDir = basePath + QStringLiteral("/bottles");
|
||||||
|
QDir dir(bottlesDir);
|
||||||
|
|
||||||
|
if (!dir.exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList bottles = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||||
|
int total = bottles.count();
|
||||||
|
int current = 0;
|
||||||
|
|
||||||
|
for (const QString &bottleName : bottles) {
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
|
||||||
|
QString bottlePath = dir.absoluteFilePath(bottleName);
|
||||||
|
QList<Game *> bottleGames = parseBottle(bottlePath, bottleName);
|
||||||
|
games.append(bottleGames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
23
src/bottlesimporter.h
Normal file
23
src/bottlesimporter.h
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class BottlesImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit BottlesImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QStringList getBottlesPaths() const;
|
||||||
|
QList<Game *> parseBottle(const QString &bottlePath, const QString &bottleName) const;
|
||||||
|
};
|
||||||
369
src/config.cpp
Normal file
369
src/config.cpp
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
#include <KConfigGroup>
|
||||||
|
#include <KSharedConfig>
|
||||||
|
|
||||||
|
Config::Config(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Config::ViewMode Config::viewMode() const
|
||||||
|
{
|
||||||
|
return m_viewMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setViewMode(ViewMode mode)
|
||||||
|
{
|
||||||
|
if (m_viewMode != mode) {
|
||||||
|
m_viewMode = mode;
|
||||||
|
save();
|
||||||
|
Q_EMIT viewModeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int Config::gridSize() const
|
||||||
|
{
|
||||||
|
return m_gridSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setGridSize(int size)
|
||||||
|
{
|
||||||
|
size = qBound(100, size, 300);
|
||||||
|
if (m_gridSize != size) {
|
||||||
|
m_gridSize = size;
|
||||||
|
save();
|
||||||
|
Q_EMIT gridSizeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::showPlatformBadges() const
|
||||||
|
{
|
||||||
|
return m_showPlatformBadges;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setShowPlatformBadges(bool show)
|
||||||
|
{
|
||||||
|
if (m_showPlatformBadges != show) {
|
||||||
|
m_showPlatformBadges = show;
|
||||||
|
save();
|
||||||
|
Q_EMIT showPlatformBadgesChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::autoImportOnStartup() const
|
||||||
|
{
|
||||||
|
return m_autoImportOnStartup;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setAutoImportOnStartup(bool import)
|
||||||
|
{
|
||||||
|
if (m_autoImportOnStartup != import) {
|
||||||
|
m_autoImportOnStartup = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT autoImportOnStartupChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importSteam() const
|
||||||
|
{
|
||||||
|
return m_importSteam;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportSteam(bool import)
|
||||||
|
{
|
||||||
|
if (m_importSteam != import) {
|
||||||
|
m_importSteam = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importSteamChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importLutris() const
|
||||||
|
{
|
||||||
|
return m_importLutris;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportLutris(bool import)
|
||||||
|
{
|
||||||
|
if (m_importLutris != import) {
|
||||||
|
m_importLutris = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importLutrisChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importHeroic() const
|
||||||
|
{
|
||||||
|
return m_importHeroic;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportHeroic(bool import)
|
||||||
|
{
|
||||||
|
if (m_importHeroic != import) {
|
||||||
|
m_importHeroic = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importHeroicChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importDesktop() const
|
||||||
|
{
|
||||||
|
return m_importDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportDesktop(bool import)
|
||||||
|
{
|
||||||
|
if (m_importDesktop != import) {
|
||||||
|
m_importDesktop = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importDesktopChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importBottles() const
|
||||||
|
{
|
||||||
|
return m_importBottles;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportBottles(bool import)
|
||||||
|
{
|
||||||
|
if (m_importBottles != import) {
|
||||||
|
m_importBottles = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importBottlesChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importFlatpak() const
|
||||||
|
{
|
||||||
|
return m_importFlatpak;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportFlatpak(bool import)
|
||||||
|
{
|
||||||
|
if (m_importFlatpak != import) {
|
||||||
|
m_importFlatpak = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importFlatpakChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importItch() const
|
||||||
|
{
|
||||||
|
return m_importItch;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportItch(bool import)
|
||||||
|
{
|
||||||
|
if (m_importItch != import) {
|
||||||
|
m_importItch = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importItchChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importLegendary() const
|
||||||
|
{
|
||||||
|
return m_importLegendary;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportLegendary(bool import)
|
||||||
|
{
|
||||||
|
if (m_importLegendary != import) {
|
||||||
|
m_importLegendary = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importLegendaryChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::importRetroArch() const
|
||||||
|
{
|
||||||
|
return m_importRetroArch;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setImportRetroArch(bool import)
|
||||||
|
{
|
||||||
|
if (m_importRetroArch != import) {
|
||||||
|
m_importRetroArch = import;
|
||||||
|
save();
|
||||||
|
Q_EMIT importRetroArchChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::exitAfterLaunch() const
|
||||||
|
{
|
||||||
|
return m_exitAfterLaunch;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setExitAfterLaunch(bool exit)
|
||||||
|
{
|
||||||
|
if (m_exitAfterLaunch != exit) {
|
||||||
|
m_exitAfterLaunch = exit;
|
||||||
|
save();
|
||||||
|
Q_EMIT exitAfterLaunchChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::coverLaunchesGame() const
|
||||||
|
{
|
||||||
|
return m_coverLaunchesGame;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setCoverLaunchesGame(bool launch)
|
||||||
|
{
|
||||||
|
if (m_coverLaunchesGame != launch) {
|
||||||
|
m_coverLaunchesGame = launch;
|
||||||
|
save();
|
||||||
|
Q_EMIT coverLaunchesGameChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::highQualityImages() const
|
||||||
|
{
|
||||||
|
return m_highQualityImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setHighQualityImages(bool hq)
|
||||||
|
{
|
||||||
|
if (m_highQualityImages != hq) {
|
||||||
|
m_highQualityImages = hq;
|
||||||
|
save();
|
||||||
|
Q_EMIT highQualityImagesChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Config::animatedCovers() const
|
||||||
|
{
|
||||||
|
return m_animatedCovers;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setAnimatedCovers(bool animated)
|
||||||
|
{
|
||||||
|
if (m_animatedCovers != animated) {
|
||||||
|
m_animatedCovers = animated;
|
||||||
|
save();
|
||||||
|
Q_EMIT animatedCoversChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Config::theme() const
|
||||||
|
{
|
||||||
|
return m_theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::setTheme(const QString &theme)
|
||||||
|
{
|
||||||
|
if (m_theme != theme) {
|
||||||
|
m_theme = theme;
|
||||||
|
save();
|
||||||
|
Q_EMIT themeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::save()
|
||||||
|
{
|
||||||
|
const auto config = KSharedConfig::openConfig(QStringLiteral("alakarterc"));
|
||||||
|
|
||||||
|
KConfigGroup generalGroup(config, QStringLiteral("General"));
|
||||||
|
generalGroup.writeEntry(QStringLiteral("viewMode"), static_cast<int>(m_viewMode));
|
||||||
|
generalGroup.writeEntry(QStringLiteral("gridSize"), m_gridSize);
|
||||||
|
generalGroup.writeEntry(QStringLiteral("showPlatformBadges"), m_showPlatformBadges);
|
||||||
|
generalGroup.writeEntry(QStringLiteral("theme"), m_theme);
|
||||||
|
|
||||||
|
KConfigGroup importGroup(config, QStringLiteral("Import"));
|
||||||
|
importGroup.writeEntry(QStringLiteral("autoImportOnStartup"), m_autoImportOnStartup);
|
||||||
|
importGroup.writeEntry(QStringLiteral("steam"), m_importSteam);
|
||||||
|
importGroup.writeEntry(QStringLiteral("lutris"), m_importLutris);
|
||||||
|
importGroup.writeEntry(QStringLiteral("heroic"), m_importHeroic);
|
||||||
|
importGroup.writeEntry(QStringLiteral("desktop"), m_importDesktop);
|
||||||
|
importGroup.writeEntry(QStringLiteral("bottles"), m_importBottles);
|
||||||
|
importGroup.writeEntry(QStringLiteral("flatpak"), m_importFlatpak);
|
||||||
|
importGroup.writeEntry(QStringLiteral("itch"), m_importItch);
|
||||||
|
importGroup.writeEntry(QStringLiteral("legendary"), m_importLegendary);
|
||||||
|
importGroup.writeEntry(QStringLiteral("retroarch"), m_importRetroArch);
|
||||||
|
|
||||||
|
KConfigGroup behaviorGroup(config, QStringLiteral("Behavior"));
|
||||||
|
behaviorGroup.writeEntry(QStringLiteral("highQualityImages"), m_highQualityImages);
|
||||||
|
behaviorGroup.writeEntry(QStringLiteral("animatedCovers"), m_animatedCovers);
|
||||||
|
behaviorGroup.writeEntry(QStringLiteral("exitAfterLaunch"), m_exitAfterLaunch);
|
||||||
|
behaviorGroup.writeEntry(QStringLiteral("coverLaunchesGame"), m_coverLaunchesGame);
|
||||||
|
|
||||||
|
config->sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::load()
|
||||||
|
{
|
||||||
|
const auto config = KSharedConfig::openConfig(QStringLiteral("alakarterc"));
|
||||||
|
|
||||||
|
const KConfigGroup generalGroup(config, QStringLiteral("General"));
|
||||||
|
m_viewMode = static_cast<ViewMode>(generalGroup.readEntry(QStringLiteral("viewMode"), static_cast<int>(GridView)));
|
||||||
|
m_gridSize = generalGroup.readEntry(QStringLiteral("gridSize"), 180);
|
||||||
|
m_showPlatformBadges = generalGroup.readEntry(QStringLiteral("showPlatformBadges"), true);
|
||||||
|
m_theme = generalGroup.readEntry(QStringLiteral("theme"), QString());
|
||||||
|
|
||||||
|
const KConfigGroup importGroup(config, QStringLiteral("Import"));
|
||||||
|
m_autoImportOnStartup = importGroup.readEntry(QStringLiteral("autoImportOnStartup"), false);
|
||||||
|
m_importSteam = importGroup.readEntry(QStringLiteral("steam"), true);
|
||||||
|
m_importLutris = importGroup.readEntry(QStringLiteral("lutris"), true);
|
||||||
|
m_importHeroic = importGroup.readEntry(QStringLiteral("heroic"), true);
|
||||||
|
m_importDesktop = importGroup.readEntry(QStringLiteral("desktop"), true);
|
||||||
|
m_importBottles = importGroup.readEntry(QStringLiteral("bottles"), true);
|
||||||
|
m_importFlatpak = importGroup.readEntry(QStringLiteral("flatpak"), true);
|
||||||
|
m_importItch = importGroup.readEntry(QStringLiteral("itch"), true);
|
||||||
|
m_importLegendary = importGroup.readEntry(QStringLiteral("legendary"), true);
|
||||||
|
m_importRetroArch = importGroup.readEntry(QStringLiteral("retroarch"), true);
|
||||||
|
|
||||||
|
const KConfigGroup behaviorGroup(config, QStringLiteral("Behavior"));
|
||||||
|
m_highQualityImages = behaviorGroup.readEntry(QStringLiteral("highQualityImages"), true);
|
||||||
|
m_animatedCovers = behaviorGroup.readEntry(QStringLiteral("animatedCovers"), false);
|
||||||
|
m_exitAfterLaunch = behaviorGroup.readEntry(QStringLiteral("exitAfterLaunch"), false);
|
||||||
|
m_coverLaunchesGame = behaviorGroup.readEntry(QStringLiteral("coverLaunchesGame"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Config::resetToDefaults()
|
||||||
|
{
|
||||||
|
m_viewMode = GridView;
|
||||||
|
m_gridSize = 180;
|
||||||
|
m_showPlatformBadges = true;
|
||||||
|
m_autoImportOnStartup = false;
|
||||||
|
m_importSteam = true;
|
||||||
|
m_importLutris = true;
|
||||||
|
m_importHeroic = true;
|
||||||
|
m_importDesktop = true;
|
||||||
|
m_importBottles = true;
|
||||||
|
m_importFlatpak = true;
|
||||||
|
m_importItch = true;
|
||||||
|
m_importLegendary = true;
|
||||||
|
m_importRetroArch = true;
|
||||||
|
m_exitAfterLaunch = false;
|
||||||
|
m_coverLaunchesGame = false;
|
||||||
|
m_highQualityImages = true;
|
||||||
|
m_animatedCovers = false;
|
||||||
|
m_theme.clear();
|
||||||
|
|
||||||
|
save();
|
||||||
|
|
||||||
|
Q_EMIT viewModeChanged();
|
||||||
|
Q_EMIT gridSizeChanged();
|
||||||
|
Q_EMIT showPlatformBadgesChanged();
|
||||||
|
Q_EMIT autoImportOnStartupChanged();
|
||||||
|
Q_EMIT importSteamChanged();
|
||||||
|
Q_EMIT importLutrisChanged();
|
||||||
|
Q_EMIT importHeroicChanged();
|
||||||
|
Q_EMIT importDesktopChanged();
|
||||||
|
Q_EMIT importBottlesChanged();
|
||||||
|
Q_EMIT importFlatpakChanged();
|
||||||
|
Q_EMIT importItchChanged();
|
||||||
|
Q_EMIT importLegendaryChanged();
|
||||||
|
Q_EMIT importRetroArchChanged();
|
||||||
|
Q_EMIT exitAfterLaunchChanged();
|
||||||
|
Q_EMIT coverLaunchesGameChanged();
|
||||||
|
Q_EMIT highQualityImagesChanged();
|
||||||
|
Q_EMIT animatedCoversChanged();
|
||||||
|
Q_EMIT themeChanged();
|
||||||
|
}
|
||||||
140
src/config.h
Normal file
140
src/config.h
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
|
||||||
|
class Config : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
|
||||||
|
Q_PROPERTY(ViewMode viewMode READ viewMode WRITE setViewMode NOTIFY viewModeChanged)
|
||||||
|
Q_PROPERTY(int gridSize READ gridSize WRITE setGridSize NOTIFY gridSizeChanged)
|
||||||
|
Q_PROPERTY(bool showPlatformBadges READ showPlatformBadges WRITE setShowPlatformBadges NOTIFY showPlatformBadgesChanged)
|
||||||
|
Q_PROPERTY(bool autoImportOnStartup READ autoImportOnStartup WRITE setAutoImportOnStartup NOTIFY autoImportOnStartupChanged)
|
||||||
|
Q_PROPERTY(bool importSteam READ importSteam WRITE setImportSteam NOTIFY importSteamChanged)
|
||||||
|
Q_PROPERTY(bool importLutris READ importLutris WRITE setImportLutris NOTIFY importLutrisChanged)
|
||||||
|
Q_PROPERTY(bool importHeroic READ importHeroic WRITE setImportHeroic NOTIFY importHeroicChanged)
|
||||||
|
Q_PROPERTY(bool importDesktop READ importDesktop WRITE setImportDesktop NOTIFY importDesktopChanged)
|
||||||
|
Q_PROPERTY(bool importBottles READ importBottles WRITE setImportBottles NOTIFY importBottlesChanged)
|
||||||
|
Q_PROPERTY(bool importFlatpak READ importFlatpak WRITE setImportFlatpak NOTIFY importFlatpakChanged)
|
||||||
|
Q_PROPERTY(bool importItch READ importItch WRITE setImportItch NOTIFY importItchChanged)
|
||||||
|
Q_PROPERTY(bool importLegendary READ importLegendary WRITE setImportLegendary NOTIFY importLegendaryChanged)
|
||||||
|
Q_PROPERTY(bool importRetroArch READ importRetroArch WRITE setImportRetroArch NOTIFY importRetroArchChanged)
|
||||||
|
Q_PROPERTY(bool exitAfterLaunch READ exitAfterLaunch WRITE setExitAfterLaunch NOTIFY exitAfterLaunchChanged)
|
||||||
|
Q_PROPERTY(bool highQualityImages READ highQualityImages WRITE setHighQualityImages NOTIFY highQualityImagesChanged)
|
||||||
|
Q_PROPERTY(bool animatedCovers READ animatedCovers WRITE setAnimatedCovers NOTIFY animatedCoversChanged)
|
||||||
|
Q_PROPERTY(bool coverLaunchesGame READ coverLaunchesGame WRITE setCoverLaunchesGame NOTIFY coverLaunchesGameChanged)
|
||||||
|
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum ViewMode {
|
||||||
|
GridView,
|
||||||
|
ListView,
|
||||||
|
CompactView
|
||||||
|
};
|
||||||
|
Q_ENUM(ViewMode)
|
||||||
|
|
||||||
|
explicit Config(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
ViewMode viewMode() const;
|
||||||
|
void setViewMode(ViewMode mode);
|
||||||
|
|
||||||
|
int gridSize() const;
|
||||||
|
void setGridSize(int size);
|
||||||
|
|
||||||
|
bool showPlatformBadges() const;
|
||||||
|
void setShowPlatformBadges(bool show);
|
||||||
|
|
||||||
|
bool autoImportOnStartup() const;
|
||||||
|
void setAutoImportOnStartup(bool import);
|
||||||
|
|
||||||
|
bool importSteam() const;
|
||||||
|
void setImportSteam(bool import);
|
||||||
|
|
||||||
|
bool importLutris() const;
|
||||||
|
void setImportLutris(bool import);
|
||||||
|
|
||||||
|
bool importHeroic() const;
|
||||||
|
void setImportHeroic(bool import);
|
||||||
|
|
||||||
|
bool importDesktop() const;
|
||||||
|
void setImportDesktop(bool import);
|
||||||
|
|
||||||
|
bool importBottles() const;
|
||||||
|
void setImportBottles(bool import);
|
||||||
|
|
||||||
|
bool importFlatpak() const;
|
||||||
|
void setImportFlatpak(bool import);
|
||||||
|
|
||||||
|
bool importItch() const;
|
||||||
|
void setImportItch(bool import);
|
||||||
|
|
||||||
|
bool importLegendary() const;
|
||||||
|
void setImportLegendary(bool import);
|
||||||
|
|
||||||
|
bool importRetroArch() const;
|
||||||
|
void setImportRetroArch(bool import);
|
||||||
|
|
||||||
|
bool exitAfterLaunch() const;
|
||||||
|
void setExitAfterLaunch(bool exit);
|
||||||
|
|
||||||
|
bool coverLaunchesGame() const;
|
||||||
|
void setCoverLaunchesGame(bool launch);
|
||||||
|
|
||||||
|
bool highQualityImages() const;
|
||||||
|
void setHighQualityImages(bool hq);
|
||||||
|
|
||||||
|
bool animatedCovers() const;
|
||||||
|
void setAnimatedCovers(bool animated);
|
||||||
|
|
||||||
|
QString theme() const;
|
||||||
|
void setTheme(const QString &theme);
|
||||||
|
|
||||||
|
Q_INVOKABLE void save();
|
||||||
|
Q_INVOKABLE void load();
|
||||||
|
Q_INVOKABLE void resetToDefaults();
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void viewModeChanged();
|
||||||
|
void gridSizeChanged();
|
||||||
|
void showPlatformBadgesChanged();
|
||||||
|
void autoImportOnStartupChanged();
|
||||||
|
void importSteamChanged();
|
||||||
|
void importLutrisChanged();
|
||||||
|
void importHeroicChanged();
|
||||||
|
void importDesktopChanged();
|
||||||
|
void importBottlesChanged();
|
||||||
|
void importFlatpakChanged();
|
||||||
|
void importItchChanged();
|
||||||
|
void importLegendaryChanged();
|
||||||
|
void importRetroArchChanged();
|
||||||
|
void exitAfterLaunchChanged();
|
||||||
|
void highQualityImagesChanged();
|
||||||
|
void animatedCoversChanged();
|
||||||
|
void coverLaunchesGameChanged();
|
||||||
|
void themeChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
ViewMode m_viewMode = GridView;
|
||||||
|
int m_gridSize = 180;
|
||||||
|
bool m_showPlatformBadges = true;
|
||||||
|
bool m_autoImportOnStartup = false;
|
||||||
|
bool m_importSteam = true;
|
||||||
|
bool m_importLutris = true;
|
||||||
|
bool m_importHeroic = true;
|
||||||
|
bool m_importDesktop = true;
|
||||||
|
bool m_importBottles = true;
|
||||||
|
bool m_importFlatpak = true;
|
||||||
|
bool m_importItch = true;
|
||||||
|
bool m_importLegendary = true;
|
||||||
|
bool m_importRetroArch = true;
|
||||||
|
bool m_exitAfterLaunch = false;
|
||||||
|
bool m_coverLaunchesGame = false;
|
||||||
|
bool m_highQualityImages = true;
|
||||||
|
bool m_animatedCovers = false;
|
||||||
|
QString m_theme;
|
||||||
|
};
|
||||||
196
src/desktopimporter.cpp
Normal file
196
src/desktopimporter.cpp
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "desktopimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
DesktopImporter::DesktopImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DesktopImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("Desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DesktopImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DesktopImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList DesktopImporter::getDesktopFilePaths() const
|
||||||
|
{
|
||||||
|
QStringList paths;
|
||||||
|
|
||||||
|
// Standard XDG application directories
|
||||||
|
QStringList appDirs = QStandardPaths::standardLocations(QStandardPaths::ApplicationsLocation);
|
||||||
|
|
||||||
|
// Add common system locations
|
||||||
|
appDirs << QStringLiteral("/usr/share/applications");
|
||||||
|
appDirs << QStringLiteral("/usr/local/share/applications");
|
||||||
|
appDirs << expandPath(QStringLiteral("~/.local/share/applications"));
|
||||||
|
|
||||||
|
// Flatpak export directories
|
||||||
|
appDirs << expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/applications"));
|
||||||
|
appDirs << QStringLiteral("/var/lib/flatpak/exports/share/applications");
|
||||||
|
|
||||||
|
for (const QString &dir : appDirs) {
|
||||||
|
QDir appDir(dir);
|
||||||
|
if (appDir.exists()) {
|
||||||
|
QStringList files = appDir.entryList({QStringLiteral("*.desktop")}, QDir::Files);
|
||||||
|
for (const QString &file : files) {
|
||||||
|
QString fullPath = appDir.absoluteFilePath(file);
|
||||||
|
if (!paths.contains(fullPath)) {
|
||||||
|
paths << fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DesktopImporter::isGameDesktopFile(const QString &filePath) const
|
||||||
|
{
|
||||||
|
QSettings desktop(filePath, QSettings::IniFormat);
|
||||||
|
desktop.beginGroup(QStringLiteral("Desktop Entry"));
|
||||||
|
|
||||||
|
QString type = desktop.value(QStringLiteral("Type")).toString();
|
||||||
|
if (type != QStringLiteral("Application")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if hidden or not shown
|
||||||
|
if (desktop.value(QStringLiteral("Hidden"), false).toBool()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (desktop.value(QStringLiteral("NoDisplay"), false).toBool()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check categories for game-related entries
|
||||||
|
QString categories = desktop.value(QStringLiteral("Categories")).toString();
|
||||||
|
QStringList gameCategories = {QStringLiteral("Game"),
|
||||||
|
QStringLiteral("ArcadeGame"),
|
||||||
|
QStringLiteral("ActionGame"),
|
||||||
|
QStringLiteral("AdventureGame"),
|
||||||
|
QStringLiteral("BlocksGame"),
|
||||||
|
QStringLiteral("BoardGame"),
|
||||||
|
QStringLiteral("CardGame"),
|
||||||
|
QStringLiteral("KidsGame"),
|
||||||
|
QStringLiteral("LogicGame"),
|
||||||
|
QStringLiteral("RolePlaying"),
|
||||||
|
QStringLiteral("Shooter"),
|
||||||
|
QStringLiteral("Simulation"),
|
||||||
|
QStringLiteral("SportsGame"),
|
||||||
|
QStringLiteral("StrategyGame")};
|
||||||
|
|
||||||
|
for (const QString &cat : gameCategories) {
|
||||||
|
if (categories.contains(cat, Qt::CaseInsensitive)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *DesktopImporter::parseDesktopFile(const QString &filePath) const
|
||||||
|
{
|
||||||
|
QSettings desktop(filePath, QSettings::IniFormat);
|
||||||
|
desktop.beginGroup(QStringLiteral("Desktop Entry"));
|
||||||
|
|
||||||
|
QString name = desktop.value(QStringLiteral("Name")).toString();
|
||||||
|
QString exec = desktop.value(QStringLiteral("Exec")).toString();
|
||||||
|
QString icon = desktop.value(QStringLiteral("Icon")).toString();
|
||||||
|
QString comment = desktop.value(QStringLiteral("Comment")).toString();
|
||||||
|
QString genericName = desktop.value(QStringLiteral("GenericName")).toString();
|
||||||
|
|
||||||
|
if (name.isEmpty() || exec.isEmpty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the exec command - remove field codes like %f, %u, %F, %U
|
||||||
|
static QRegularExpression fieldCodeRegex(QStringLiteral("%[fFuUdDnNickvm]"));
|
||||||
|
exec = exec.remove(fieldCodeRegex).trimmed();
|
||||||
|
|
||||||
|
// Create unique ID from the desktop file name
|
||||||
|
QFileInfo fileInfo(filePath);
|
||||||
|
QString gameId = QStringLiteral("desktop_") + fileInfo.baseName();
|
||||||
|
|
||||||
|
Game *game = new Game(gameId, name);
|
||||||
|
game->setLaunchCommand(exec);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(platformId());
|
||||||
|
|
||||||
|
if (!comment.isEmpty()) {
|
||||||
|
game->setDescription(comment);
|
||||||
|
} else if (!genericName.isEmpty()) {
|
||||||
|
game->setDescription(genericName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find icon
|
||||||
|
if (!icon.isEmpty()) {
|
||||||
|
// If it's an absolute path, use it directly
|
||||||
|
if (QFile::exists(icon)) {
|
||||||
|
game->setIconUrl(QUrl::fromLocalFile(icon));
|
||||||
|
} else {
|
||||||
|
// Try to find in standard icon locations
|
||||||
|
QStringList iconPaths = {
|
||||||
|
expandPath(QStringLiteral("~/.local/share/icons/hicolor/256x256/apps/") + icon + QStringLiteral(".png")),
|
||||||
|
QStringLiteral("/usr/share/icons/hicolor/256x256/apps/") + icon + QStringLiteral(".png"),
|
||||||
|
QStringLiteral("/usr/share/pixmaps/") + icon + QStringLiteral(".png"),
|
||||||
|
expandPath(QStringLiteral("~/.local/share/icons/hicolor/128x128/apps/") + icon + QStringLiteral(".png")),
|
||||||
|
QStringLiteral("/usr/share/icons/hicolor/128x128/apps/") + icon + QStringLiteral(".png"),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &iconPath : iconPaths) {
|
||||||
|
if (QFile::exists(iconPath)) {
|
||||||
|
const QUrl url = QUrl::fromLocalFile(iconPath);
|
||||||
|
game->setIconUrl(url);
|
||||||
|
if (!game->coverUrl().isValid() || game->coverUrl().isEmpty()) {
|
||||||
|
game->setCoverUrl(url);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> DesktopImporter::importGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
QStringList desktopFiles = getDesktopFilePaths();
|
||||||
|
int total = desktopFiles.count();
|
||||||
|
int current = 0;
|
||||||
|
|
||||||
|
for (const QString &filePath : desktopFiles) {
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
|
||||||
|
if (!isGameDesktopFile(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = parseDesktopFile(filePath);
|
||||||
|
if (game) {
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
24
src/desktopimporter.h
Normal file
24
src/desktopimporter.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class DesktopImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit DesktopImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QStringList getDesktopFilePaths() const;
|
||||||
|
Game *parseDesktopFile(const QString &filePath) const;
|
||||||
|
bool isGameDesktopFile(const QString &filePath) const;
|
||||||
|
};
|
||||||
170
src/flatpakimporter.cpp
Normal file
170
src/flatpakimporter.cpp
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "flatpakimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSettings>
|
||||||
|
|
||||||
|
FlatpakImporter::FlatpakImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FlatpakImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("Flatpak");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FlatpakImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("flatpak");
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList FlatpakImporter::getFlatpakPaths() const
|
||||||
|
{
|
||||||
|
QStringList paths;
|
||||||
|
|
||||||
|
// User Flatpak exports
|
||||||
|
paths << expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/applications"));
|
||||||
|
// System Flatpak exports
|
||||||
|
paths << QStringLiteral("/var/lib/flatpak/exports/share/applications");
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FlatpakImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
return !findExecutable(QStringLiteral("flatpak")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FlatpakImporter::isGameApp(const QString &desktopFilePath) const
|
||||||
|
{
|
||||||
|
QSettings desktop(desktopFilePath, QSettings::IniFormat);
|
||||||
|
desktop.beginGroup(QStringLiteral("Desktop Entry"));
|
||||||
|
|
||||||
|
QString type = desktop.value(QStringLiteral("Type")).toString();
|
||||||
|
if (type != QStringLiteral("Application")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desktop.value(QStringLiteral("Hidden"), false).toBool()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (desktop.value(QStringLiteral("NoDisplay"), false).toBool()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString categories = desktop.value(QStringLiteral("Categories")).toString();
|
||||||
|
QStringList gameCategories = {QStringLiteral("Game"),
|
||||||
|
QStringLiteral("ArcadeGame"),
|
||||||
|
QStringLiteral("ActionGame"),
|
||||||
|
QStringLiteral("AdventureGame"),
|
||||||
|
QStringLiteral("BlocksGame"),
|
||||||
|
QStringLiteral("BoardGame"),
|
||||||
|
QStringLiteral("CardGame"),
|
||||||
|
QStringLiteral("KidsGame"),
|
||||||
|
QStringLiteral("LogicGame"),
|
||||||
|
QStringLiteral("RolePlaying"),
|
||||||
|
QStringLiteral("Shooter"),
|
||||||
|
QStringLiteral("Simulation"),
|
||||||
|
QStringLiteral("SportsGame"),
|
||||||
|
QStringLiteral("StrategyGame")};
|
||||||
|
|
||||||
|
for (const QString &cat : gameCategories) {
|
||||||
|
if (categories.contains(cat, Qt::CaseInsensitive)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *FlatpakImporter::parseDesktopFile(const QString &filePath, const QString &appId) const
|
||||||
|
{
|
||||||
|
QSettings desktop(filePath, QSettings::IniFormat);
|
||||||
|
desktop.beginGroup(QStringLiteral("Desktop Entry"));
|
||||||
|
|
||||||
|
QString name = desktop.value(QStringLiteral("Name")).toString();
|
||||||
|
QString icon = desktop.value(QStringLiteral("Icon")).toString();
|
||||||
|
QString comment = desktop.value(QStringLiteral("Comment")).toString();
|
||||||
|
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString gameId = QStringLiteral("flatpak_") + appId.toLower().replace(QStringLiteral("."), QStringLiteral("_"));
|
||||||
|
|
||||||
|
Game *game = new Game(gameId, name);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(platformId());
|
||||||
|
game->setLaunchCommand(QStringLiteral("flatpak run %1").arg(appId));
|
||||||
|
|
||||||
|
if (!comment.isEmpty()) {
|
||||||
|
game->setDescription(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find icon
|
||||||
|
if (!icon.isEmpty()) {
|
||||||
|
QStringList iconPaths = {
|
||||||
|
expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/icons/hicolor/256x256/apps/") + icon + QStringLiteral(".png")),
|
||||||
|
QStringLiteral("/var/lib/flatpak/exports/share/icons/hicolor/256x256/apps/") + icon + QStringLiteral(".png"),
|
||||||
|
expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/icons/hicolor/128x128/apps/") + icon + QStringLiteral(".png")),
|
||||||
|
QStringLiteral("/var/lib/flatpak/exports/share/icons/hicolor/128x128/apps/") + icon + QStringLiteral(".png"),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &iconPath : iconPaths) {
|
||||||
|
if (QFile::exists(iconPath)) {
|
||||||
|
const QUrl url = QUrl::fromLocalFile(iconPath);
|
||||||
|
game->setIconUrl(url);
|
||||||
|
if (!game->coverUrl().isValid() || game->coverUrl().isEmpty()) {
|
||||||
|
game->setCoverUrl(url);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> FlatpakImporter::importGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
for (const QString &path : getFlatpakPaths()) {
|
||||||
|
QDir dir(path);
|
||||||
|
if (!dir.exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList desktopFiles = dir.entryList({QStringLiteral("*.desktop")}, QDir::Files);
|
||||||
|
int total = desktopFiles.count();
|
||||||
|
int current = 0;
|
||||||
|
|
||||||
|
for (const QString &fileName : desktopFiles) {
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
|
||||||
|
QString filePath = dir.absoluteFilePath(fileName);
|
||||||
|
|
||||||
|
if (!isGameApp(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract app ID from filename (e.g., "com.example.Game.desktop" -> "com.example.Game")
|
||||||
|
QString appId = fileName;
|
||||||
|
appId.chop(8); // Remove ".desktop"
|
||||||
|
|
||||||
|
Game *game = parseDesktopFile(filePath, appId);
|
||||||
|
if (game) {
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
24
src/flatpakimporter.h
Normal file
24
src/flatpakimporter.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class FlatpakImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit FlatpakImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QStringList getFlatpakPaths() const;
|
||||||
|
bool isGameApp(const QString &desktopFilePath) const;
|
||||||
|
Game *parseDesktopFile(const QString &filePath, const QString &appId) const;
|
||||||
|
};
|
||||||
361
src/game.cpp
Normal file
361
src/game.cpp
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "game.h"
|
||||||
|
|
||||||
|
#include <KLocalizedString>
|
||||||
|
#include <QLocale>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
Game::Game(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_id(QUuid::createUuid().toString(QUuid::WithoutBraces))
|
||||||
|
, m_dateAdded(QDateTime::currentDateTime())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Game::Game(const QString &id, const QString &name, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_id(id.isEmpty() ? QUuid::createUuid().toString(QUuid::WithoutBraces) : id)
|
||||||
|
, m_name(name)
|
||||||
|
, m_dateAdded(QDateTime::currentDateTime())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::id() const
|
||||||
|
{
|
||||||
|
return m_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::name() const
|
||||||
|
{
|
||||||
|
return m_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setName(const QString &name)
|
||||||
|
{
|
||||||
|
if (m_name != name) {
|
||||||
|
m_name = name;
|
||||||
|
Q_EMIT nameChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::description() const
|
||||||
|
{
|
||||||
|
return m_description;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setDescription(const QString &description)
|
||||||
|
{
|
||||||
|
if (m_description != description) {
|
||||||
|
m_description = description;
|
||||||
|
Q_EMIT descriptionChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::developer() const
|
||||||
|
{
|
||||||
|
return m_developer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setDeveloper(const QString &developer)
|
||||||
|
{
|
||||||
|
if (m_developer != developer) {
|
||||||
|
m_developer = developer;
|
||||||
|
Q_EMIT developerChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::publisher() const
|
||||||
|
{
|
||||||
|
return m_publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setPublisher(const QString &publisher)
|
||||||
|
{
|
||||||
|
if (m_publisher != publisher) {
|
||||||
|
m_publisher = publisher;
|
||||||
|
Q_EMIT publisherChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QUrl Game::coverUrl() const
|
||||||
|
{
|
||||||
|
return m_coverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setCoverUrl(const QUrl &url)
|
||||||
|
{
|
||||||
|
if (m_coverUrl != url) {
|
||||||
|
m_coverUrl = url;
|
||||||
|
Q_EMIT coverUrlChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QUrl Game::iconUrl() const
|
||||||
|
{
|
||||||
|
return m_iconUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setIconUrl(const QUrl &url)
|
||||||
|
{
|
||||||
|
if (m_iconUrl != url) {
|
||||||
|
m_iconUrl = url;
|
||||||
|
Q_EMIT iconUrlChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::launchCommand() const
|
||||||
|
{
|
||||||
|
return m_launchCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setLaunchCommand(const QString &command)
|
||||||
|
{
|
||||||
|
if (m_launchCommand != command) {
|
||||||
|
m_launchCommand = command;
|
||||||
|
Q_EMIT launchCommandChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::workingDirectory() const
|
||||||
|
{
|
||||||
|
return m_workingDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setWorkingDirectory(const QString &dir)
|
||||||
|
{
|
||||||
|
if (m_workingDirectory != dir) {
|
||||||
|
m_workingDirectory = dir;
|
||||||
|
Q_EMIT workingDirectoryChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::platform() const
|
||||||
|
{
|
||||||
|
return m_platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setPlatform(const QString &platform)
|
||||||
|
{
|
||||||
|
if (m_platform != platform) {
|
||||||
|
m_platform = platform;
|
||||||
|
Q_EMIT platformChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::platformId() const
|
||||||
|
{
|
||||||
|
return m_platformId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setPlatformId(const QString &id)
|
||||||
|
{
|
||||||
|
if (m_platformId != id) {
|
||||||
|
m_platformId = id;
|
||||||
|
Q_EMIT platformIdChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QDateTime Game::dateAdded() const
|
||||||
|
{
|
||||||
|
return m_dateAdded;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setDateAdded(const QDateTime &dateTime)
|
||||||
|
{
|
||||||
|
if (m_dateAdded != dateTime) {
|
||||||
|
m_dateAdded = dateTime;
|
||||||
|
Q_EMIT dateAddedChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QDateTime Game::lastPlayed() const
|
||||||
|
{
|
||||||
|
return m_lastPlayed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setLastPlayed(const QDateTime &dateTime)
|
||||||
|
{
|
||||||
|
if (m_lastPlayed != dateTime) {
|
||||||
|
m_lastPlayed = dateTime;
|
||||||
|
Q_EMIT lastPlayedChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 Game::playTime() const
|
||||||
|
{
|
||||||
|
return m_playTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setPlayTime(qint64 seconds)
|
||||||
|
{
|
||||||
|
if (m_playTime != seconds) {
|
||||||
|
m_playTime = seconds;
|
||||||
|
Q_EMIT playTimeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Game::favorite() const
|
||||||
|
{
|
||||||
|
return m_favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setFavorite(bool favorite)
|
||||||
|
{
|
||||||
|
if (m_favorite != favorite) {
|
||||||
|
m_favorite = favorite;
|
||||||
|
Q_EMIT favoriteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Game::hidden() const
|
||||||
|
{
|
||||||
|
return m_hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setHidden(bool hidden)
|
||||||
|
{
|
||||||
|
if (m_hidden != hidden) {
|
||||||
|
m_hidden = hidden;
|
||||||
|
Q_EMIT hiddenChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Game::installed() const
|
||||||
|
{
|
||||||
|
return m_installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setInstalled(bool installed)
|
||||||
|
{
|
||||||
|
if (m_installed != installed) {
|
||||||
|
m_installed = installed;
|
||||||
|
Q_EMIT installedChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Game::running() const
|
||||||
|
{
|
||||||
|
return m_running;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Game::setRunning(bool running)
|
||||||
|
{
|
||||||
|
if (m_running != running) {
|
||||||
|
m_running = running;
|
||||||
|
Q_EMIT runningChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject Game::toJson() const
|
||||||
|
{
|
||||||
|
QJsonObject obj;
|
||||||
|
obj[QStringLiteral("id")] = m_id;
|
||||||
|
obj[QStringLiteral("name")] = m_name;
|
||||||
|
obj[QStringLiteral("description")] = m_description;
|
||||||
|
obj[QStringLiteral("developer")] = m_developer;
|
||||||
|
obj[QStringLiteral("publisher")] = m_publisher;
|
||||||
|
obj[QStringLiteral("coverUrl")] = m_coverUrl.toString();
|
||||||
|
obj[QStringLiteral("iconUrl")] = m_iconUrl.toString();
|
||||||
|
obj[QStringLiteral("launchCommand")] = m_launchCommand;
|
||||||
|
obj[QStringLiteral("workingDirectory")] = m_workingDirectory;
|
||||||
|
obj[QStringLiteral("platform")] = m_platform;
|
||||||
|
obj[QStringLiteral("platformId")] = m_platformId;
|
||||||
|
obj[QStringLiteral("dateAdded")] = m_dateAdded.toString(Qt::ISODate);
|
||||||
|
obj[QStringLiteral("lastPlayed")] = m_lastPlayed.toString(Qt::ISODate);
|
||||||
|
obj[QStringLiteral("playTime")] = m_playTime;
|
||||||
|
obj[QStringLiteral("favorite")] = m_favorite;
|
||||||
|
obj[QStringLiteral("hidden")] = m_hidden;
|
||||||
|
obj[QStringLiteral("installed")] = m_installed;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *Game::fromJson(const QJsonObject &json, QObject *parent)
|
||||||
|
{
|
||||||
|
QString id = json[QStringLiteral("id")].toString();
|
||||||
|
QString name = json[QStringLiteral("name")].toString();
|
||||||
|
|
||||||
|
if (id.isEmpty() || name.isEmpty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = new Game(id, name, parent);
|
||||||
|
game->setDescription(json[QStringLiteral("description")].toString());
|
||||||
|
game->setDeveloper(json[QStringLiteral("developer")].toString());
|
||||||
|
game->setPublisher(json[QStringLiteral("publisher")].toString());
|
||||||
|
game->setCoverUrl(QUrl(json[QStringLiteral("coverUrl")].toString()));
|
||||||
|
game->setIconUrl(QUrl(json[QStringLiteral("iconUrl")].toString()));
|
||||||
|
game->setLaunchCommand(json[QStringLiteral("launchCommand")].toString());
|
||||||
|
game->setWorkingDirectory(json[QStringLiteral("workingDirectory")].toString());
|
||||||
|
game->setPlatform(json[QStringLiteral("platform")].toString());
|
||||||
|
game->setPlatformId(json[QStringLiteral("platformId")].toString());
|
||||||
|
game->setDateAdded(QDateTime::fromString(json[QStringLiteral("dateAdded")].toString(), Qt::ISODate));
|
||||||
|
game->setLastPlayed(QDateTime::fromString(json[QStringLiteral("lastPlayed")].toString(), Qt::ISODate));
|
||||||
|
game->setPlayTime(json[QStringLiteral("playTime")].toInteger());
|
||||||
|
game->setFavorite(json[QStringLiteral("favorite")].toBool());
|
||||||
|
game->setHidden(json[QStringLiteral("hidden")].toBool());
|
||||||
|
game->setInstalled(json[QStringLiteral("installed")].toBool(true));
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::playTimeFormatted() const
|
||||||
|
{
|
||||||
|
if (m_playTime == 0) {
|
||||||
|
return i18n("Never played");
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 hours = m_playTime / 3600;
|
||||||
|
qint64 minutes = (m_playTime % 3600) / 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return i18np("%1 hour", "%1 hours", hours) + QStringLiteral(" ") + i18np("%1 minute", "%1 minutes", minutes);
|
||||||
|
}
|
||||||
|
return i18np("%1 minute", "%1 minutes", minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::lastPlayedFormatted() const
|
||||||
|
{
|
||||||
|
if (!m_lastPlayed.isValid()) {
|
||||||
|
return i18n("Never");
|
||||||
|
}
|
||||||
|
|
||||||
|
QDateTime now = QDateTime::currentDateTime();
|
||||||
|
qint64 days = m_lastPlayed.daysTo(now);
|
||||||
|
|
||||||
|
if (days == 0) {
|
||||||
|
return i18n("Today");
|
||||||
|
} else if (days == 1) {
|
||||||
|
return i18n("Yesterday");
|
||||||
|
} else if (days < 7) {
|
||||||
|
return i18np("%1 day ago", "%1 days ago", days);
|
||||||
|
} else if (days < 30) {
|
||||||
|
return i18np("%1 week ago", "%1 weeks ago", days / 7);
|
||||||
|
} else if (days < 365) {
|
||||||
|
return i18np("%1 month ago", "%1 months ago", days / 30);
|
||||||
|
}
|
||||||
|
return QLocale().toString(m_lastPlayed, QLocale::ShortFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Game::dateAddedFormatted() const
|
||||||
|
{
|
||||||
|
if (!m_dateAdded.isValid()) {
|
||||||
|
return i18n("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
QDateTime now = QDateTime::currentDateTime();
|
||||||
|
qint64 days = m_dateAdded.daysTo(now);
|
||||||
|
|
||||||
|
if (days == 0) {
|
||||||
|
return i18n("Today");
|
||||||
|
} else if (days == 1) {
|
||||||
|
return i18n("Yesterday");
|
||||||
|
} else if (days < 7) {
|
||||||
|
return i18np("%1 day ago", "%1 days ago", days);
|
||||||
|
} else if (days < 30) {
|
||||||
|
return i18np("%1 week ago", "%1 weeks ago", days / 7);
|
||||||
|
} else if (days < 365) {
|
||||||
|
return i18np("%1 month ago", "%1 months ago", days / 30);
|
||||||
|
}
|
||||||
|
return QLocale().toString(m_dateAdded, QLocale::ShortFormat);
|
||||||
|
}
|
||||||
140
src/game.h
Normal file
140
src/game.h
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QString>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
class Game : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Game objects are created by GameModel")
|
||||||
|
|
||||||
|
Q_PROPERTY(QString id READ id CONSTANT)
|
||||||
|
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
|
||||||
|
Q_PROPERTY(QString description READ description WRITE setDescription NOTIFY descriptionChanged)
|
||||||
|
Q_PROPERTY(QString developer READ developer WRITE setDeveloper NOTIFY developerChanged)
|
||||||
|
Q_PROPERTY(QString publisher READ publisher WRITE setPublisher NOTIFY publisherChanged)
|
||||||
|
Q_PROPERTY(QUrl coverUrl READ coverUrl WRITE setCoverUrl NOTIFY coverUrlChanged)
|
||||||
|
Q_PROPERTY(QUrl iconUrl READ iconUrl WRITE setIconUrl NOTIFY iconUrlChanged)
|
||||||
|
Q_PROPERTY(QString launchCommand READ launchCommand WRITE setLaunchCommand NOTIFY launchCommandChanged)
|
||||||
|
Q_PROPERTY(QString workingDirectory READ workingDirectory WRITE setWorkingDirectory NOTIFY workingDirectoryChanged)
|
||||||
|
Q_PROPERTY(QString platform READ platform WRITE setPlatform NOTIFY platformChanged)
|
||||||
|
Q_PROPERTY(QString platformId READ platformId WRITE setPlatformId NOTIFY platformIdChanged)
|
||||||
|
Q_PROPERTY(QDateTime dateAdded READ dateAdded WRITE setDateAdded NOTIFY dateAddedChanged)
|
||||||
|
Q_PROPERTY(QDateTime lastPlayed READ lastPlayed WRITE setLastPlayed NOTIFY lastPlayedChanged)
|
||||||
|
Q_PROPERTY(qint64 playTime READ playTime WRITE setPlayTime NOTIFY playTimeChanged)
|
||||||
|
Q_PROPERTY(bool favorite READ favorite WRITE setFavorite NOTIFY favoriteChanged)
|
||||||
|
Q_PROPERTY(bool hidden READ hidden WRITE setHidden NOTIFY hiddenChanged)
|
||||||
|
Q_PROPERTY(bool installed READ installed WRITE setInstalled NOTIFY installedChanged)
|
||||||
|
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Game(QObject *parent = nullptr);
|
||||||
|
Game(const QString &id, const QString &name, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString id() const;
|
||||||
|
|
||||||
|
QString name() const;
|
||||||
|
void setName(const QString &name);
|
||||||
|
|
||||||
|
QString description() const;
|
||||||
|
void setDescription(const QString &description);
|
||||||
|
|
||||||
|
QString developer() const;
|
||||||
|
void setDeveloper(const QString &developer);
|
||||||
|
|
||||||
|
QString publisher() const;
|
||||||
|
void setPublisher(const QString &publisher);
|
||||||
|
|
||||||
|
QUrl coverUrl() const;
|
||||||
|
void setCoverUrl(const QUrl &url);
|
||||||
|
|
||||||
|
QUrl iconUrl() const;
|
||||||
|
void setIconUrl(const QUrl &url);
|
||||||
|
|
||||||
|
QString launchCommand() const;
|
||||||
|
void setLaunchCommand(const QString &command);
|
||||||
|
|
||||||
|
QString workingDirectory() const;
|
||||||
|
void setWorkingDirectory(const QString &dir);
|
||||||
|
|
||||||
|
QString platform() const;
|
||||||
|
void setPlatform(const QString &platform);
|
||||||
|
|
||||||
|
QString platformId() const;
|
||||||
|
void setPlatformId(const QString &id);
|
||||||
|
|
||||||
|
QDateTime dateAdded() const;
|
||||||
|
void setDateAdded(const QDateTime &dateTime);
|
||||||
|
|
||||||
|
QDateTime lastPlayed() const;
|
||||||
|
void setLastPlayed(const QDateTime &dateTime);
|
||||||
|
|
||||||
|
qint64 playTime() const;
|
||||||
|
void setPlayTime(qint64 seconds);
|
||||||
|
|
||||||
|
bool favorite() const;
|
||||||
|
void setFavorite(bool favorite);
|
||||||
|
|
||||||
|
bool hidden() const;
|
||||||
|
void setHidden(bool hidden);
|
||||||
|
|
||||||
|
bool installed() const;
|
||||||
|
void setInstalled(bool installed);
|
||||||
|
|
||||||
|
bool running() const;
|
||||||
|
void setRunning(bool running);
|
||||||
|
|
||||||
|
QJsonObject toJson() const;
|
||||||
|
static Game *fromJson(const QJsonObject &json, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
Q_INVOKABLE QString playTimeFormatted() const;
|
||||||
|
Q_INVOKABLE QString lastPlayedFormatted() const;
|
||||||
|
Q_INVOKABLE QString dateAddedFormatted() const;
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void nameChanged();
|
||||||
|
void descriptionChanged();
|
||||||
|
void developerChanged();
|
||||||
|
void publisherChanged();
|
||||||
|
void coverUrlChanged();
|
||||||
|
void iconUrlChanged();
|
||||||
|
void launchCommandChanged();
|
||||||
|
void workingDirectoryChanged();
|
||||||
|
void platformChanged();
|
||||||
|
void platformIdChanged();
|
||||||
|
void dateAddedChanged();
|
||||||
|
void lastPlayedChanged();
|
||||||
|
void playTimeChanged();
|
||||||
|
void favoriteChanged();
|
||||||
|
void hiddenChanged();
|
||||||
|
void installedChanged();
|
||||||
|
void runningChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_id;
|
||||||
|
QString m_name;
|
||||||
|
QString m_description;
|
||||||
|
QString m_developer;
|
||||||
|
QString m_publisher;
|
||||||
|
QUrl m_coverUrl;
|
||||||
|
QUrl m_iconUrl;
|
||||||
|
QString m_launchCommand;
|
||||||
|
QString m_workingDirectory;
|
||||||
|
QString m_platform;
|
||||||
|
QString m_platformId;
|
||||||
|
QDateTime m_dateAdded;
|
||||||
|
QDateTime m_lastPlayed;
|
||||||
|
qint64 m_playTime = 0;
|
||||||
|
bool m_favorite = false;
|
||||||
|
bool m_hidden = false;
|
||||||
|
bool m_installed = true;
|
||||||
|
bool m_running = false;
|
||||||
|
};
|
||||||
202
src/gamelauncher.cpp
Normal file
202
src/gamelauncher.cpp
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "gamelauncher.h"
|
||||||
|
#include "app.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
GameLauncher::GameLauncher(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
GameLauncher::~GameLauncher()
|
||||||
|
{
|
||||||
|
// Cleanup all running processes
|
||||||
|
for (QProcess *process : m_runningGames.values()) {
|
||||||
|
process->disconnect();
|
||||||
|
process->terminate();
|
||||||
|
process->waitForFinished(3000);
|
||||||
|
delete process;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::hasRunningGames() const
|
||||||
|
{
|
||||||
|
return !m_runningGames.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncher::launchGame(Game *game)
|
||||||
|
{
|
||||||
|
if (!game) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString command = game->launchCommand();
|
||||||
|
if (command.isEmpty()) {
|
||||||
|
Q_EMIT gameError(game, tr("No launch command configured"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already running
|
||||||
|
if (m_runningGames.contains(game->id())) {
|
||||||
|
Q_EMIT gameError(game, tr("Game is already running"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Steam URLs
|
||||||
|
if (command.startsWith(QLatin1String("steam://"))) {
|
||||||
|
QDesktopServices::openUrl(QUrl(command));
|
||||||
|
game->setLastPlayed(QDateTime::currentDateTime());
|
||||||
|
Q_EMIT gameStarted(game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Lutris URLs
|
||||||
|
if (command.startsWith(QLatin1String("lutris "))) {
|
||||||
|
QString lutrisCommand = command.mid(7); // Remove "lutris "
|
||||||
|
QDesktopServices::openUrl(QUrl(lutrisCommand));
|
||||||
|
game->setLastPlayed(QDateTime::currentDateTime());
|
||||||
|
Q_EMIT gameStarted(game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start process for other commands
|
||||||
|
QProcess *process = new QProcess(this);
|
||||||
|
|
||||||
|
// Set working directory if available
|
||||||
|
if (!game->workingDirectory().isEmpty()) {
|
||||||
|
process->setWorkingDirectory(game->workingDirectory());
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this, &GameLauncher::onProcessFinished);
|
||||||
|
connect(process, &QProcess::errorOccurred, this, &GameLauncher::onProcessError);
|
||||||
|
|
||||||
|
m_runningGames.insert(game->id(), process);
|
||||||
|
m_processToGame.insert(process, game);
|
||||||
|
|
||||||
|
// Parse command and arguments
|
||||||
|
QStringList parts = QProcess::splitCommand(command);
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
cleanupProcess(process);
|
||||||
|
Q_EMIT gameError(game, tr("Invalid launch command"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString program = parts.takeFirst();
|
||||||
|
process->start(program, parts);
|
||||||
|
|
||||||
|
if (!process->waitForStarted(5000)) {
|
||||||
|
QString error = process->errorString();
|
||||||
|
cleanupProcess(process);
|
||||||
|
Q_EMIT gameError(game, tr("Failed to start game: %1").arg(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game->setRunning(true);
|
||||||
|
game->setLastPlayed(QDateTime::currentDateTime());
|
||||||
|
Q_EMIT gameStarted(game);
|
||||||
|
Q_EMIT runningGamesChanged();
|
||||||
|
|
||||||
|
// Exit after launch if configured
|
||||||
|
if (App::instance()->config()->exitAfterLaunch()) {
|
||||||
|
QTimer::singleShot(500, qApp, &QCoreApplication::quit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncher::stopGame(Game *game)
|
||||||
|
{
|
||||||
|
if (!game || !m_runningGames.contains(game->id())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProcess *process = m_runningGames.value(game->id());
|
||||||
|
process->terminate();
|
||||||
|
|
||||||
|
if (!process->waitForFinished(5000)) {
|
||||||
|
process->kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::isGameRunning(Game *game) const
|
||||||
|
{
|
||||||
|
return game && m_runningGames.contains(game->id());
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncher::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus)
|
||||||
|
{
|
||||||
|
Q_UNUSED(exitStatus)
|
||||||
|
|
||||||
|
QProcess *process = qobject_cast<QProcess *>(sender());
|
||||||
|
if (!process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = m_processToGame.value(process);
|
||||||
|
if (game) {
|
||||||
|
game->setRunning(false);
|
||||||
|
Q_EMIT gameStopped(game, exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupProcess(process);
|
||||||
|
Q_EMIT runningGamesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncher::onProcessError(QProcess::ProcessError error)
|
||||||
|
{
|
||||||
|
QProcess *process = qobject_cast<QProcess *>(sender());
|
||||||
|
if (!process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = m_processToGame.value(process);
|
||||||
|
|
||||||
|
QString errorMessage;
|
||||||
|
switch (error) {
|
||||||
|
case QProcess::FailedToStart:
|
||||||
|
errorMessage = tr("Failed to start the game process");
|
||||||
|
break;
|
||||||
|
case QProcess::Crashed:
|
||||||
|
errorMessage = tr("Game process crashed");
|
||||||
|
break;
|
||||||
|
case QProcess::Timedout:
|
||||||
|
errorMessage = tr("Game process timed out");
|
||||||
|
break;
|
||||||
|
case QProcess::WriteError:
|
||||||
|
errorMessage = tr("Failed to write to game process");
|
||||||
|
break;
|
||||||
|
case QProcess::ReadError:
|
||||||
|
errorMessage = tr("Failed to read from game process");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = tr("Unknown error occurred");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game) {
|
||||||
|
game->setRunning(false);
|
||||||
|
Q_EMIT gameError(game, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupProcess(process);
|
||||||
|
Q_EMIT runningGamesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncher::cleanupProcess(QProcess *process)
|
||||||
|
{
|
||||||
|
if (!process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = m_processToGame.take(process);
|
||||||
|
if (game) {
|
||||||
|
m_runningGames.remove(game->id());
|
||||||
|
}
|
||||||
|
|
||||||
|
process->deleteLater();
|
||||||
|
}
|
||||||
45
src/gamelauncher.h
Normal file
45
src/gamelauncher.h
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMap>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
|
||||||
|
#include "game.h"
|
||||||
|
|
||||||
|
class GameLauncher : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
|
||||||
|
Q_PROPERTY(bool hasRunningGames READ hasRunningGames NOTIFY runningGamesChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GameLauncher(QObject *parent = nullptr);
|
||||||
|
~GameLauncher();
|
||||||
|
|
||||||
|
bool hasRunningGames() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE void launchGame(Game *game);
|
||||||
|
Q_INVOKABLE void stopGame(Game *game);
|
||||||
|
Q_INVOKABLE bool isGameRunning(Game *game) const;
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void gameStarted(Game *game);
|
||||||
|
void gameStopped(Game *game, int exitCode);
|
||||||
|
void gameError(Game *game, const QString &error);
|
||||||
|
void runningGamesChanged();
|
||||||
|
|
||||||
|
private Q_SLOTS:
|
||||||
|
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||||
|
void onProcessError(QProcess::ProcessError error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QMap<QString, QProcess *> m_runningGames;
|
||||||
|
QMap<QProcess *, Game *> m_processToGame;
|
||||||
|
|
||||||
|
void cleanupProcess(QProcess *process);
|
||||||
|
};
|
||||||
345
src/gamemodel.cpp
Normal file
345
src/gamemodel.cpp
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "gamemodel.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
GameModel::GameModel(QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int GameModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return m_filteredGames.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant GameModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_filteredGames.count()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = m_filteredGames.at(index.row());
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case IdRole:
|
||||||
|
return game->id();
|
||||||
|
case NameRole:
|
||||||
|
case Qt::DisplayRole:
|
||||||
|
return game->name();
|
||||||
|
case DescriptionRole:
|
||||||
|
return game->description();
|
||||||
|
case DeveloperRole:
|
||||||
|
return game->developer();
|
||||||
|
case PublisherRole:
|
||||||
|
return game->publisher();
|
||||||
|
case CoverUrlRole:
|
||||||
|
return game->coverUrl();
|
||||||
|
case IconUrlRole:
|
||||||
|
return game->iconUrl();
|
||||||
|
case LaunchCommandRole:
|
||||||
|
return game->launchCommand();
|
||||||
|
case PlatformRole:
|
||||||
|
return game->platform();
|
||||||
|
case PlatformIdRole:
|
||||||
|
return game->platformId();
|
||||||
|
case LastPlayedRole:
|
||||||
|
return game->lastPlayed();
|
||||||
|
case PlayTimeRole:
|
||||||
|
return game->playTime();
|
||||||
|
case FavoriteRole:
|
||||||
|
return game->favorite();
|
||||||
|
case HiddenRole:
|
||||||
|
return game->hidden();
|
||||||
|
case InstalledRole:
|
||||||
|
return game->installed();
|
||||||
|
case RunningRole:
|
||||||
|
return game->running();
|
||||||
|
case GameObjectRole:
|
||||||
|
return QVariant::fromValue(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameModel::setData(const QModelIndex &index, const QVariant &value, int role)
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_filteredGames.count()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = m_filteredGames.at(index.row());
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case FavoriteRole:
|
||||||
|
game->setFavorite(value.toBool());
|
||||||
|
break;
|
||||||
|
case HiddenRole:
|
||||||
|
game->setHidden(value.toBool());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Q_EMIT dataChanged(index, index, {role});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Qt::ItemFlags GameModel::flags(const QModelIndex &index) const
|
||||||
|
{
|
||||||
|
if (!index.isValid()) {
|
||||||
|
return Qt::NoItemFlags;
|
||||||
|
}
|
||||||
|
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> GameModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {{IdRole, "gameId"},
|
||||||
|
{NameRole, "name"},
|
||||||
|
{DescriptionRole, "description"},
|
||||||
|
{DeveloperRole, "developer"},
|
||||||
|
{PublisherRole, "publisher"},
|
||||||
|
{CoverUrlRole, "coverUrl"},
|
||||||
|
{IconUrlRole, "iconUrl"},
|
||||||
|
{LaunchCommandRole, "launchCommand"},
|
||||||
|
{PlatformRole, "platform"},
|
||||||
|
{PlatformIdRole, "platformId"},
|
||||||
|
{LastPlayedRole, "lastPlayed"},
|
||||||
|
{PlayTimeRole, "playTime"},
|
||||||
|
{FavoriteRole, "favorite"},
|
||||||
|
{HiddenRole, "hidden"},
|
||||||
|
{InstalledRole, "installed"},
|
||||||
|
{RunningRole, "running"},
|
||||||
|
{GameObjectRole, "gameObject"}};
|
||||||
|
}
|
||||||
|
|
||||||
|
int GameModel::count() const
|
||||||
|
{
|
||||||
|
return m_filteredGames.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString GameModel::filterText() const
|
||||||
|
{
|
||||||
|
return m_filterText;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::setFilterText(const QString &text)
|
||||||
|
{
|
||||||
|
if (m_filterText != text) {
|
||||||
|
m_filterText = text;
|
||||||
|
applyFilter();
|
||||||
|
Q_EMIT filterTextChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString GameModel::filterPlatform() const
|
||||||
|
{
|
||||||
|
return m_filterPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::setFilterPlatform(const QString &platform)
|
||||||
|
{
|
||||||
|
if (m_filterPlatform != platform) {
|
||||||
|
m_filterPlatform = platform;
|
||||||
|
applyFilter();
|
||||||
|
Q_EMIT filterPlatformChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameModel::showHidden() const
|
||||||
|
{
|
||||||
|
return m_showHidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::setShowHidden(bool show)
|
||||||
|
{
|
||||||
|
if (m_showHidden != show) {
|
||||||
|
m_showHidden = show;
|
||||||
|
applyFilter();
|
||||||
|
Q_EMIT showHiddenChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameModel::favoritesOnly() const
|
||||||
|
{
|
||||||
|
return m_favoritesOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::setFavoritesOnly(bool favorites)
|
||||||
|
{
|
||||||
|
if (m_favoritesOnly != favorites) {
|
||||||
|
m_favoritesOnly = favorites;
|
||||||
|
applyFilter();
|
||||||
|
Q_EMIT favoritesOnlyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GameModel::SortMode GameModel::sortMode() const
|
||||||
|
{
|
||||||
|
return m_sortMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::setSortMode(SortMode mode)
|
||||||
|
{
|
||||||
|
if (m_sortMode != mode) {
|
||||||
|
m_sortMode = mode;
|
||||||
|
applySort();
|
||||||
|
Q_EMIT sortModeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::addGame(Game *game)
|
||||||
|
{
|
||||||
|
if (!game)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
for (Game *existing : m_games) {
|
||||||
|
if (existing->platform() == game->platform() && existing->platformId() == game->platformId()) {
|
||||||
|
delete game;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
game->setParent(this);
|
||||||
|
|
||||||
|
beginInsertRows(QModelIndex(), m_games.count(), m_games.count());
|
||||||
|
m_games.append(game);
|
||||||
|
endInsertRows();
|
||||||
|
|
||||||
|
applyFilter();
|
||||||
|
Q_EMIT countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::removeGame(const QString &id)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < m_games.count(); ++i) {
|
||||||
|
if (m_games.at(i)->id() == id) {
|
||||||
|
int filteredIndex = m_filteredGames.indexOf(m_games.at(i));
|
||||||
|
if (filteredIndex >= 0) {
|
||||||
|
beginRemoveRows(QModelIndex(), filteredIndex, filteredIndex);
|
||||||
|
m_filteredGames.removeAt(filteredIndex);
|
||||||
|
endRemoveRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
delete m_games.takeAt(i);
|
||||||
|
Q_EMIT countChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *GameModel::gameAt(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_filteredGames.count()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return m_filteredGames.at(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *GameModel::gameById(const QString &id) const
|
||||||
|
{
|
||||||
|
for (Game *game : m_games) {
|
||||||
|
if (game->id() == id) {
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::clear()
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
qDeleteAll(m_games);
|
||||||
|
m_games.clear();
|
||||||
|
m_filteredGames.clear();
|
||||||
|
endResetModel();
|
||||||
|
Q_EMIT countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList GameModel::platforms() const
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
for (Game *game : m_games) {
|
||||||
|
if (!result.contains(game->platform())) {
|
||||||
|
result.append(game->platform());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameModel::matchesFilter(Game *game) const
|
||||||
|
{
|
||||||
|
if (!m_showHidden && game->hidden()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_favoritesOnly && !game->favorite()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_filterPlatform.isEmpty() && game->platform() != m_filterPlatform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_filterText.isEmpty()) {
|
||||||
|
bool matches = game->name().contains(m_filterText, Qt::CaseInsensitive) || game->developer().contains(m_filterText, Qt::CaseInsensitive)
|
||||||
|
|| game->publisher().contains(m_filterText, Qt::CaseInsensitive);
|
||||||
|
if (!matches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::applyFilter()
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
|
||||||
|
m_filteredGames.clear();
|
||||||
|
for (Game *game : m_games) {
|
||||||
|
if (matchesFilter(game)) {
|
||||||
|
m_filteredGames.append(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applySort();
|
||||||
|
|
||||||
|
endResetModel();
|
||||||
|
Q_EMIT countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameModel::applySort()
|
||||||
|
{
|
||||||
|
std::sort(m_filteredGames.begin(), m_filteredGames.end(), [this](Game *a, Game *b) {
|
||||||
|
// Favorites always first
|
||||||
|
if (a->favorite() != b->favorite()) {
|
||||||
|
return a->favorite();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (m_sortMode) {
|
||||||
|
case SortByName:
|
||||||
|
return a->name().compare(b->name(), Qt::CaseInsensitive) < 0;
|
||||||
|
case SortByLastPlayed:
|
||||||
|
return a->lastPlayed() > b->lastPlayed();
|
||||||
|
case SortByPlayTime:
|
||||||
|
return a->playTime() > b->playTime();
|
||||||
|
case SortByPlatform:
|
||||||
|
if (a->platform() != b->platform()) {
|
||||||
|
return a->platform() < b->platform();
|
||||||
|
}
|
||||||
|
return a->name().compare(b->name(), Qt::CaseInsensitive) < 0;
|
||||||
|
case SortByRecent:
|
||||||
|
return a->lastPlayed() > b->lastPlayed();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
108
src/gamemodel.h
Normal file
108
src/gamemodel.h
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
|
||||||
|
#include "game.h"
|
||||||
|
|
||||||
|
class GameModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged)
|
||||||
|
Q_PROPERTY(QString filterPlatform READ filterPlatform WRITE setFilterPlatform NOTIFY filterPlatformChanged)
|
||||||
|
Q_PROPERTY(bool showHidden READ showHidden WRITE setShowHidden NOTIFY showHiddenChanged)
|
||||||
|
Q_PROPERTY(bool favoritesOnly READ favoritesOnly WRITE setFavoritesOnly NOTIFY favoritesOnlyChanged)
|
||||||
|
Q_PROPERTY(SortMode sortMode READ sortMode WRITE setSortMode NOTIFY sortModeChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
IdRole = Qt::UserRole + 1,
|
||||||
|
NameRole,
|
||||||
|
DescriptionRole,
|
||||||
|
DeveloperRole,
|
||||||
|
PublisherRole,
|
||||||
|
CoverUrlRole,
|
||||||
|
IconUrlRole,
|
||||||
|
LaunchCommandRole,
|
||||||
|
PlatformRole,
|
||||||
|
PlatformIdRole,
|
||||||
|
LastPlayedRole,
|
||||||
|
PlayTimeRole,
|
||||||
|
FavoriteRole,
|
||||||
|
HiddenRole,
|
||||||
|
InstalledRole,
|
||||||
|
RunningRole,
|
||||||
|
GameObjectRole
|
||||||
|
};
|
||||||
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
|
enum SortMode {
|
||||||
|
SortByName,
|
||||||
|
SortByLastPlayed,
|
||||||
|
SortByPlayTime,
|
||||||
|
SortByPlatform,
|
||||||
|
SortByRecent
|
||||||
|
};
|
||||||
|
Q_ENUM(SortMode)
|
||||||
|
|
||||||
|
explicit GameModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
|
||||||
|
Qt::ItemFlags flags(const QModelIndex &index) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
int count() const;
|
||||||
|
|
||||||
|
QString filterText() const;
|
||||||
|
void setFilterText(const QString &text);
|
||||||
|
|
||||||
|
QString filterPlatform() const;
|
||||||
|
void setFilterPlatform(const QString &platform);
|
||||||
|
|
||||||
|
bool showHidden() const;
|
||||||
|
void setShowHidden(bool show);
|
||||||
|
|
||||||
|
bool favoritesOnly() const;
|
||||||
|
void setFavoritesOnly(bool favorites);
|
||||||
|
|
||||||
|
SortMode sortMode() const;
|
||||||
|
void setSortMode(SortMode mode);
|
||||||
|
|
||||||
|
Q_INVOKABLE void addGame(Game *game);
|
||||||
|
Q_INVOKABLE void removeGame(const QString &id);
|
||||||
|
Q_INVOKABLE Game *gameAt(int index) const;
|
||||||
|
Q_INVOKABLE Game *gameById(const QString &id) const;
|
||||||
|
Q_INVOKABLE void clear();
|
||||||
|
Q_INVOKABLE QStringList platforms() const;
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void countChanged();
|
||||||
|
void filterTextChanged();
|
||||||
|
void filterPlatformChanged();
|
||||||
|
void showHiddenChanged();
|
||||||
|
void favoritesOnlyChanged();
|
||||||
|
void sortModeChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QList<Game *> m_games;
|
||||||
|
QList<Game *> m_filteredGames;
|
||||||
|
|
||||||
|
QString m_filterText;
|
||||||
|
QString m_filterPlatform;
|
||||||
|
bool m_showHidden = false;
|
||||||
|
bool m_favoritesOnly = false;
|
||||||
|
SortMode m_sortMode = SortByName;
|
||||||
|
|
||||||
|
void applyFilter();
|
||||||
|
void applySort();
|
||||||
|
bool matchesFilter(Game *game) const;
|
||||||
|
};
|
||||||
232
src/gamepadmanager.cpp
Normal file
232
src/gamepadmanager.cpp
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "gamepadmanager.h"
|
||||||
|
|
||||||
|
#if defined(ALAKARTE_HAVE_QT_GAMEPAD)
|
||||||
|
|
||||||
|
#include <QGamepad>
|
||||||
|
#include <QGamepadManager>
|
||||||
|
|
||||||
|
GamepadManager *GamepadManager::s_instance = nullptr;
|
||||||
|
|
||||||
|
GamepadManager::GamepadManager(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
auto *manager = QGamepadManager::instance();
|
||||||
|
|
||||||
|
connect(manager, &QGamepadManager::gamepadConnected, this, &GamepadManager::onGamepadConnected);
|
||||||
|
connect(manager, &QGamepadManager::gamepadDisconnected, this, &GamepadManager::onGamepadDisconnected);
|
||||||
|
|
||||||
|
m_repeatTimer.setInterval(150);
|
||||||
|
connect(&m_repeatTimer, &QTimer::timeout, this, &GamepadManager::processInput);
|
||||||
|
|
||||||
|
const auto gamepads = manager->connectedGamepads();
|
||||||
|
if (!gamepads.isEmpty()) {
|
||||||
|
setupGamepad(gamepads.first());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GamepadManager *GamepadManager::instance()
|
||||||
|
{
|
||||||
|
if (!s_instance) {
|
||||||
|
s_instance = new GamepadManager();
|
||||||
|
}
|
||||||
|
return s_instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
GamepadManager *GamepadManager::create(QQmlEngine *engine, QJSEngine *scriptEngine)
|
||||||
|
{
|
||||||
|
Q_UNUSED(engine)
|
||||||
|
Q_UNUSED(scriptEngine)
|
||||||
|
return instance();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GamepadManager::connected() const
|
||||||
|
{
|
||||||
|
return m_connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GamepadManager::active() const
|
||||||
|
{
|
||||||
|
return m_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::setActive(bool active)
|
||||||
|
{
|
||||||
|
if (m_active != active) {
|
||||||
|
m_active = active;
|
||||||
|
Q_EMIT activeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::onGamepadConnected(int deviceId)
|
||||||
|
{
|
||||||
|
if (!m_gamepad) {
|
||||||
|
setupGamepad(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::onGamepadDisconnected(int deviceId)
|
||||||
|
{
|
||||||
|
if (m_gamepad && m_gamepad->deviceId() == deviceId) {
|
||||||
|
m_gamepad->deleteLater();
|
||||||
|
m_gamepad = nullptr;
|
||||||
|
m_connected = false;
|
||||||
|
m_repeatTimer.stop();
|
||||||
|
Q_EMIT connectedChanged();
|
||||||
|
|
||||||
|
const auto gamepads = QGamepadManager::instance()->connectedGamepads();
|
||||||
|
if (!gamepads.isEmpty()) {
|
||||||
|
setupGamepad(gamepads.first());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::setupGamepad(int deviceId)
|
||||||
|
{
|
||||||
|
m_gamepad = new QGamepad(deviceId, this);
|
||||||
|
m_connected = true;
|
||||||
|
Q_EMIT connectedChanged();
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::axisLeftYChanged, this, [this](double value) {
|
||||||
|
if (qAbs(value) > 0.5) {
|
||||||
|
setActive(true);
|
||||||
|
if (value < -0.5) {
|
||||||
|
Q_EMIT navigateUp();
|
||||||
|
} else {
|
||||||
|
Q_EMIT navigateDown();
|
||||||
|
}
|
||||||
|
m_repeatTimer.start();
|
||||||
|
} else {
|
||||||
|
m_repeatTimer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::axisLeftXChanged, this, [this](double value) {
|
||||||
|
if (qAbs(value) > 0.5) {
|
||||||
|
setActive(true);
|
||||||
|
if (value < -0.5) {
|
||||||
|
Q_EMIT navigateLeft();
|
||||||
|
} else {
|
||||||
|
Q_EMIT navigateRight();
|
||||||
|
}
|
||||||
|
m_repeatTimer.start();
|
||||||
|
} else {
|
||||||
|
m_repeatTimer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonAChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT selectPressed();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonBChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT backPressed();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonStartChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT menuPressed();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonYChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT searchPressed();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonUpChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT navigateUp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonDownChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT navigateDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonLeftChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT navigateLeft();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_gamepad, &QGamepad::buttonRightChanged, this, [this](bool pressed) {
|
||||||
|
if (pressed) {
|
||||||
|
setActive(true);
|
||||||
|
Q_EMIT navigateRight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::processInput()
|
||||||
|
{
|
||||||
|
if (!m_gamepad)
|
||||||
|
return;
|
||||||
|
|
||||||
|
double y = m_gamepad->axisLeftY();
|
||||||
|
double x = m_gamepad->axisLeftX();
|
||||||
|
|
||||||
|
if (y < -0.5) {
|
||||||
|
Q_EMIT navigateUp();
|
||||||
|
} else if (y > 0.5) {
|
||||||
|
Q_EMIT navigateDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x < -0.5) {
|
||||||
|
Q_EMIT navigateLeft();
|
||||||
|
} else if (x > 0.5) {
|
||||||
|
Q_EMIT navigateRight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
GamepadManager *GamepadManager::s_instance = nullptr;
|
||||||
|
|
||||||
|
GamepadManager::GamepadManager(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
GamepadManager *GamepadManager::instance()
|
||||||
|
{
|
||||||
|
if (!s_instance) {
|
||||||
|
s_instance = new GamepadManager();
|
||||||
|
}
|
||||||
|
return s_instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
GamepadManager *GamepadManager::create(QQmlEngine *engine, QJSEngine *scriptEngine)
|
||||||
|
{
|
||||||
|
Q_UNUSED(engine)
|
||||||
|
Q_UNUSED(scriptEngine)
|
||||||
|
return instance();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GamepadManager::connected() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GamepadManager::active() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
57
src/gamepadmanager.h
Normal file
57
src/gamepadmanager.h
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
class QGamepad;
|
||||||
|
class QGamepadManager;
|
||||||
|
|
||||||
|
class GamepadManager : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
|
||||||
|
Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
|
||||||
|
Q_PROPERTY(bool active READ active NOTIFY activeChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
static GamepadManager *instance();
|
||||||
|
static GamepadManager *create(QQmlEngine *engine, QJSEngine *scriptEngine);
|
||||||
|
|
||||||
|
bool connected() const;
|
||||||
|
bool active() const;
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void connectedChanged();
|
||||||
|
void activeChanged();
|
||||||
|
|
||||||
|
void navigateUp();
|
||||||
|
void navigateDown();
|
||||||
|
void navigateLeft();
|
||||||
|
void navigateRight();
|
||||||
|
void selectPressed();
|
||||||
|
void backPressed();
|
||||||
|
void menuPressed();
|
||||||
|
void searchPressed();
|
||||||
|
|
||||||
|
private:
|
||||||
|
explicit GamepadManager(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
static GamepadManager *s_instance;
|
||||||
|
|
||||||
|
QGamepad *m_gamepad = nullptr;
|
||||||
|
bool m_connected = false;
|
||||||
|
bool m_active = false;
|
||||||
|
QTimer m_repeatTimer;
|
||||||
|
|
||||||
|
void onGamepadConnected(int deviceId);
|
||||||
|
void onGamepadDisconnected(int deviceId);
|
||||||
|
void setupGamepad(int deviceId);
|
||||||
|
void processInput();
|
||||||
|
void setActive(bool active);
|
||||||
|
};
|
||||||
174
src/gamesortfiltermodel.cpp
Normal file
174
src/gamesortfiltermodel.cpp
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "gamesortfiltermodel.h"
|
||||||
|
#include "game.h"
|
||||||
|
#include "gamemodel.h"
|
||||||
|
|
||||||
|
GameSortFilterModel::GameSortFilterModel(QObject *parent)
|
||||||
|
: QSortFilterProxyModel(parent)
|
||||||
|
{
|
||||||
|
setDynamicSortFilter(true);
|
||||||
|
sort(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString GameSortFilterModel::filterText() const
|
||||||
|
{
|
||||||
|
return m_filterText;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameSortFilterModel::setFilterText(const QString &text)
|
||||||
|
{
|
||||||
|
if (m_filterText != text) {
|
||||||
|
m_filterText = text;
|
||||||
|
invalidateAndEmit();
|
||||||
|
Q_EMIT filterTextChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString GameSortFilterModel::filterSource() const
|
||||||
|
{
|
||||||
|
return m_filterSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameSortFilterModel::setFilterSource(const QString &source)
|
||||||
|
{
|
||||||
|
if (m_filterSource != source) {
|
||||||
|
m_filterSource = source;
|
||||||
|
invalidateAndEmit();
|
||||||
|
Q_EMIT filterSourceChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameSortFilterModel::showHidden() const
|
||||||
|
{
|
||||||
|
return m_showHidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameSortFilterModel::setShowHidden(bool show)
|
||||||
|
{
|
||||||
|
if (m_showHidden != show) {
|
||||||
|
m_showHidden = show;
|
||||||
|
invalidateAndEmit();
|
||||||
|
Q_EMIT showHiddenChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameSortFilterModel::favoritesOnly() const
|
||||||
|
{
|
||||||
|
return m_favoritesOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameSortFilterModel::setFavoritesOnly(bool favorites)
|
||||||
|
{
|
||||||
|
if (m_favoritesOnly != favorites) {
|
||||||
|
m_favoritesOnly = favorites;
|
||||||
|
invalidateAndEmit();
|
||||||
|
Q_EMIT favoritesOnlyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSortFilterModel::SortMode GameSortFilterModel::sortMode() const
|
||||||
|
{
|
||||||
|
return m_sortMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameSortFilterModel::setSortMode(SortMode mode)
|
||||||
|
{
|
||||||
|
if (m_sortMode != mode) {
|
||||||
|
m_sortMode = mode;
|
||||||
|
invalidate();
|
||||||
|
Q_EMIT sortModeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int GameSortFilterModel::count() const
|
||||||
|
{
|
||||||
|
return rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant GameSortFilterModel::get(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= rowCount()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return data(this->index(index, 0), GameModel::GameObjectRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameSortFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
|
||||||
|
{
|
||||||
|
QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
|
||||||
|
|
||||||
|
bool hidden = sourceModel()->data(index, GameModel::HiddenRole).toBool();
|
||||||
|
|
||||||
|
// When showHidden is true, ONLY show hidden games
|
||||||
|
// When showHidden is false, ONLY show non-hidden games
|
||||||
|
if (m_showHidden != hidden) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_favoritesOnly) {
|
||||||
|
bool favorite = sourceModel()->data(index, GameModel::FavoriteRole).toBool();
|
||||||
|
if (!favorite) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_filterSource.isEmpty() && m_filterSource != QStringLiteral("all")) {
|
||||||
|
QString platform = sourceModel()->data(index, GameModel::PlatformRole).toString();
|
||||||
|
if (!platform.contains(m_filterSource, Qt::CaseInsensitive)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_filterText.isEmpty()) {
|
||||||
|
QString name = sourceModel()->data(index, GameModel::NameRole).toString();
|
||||||
|
QString developer = sourceModel()->data(index, GameModel::DeveloperRole).toString();
|
||||||
|
|
||||||
|
bool matches = name.contains(m_filterText, Qt::CaseInsensitive) || developer.contains(m_filterText, Qt::CaseInsensitive);
|
||||||
|
if (!matches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameSortFilterModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
|
||||||
|
{
|
||||||
|
bool leftFavorite = sourceModel()->data(left, GameModel::FavoriteRole).toBool();
|
||||||
|
bool rightFavorite = sourceModel()->data(right, GameModel::FavoriteRole).toBool();
|
||||||
|
|
||||||
|
if (leftFavorite != rightFavorite) {
|
||||||
|
return leftFavorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (m_sortMode) {
|
||||||
|
case SortByName: {
|
||||||
|
QString leftName = sourceModel()->data(left, GameModel::NameRole).toString();
|
||||||
|
QString rightName = sourceModel()->data(right, GameModel::NameRole).toString();
|
||||||
|
return leftName.compare(rightName, Qt::CaseInsensitive) < 0;
|
||||||
|
}
|
||||||
|
case SortByLastPlayed: {
|
||||||
|
QDateTime leftDate = sourceModel()->data(left, GameModel::LastPlayedRole).toDateTime();
|
||||||
|
QDateTime rightDate = sourceModel()->data(right, GameModel::LastPlayedRole).toDateTime();
|
||||||
|
return leftDate > rightDate;
|
||||||
|
}
|
||||||
|
case SortByPlayTime: {
|
||||||
|
qint64 leftTime = sourceModel()->data(left, GameModel::PlayTimeRole).toLongLong();
|
||||||
|
qint64 rightTime = sourceModel()->data(right, GameModel::PlayTimeRole).toLongLong();
|
||||||
|
return leftTime > rightTime;
|
||||||
|
}
|
||||||
|
case SortByDateAdded: {
|
||||||
|
return left.row() > right.row();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameSortFilterModel::invalidateAndEmit()
|
||||||
|
{
|
||||||
|
invalidateFilter();
|
||||||
|
Q_EMIT countChanged();
|
||||||
|
}
|
||||||
71
src/gamesortfiltermodel.h
Normal file
71
src/gamesortfiltermodel.h
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
|
||||||
|
class GameSortFilterModel : public QSortFilterProxyModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
|
||||||
|
Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged)
|
||||||
|
Q_PROPERTY(QString filterSource READ filterSource WRITE setFilterSource NOTIFY filterSourceChanged)
|
||||||
|
Q_PROPERTY(bool showHidden READ showHidden WRITE setShowHidden NOTIFY showHiddenChanged)
|
||||||
|
Q_PROPERTY(bool favoritesOnly READ favoritesOnly WRITE setFavoritesOnly NOTIFY favoritesOnlyChanged)
|
||||||
|
Q_PROPERTY(SortMode sortMode READ sortMode WRITE setSortMode NOTIFY sortModeChanged)
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum SortMode {
|
||||||
|
SortByName,
|
||||||
|
SortByLastPlayed,
|
||||||
|
SortByPlayTime,
|
||||||
|
SortByDateAdded
|
||||||
|
};
|
||||||
|
Q_ENUM(SortMode)
|
||||||
|
|
||||||
|
explicit GameSortFilterModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString filterText() const;
|
||||||
|
void setFilterText(const QString &text);
|
||||||
|
|
||||||
|
QString filterSource() const;
|
||||||
|
void setFilterSource(const QString &source);
|
||||||
|
|
||||||
|
bool showHidden() const;
|
||||||
|
void setShowHidden(bool show);
|
||||||
|
|
||||||
|
bool favoritesOnly() const;
|
||||||
|
void setFavoritesOnly(bool favorites);
|
||||||
|
|
||||||
|
SortMode sortMode() const;
|
||||||
|
void setSortMode(SortMode mode);
|
||||||
|
|
||||||
|
int count() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE QVariant get(int index) const;
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void filterTextChanged();
|
||||||
|
void filterSourceChanged();
|
||||||
|
void showHiddenChanged();
|
||||||
|
void favoritesOnlyChanged();
|
||||||
|
void sortModeChanged();
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
|
||||||
|
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_filterText;
|
||||||
|
QString m_filterSource;
|
||||||
|
bool m_showHidden = false;
|
||||||
|
bool m_favoritesOnly = false;
|
||||||
|
SortMode m_sortMode = SortByLastPlayed;
|
||||||
|
|
||||||
|
void invalidateAndEmit();
|
||||||
|
};
|
||||||
319
src/heroicimporter.cpp
Normal file
319
src/heroicimporter.cpp
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "heroicimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
HeroicImporter::HeroicImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString HeroicImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("Heroic");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString HeroicImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("heroic");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HeroicImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
return directoryExists(findHeroicConfigPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString HeroicImporter::findHeroicConfigPath() const
|
||||||
|
{
|
||||||
|
QStringList possiblePaths = {expandPath(QStringLiteral("~/.config/heroic")),
|
||||||
|
expandPath(QStringLiteral("~/.var/app/com.heroicgameslauncher.hgl/config/heroic"))};
|
||||||
|
|
||||||
|
for (const QString &path : possiblePaths) {
|
||||||
|
if (directoryExists(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store) const
|
||||||
|
{
|
||||||
|
QString configPath = findHeroicConfigPath();
|
||||||
|
if (configPath.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check images cache
|
||||||
|
QStringList imageDirs = {configPath + QStringLiteral("/images-cache"), configPath + QStringLiteral("/images")};
|
||||||
|
|
||||||
|
QStringList extensions = {QStringLiteral(".jpg"), QStringLiteral(".png"), QStringLiteral(".webp")};
|
||||||
|
QStringList prefixes = {appName, appName + QStringLiteral("_cover"), appName + QStringLiteral("_library")};
|
||||||
|
|
||||||
|
for (const QString &imageDir : imageDirs) {
|
||||||
|
QDir dir(imageDir);
|
||||||
|
if (!dir.exists())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (const QString &prefix : prefixes) {
|
||||||
|
for (const QString &ext : extensions) {
|
||||||
|
QString imagePath = imageDir + QStringLiteral("/") + prefix + ext;
|
||||||
|
if (QFile::exists(imagePath)) {
|
||||||
|
return QUrl::fromLocalFile(imagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try looking in subdirectories
|
||||||
|
QStringList subDirs = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||||
|
for (const QString &subDir : subDirs) {
|
||||||
|
for (const QString &prefix : prefixes) {
|
||||||
|
for (const QString &ext : extensions) {
|
||||||
|
QString imagePath = imageDir + QStringLiteral("/") + subDir + QStringLiteral("/") + prefix + ext;
|
||||||
|
if (QFile::exists(imagePath)) {
|
||||||
|
return QUrl::fromLocalFile(imagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> HeroicImporter::importGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
games.append(importEpicGames());
|
||||||
|
games.append(importGOGGames());
|
||||||
|
games.append(importAmazonGames());
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> HeroicImporter::importEpicGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
QString configPath = findHeroicConfigPath();
|
||||||
|
|
||||||
|
if (configPath.isEmpty()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for installed.json
|
||||||
|
QString installedPath = configPath + QStringLiteral("/legendaryConfig/legendary/installed.json");
|
||||||
|
QFile installedFile(installedPath);
|
||||||
|
|
||||||
|
if (!installedFile.open(QIODevice::ReadOnly)) {
|
||||||
|
// Try alternative path
|
||||||
|
installedPath = configPath + QStringLiteral("/store_cache/legendary_library.json");
|
||||||
|
installedFile.setFileName(installedPath);
|
||||||
|
if (!installedFile.open(QIODevice::ReadOnly)) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll());
|
||||||
|
|
||||||
|
if (doc.isObject()) {
|
||||||
|
QJsonObject obj = doc.object();
|
||||||
|
int current = 0;
|
||||||
|
int total = obj.keys().count();
|
||||||
|
|
||||||
|
for (const QString &appName : obj.keys()) {
|
||||||
|
QJsonObject gameObj = obj[appName].toObject();
|
||||||
|
|
||||||
|
QString title = gameObj[QStringLiteral("title")].toString();
|
||||||
|
if (title.isEmpty()) {
|
||||||
|
title = appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString installPath = gameObj[QStringLiteral("install_path")].toString();
|
||||||
|
bool isInstalled = !installPath.isEmpty();
|
||||||
|
|
||||||
|
Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title);
|
||||||
|
game->setPlatform(QStringLiteral("Heroic (Epic)"));
|
||||||
|
game->setPlatformId(appName);
|
||||||
|
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName));
|
||||||
|
game->setWorkingDirectory(installPath);
|
||||||
|
game->setInstalled(isInstalled);
|
||||||
|
|
||||||
|
// Developer/Publisher info
|
||||||
|
QString developer = gameObj[QStringLiteral("developer")].toString();
|
||||||
|
QString publisher = gameObj[QStringLiteral("publisher")].toString();
|
||||||
|
game->setDeveloper(developer);
|
||||||
|
game->setPublisher(publisher);
|
||||||
|
|
||||||
|
QUrl coverUrl = findCoverImage(appName, QStringLiteral("epic"));
|
||||||
|
if (coverUrl.isValid()) {
|
||||||
|
game->setCoverUrl(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
}
|
||||||
|
} else if (doc.isArray()) {
|
||||||
|
QJsonArray arr = doc.array();
|
||||||
|
int current = 0;
|
||||||
|
int total = arr.count();
|
||||||
|
|
||||||
|
for (const QJsonValue &value : arr) {
|
||||||
|
QJsonObject gameObj = value.toObject();
|
||||||
|
|
||||||
|
QString appName = gameObj[QStringLiteral("app_name")].toString();
|
||||||
|
QString title = gameObj[QStringLiteral("title")].toString();
|
||||||
|
|
||||||
|
if (appName.isEmpty() || title.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool();
|
||||||
|
|
||||||
|
Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title);
|
||||||
|
game->setPlatform(QStringLiteral("Heroic (Epic)"));
|
||||||
|
game->setPlatformId(appName);
|
||||||
|
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName));
|
||||||
|
game->setInstalled(isInstalled);
|
||||||
|
|
||||||
|
QUrl coverUrl = findCoverImage(appName, QStringLiteral("epic"));
|
||||||
|
if (coverUrl.isValid()) {
|
||||||
|
game->setCoverUrl(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> HeroicImporter::importGOGGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
QString configPath = findHeroicConfigPath();
|
||||||
|
|
||||||
|
if (configPath.isEmpty()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for GOG installed games
|
||||||
|
QString installedPath = configPath + QStringLiteral("/gog_store/installed.json");
|
||||||
|
QFile installedFile(installedPath);
|
||||||
|
|
||||||
|
if (!installedFile.open(QIODevice::ReadOnly)) {
|
||||||
|
// Try library file
|
||||||
|
installedPath = configPath + QStringLiteral("/store_cache/gog_library.json");
|
||||||
|
installedFile.setFileName(installedPath);
|
||||||
|
if (!installedFile.open(QIODevice::ReadOnly)) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll());
|
||||||
|
|
||||||
|
auto parseGames = [this, &games](const QJsonArray &arr) {
|
||||||
|
for (const QJsonValue &value : arr) {
|
||||||
|
QJsonObject gameObj = value.toObject();
|
||||||
|
|
||||||
|
QString appName = gameObj[QStringLiteral("app_name")].toString();
|
||||||
|
if (appName.isEmpty()) {
|
||||||
|
appName = gameObj[QStringLiteral("appName")].toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString title = gameObj[QStringLiteral("title")].toString();
|
||||||
|
if (title.isEmpty()) {
|
||||||
|
title = gameObj[QStringLiteral("name")].toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appName.isEmpty() || title.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool() || gameObj[QStringLiteral("isInstalled")].toBool();
|
||||||
|
|
||||||
|
Game *game = new Game(QStringLiteral("heroic-gog-%1").arg(appName), title);
|
||||||
|
game->setPlatform(QStringLiteral("Heroic (GOG)"));
|
||||||
|
game->setPlatformId(appName);
|
||||||
|
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName));
|
||||||
|
game->setInstalled(isInstalled);
|
||||||
|
|
||||||
|
QUrl coverUrl = findCoverImage(appName, QStringLiteral("gog"));
|
||||||
|
if (coverUrl.isValid()) {
|
||||||
|
game->setCoverUrl(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (doc.isArray()) {
|
||||||
|
parseGames(doc.array());
|
||||||
|
} else if (doc.isObject()) {
|
||||||
|
QJsonObject obj = doc.object();
|
||||||
|
if (obj.contains(QStringLiteral("games"))) {
|
||||||
|
parseGames(obj[QStringLiteral("games")].toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> HeroicImporter::importAmazonGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
QString configPath = findHeroicConfigPath();
|
||||||
|
|
||||||
|
if (configPath.isEmpty()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString libraryPath = configPath + QStringLiteral("/store_cache/nile_library.json");
|
||||||
|
QFile libraryFile(libraryPath);
|
||||||
|
|
||||||
|
if (!libraryFile.open(QIODevice::ReadOnly)) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(libraryFile.readAll());
|
||||||
|
|
||||||
|
if (!doc.isArray()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray arr = doc.array();
|
||||||
|
for (const QJsonValue &value : arr) {
|
||||||
|
QJsonObject gameObj = value.toObject();
|
||||||
|
|
||||||
|
QString appName = gameObj[QStringLiteral("app_name")].toString();
|
||||||
|
QString title = gameObj[QStringLiteral("title")].toString();
|
||||||
|
|
||||||
|
if (appName.isEmpty() || title.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool();
|
||||||
|
|
||||||
|
Game *game = new Game(QStringLiteral("heroic-amazon-%1").arg(appName), title);
|
||||||
|
game->setPlatform(QStringLiteral("Heroic (Amazon)"));
|
||||||
|
game->setPlatformId(appName);
|
||||||
|
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName));
|
||||||
|
game->setInstalled(isInstalled);
|
||||||
|
|
||||||
|
QUrl coverUrl = findCoverImage(appName, QStringLiteral("amazon"));
|
||||||
|
if (coverUrl.isValid()) {
|
||||||
|
game->setCoverUrl(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
26
src/heroicimporter.h
Normal file
26
src/heroicimporter.h
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class HeroicImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit HeroicImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString findHeroicConfigPath() const;
|
||||||
|
QList<Game *> importEpicGames();
|
||||||
|
QList<Game *> importGOGGames();
|
||||||
|
QList<Game *> importAmazonGames();
|
||||||
|
QUrl findCoverImage(const QString &appName, const QString &store) const;
|
||||||
|
};
|
||||||
128
src/itchimporter.cpp
Normal file
128
src/itchimporter.cpp
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "itchimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QSqlError>
|
||||||
|
#include <QSqlQuery>
|
||||||
|
|
||||||
|
ItchImporter::ItchImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ItchImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("itch.io");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ItchImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("itch");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ItchImporter::getItchDatabasePath() const
|
||||||
|
{
|
||||||
|
QStringList paths = {
|
||||||
|
expandPath(QStringLiteral("~/.config/itch/db/butler.db")),
|
||||||
|
expandPath(QStringLiteral("~/.var/app/io.itch.itch/config/itch/db/butler.db")),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &path : paths) {
|
||||||
|
if (QFile::exists(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ItchImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
return !getItchDatabasePath().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> ItchImporter::parseItchDatabase(const QString &dbPath) const
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
// Use a unique connection name
|
||||||
|
QString connectionName = QStringLiteral("itch_import_%1").arg(reinterpret_cast<quintptr>(this));
|
||||||
|
|
||||||
|
{
|
||||||
|
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName);
|
||||||
|
db.setDatabaseName(dbPath);
|
||||||
|
|
||||||
|
if (!db.open()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query caves table for installed games
|
||||||
|
QSqlQuery query(db);
|
||||||
|
query.prepare(
|
||||||
|
QStringLiteral("SELECT c.id, c.game_id, g.title, g.short_text, g.cover_url, c.install_folder_name "
|
||||||
|
"FROM caves c "
|
||||||
|
"LEFT JOIN games g ON c.game_id = g.id "
|
||||||
|
"WHERE c.install_folder_name IS NOT NULL"));
|
||||||
|
|
||||||
|
if (!query.exec()) {
|
||||||
|
db.close();
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (query.next()) {
|
||||||
|
QString caveId = query.value(0).toString();
|
||||||
|
QString gameId = query.value(1).toString();
|
||||||
|
QString title = query.value(2).toString();
|
||||||
|
QString shortText = query.value(3).toString();
|
||||||
|
QString coverUrl = query.value(4).toString();
|
||||||
|
QString installFolder = query.value(5).toString();
|
||||||
|
|
||||||
|
if (title.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString id = QStringLiteral("itch_%1").arg(gameId);
|
||||||
|
|
||||||
|
Game *game = new Game(id, title);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(platformId());
|
||||||
|
|
||||||
|
if (!shortText.isEmpty()) {
|
||||||
|
game->setDescription(shortText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coverUrl.isEmpty()) {
|
||||||
|
game->setCoverUrl(QUrl(coverUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build launch command using itch app
|
||||||
|
QString launchCmd = QStringLiteral("itch://caves/%1/launch").arg(caveId);
|
||||||
|
game->setLaunchCommand(QStringLiteral("xdg-open \"%1\"").arg(launchCmd));
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
QSqlDatabase::removeDatabase(connectionName);
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> ItchImporter::importGames()
|
||||||
|
{
|
||||||
|
QString dbPath = getItchDatabasePath();
|
||||||
|
if (dbPath.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseItchDatabase(dbPath);
|
||||||
|
}
|
||||||
23
src/itchimporter.h
Normal file
23
src/itchimporter.h
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class ItchImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ItchImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString getItchDatabasePath() const;
|
||||||
|
QList<Game *> parseItchDatabase(const QString &dbPath) const;
|
||||||
|
};
|
||||||
20
src/krunner/CMakeLists.txt
Normal file
20
src/krunner/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
find_package(KF6Runner ${KF_MIN_VERSION})
|
||||||
|
|
||||||
|
if(KF6Runner_FOUND)
|
||||||
|
kcoreaddons_add_plugin(krunner_alakarte
|
||||||
|
SOURCES alakarterunner.cpp
|
||||||
|
INSTALL_NAMESPACE "kf6/krunner"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(krunner_alakarte
|
||||||
|
KF6::Runner
|
||||||
|
KF6::I18n
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
message(STATUS "KRunner not found, skipping KRunner plugin")
|
||||||
|
endif()
|
||||||
166
src/krunner/alakarterunner.cpp
Normal file
166
src/krunner/alakarterunner.cpp
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "alakarterunner.h"
|
||||||
|
|
||||||
|
#include <KLocalizedString>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
K_PLUGIN_CLASS_WITH_JSON(AlakarteRunner, "plasma-runner-alakarte.json")
|
||||||
|
|
||||||
|
AlakarteRunner::AlakarteRunner(QObject *parent, const KPluginMetaData &metaData)
|
||||||
|
: KRunner::AbstractRunner(parent, metaData)
|
||||||
|
{
|
||||||
|
addSyntax(QStringLiteral("game <query>"), i18n("Search and launch games from A-La-Karte library"));
|
||||||
|
addSyntax(QStringLiteral("<query>"), i18n("Search games by name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
AlakarteRunner::~AlakarteRunner() = default;
|
||||||
|
|
||||||
|
QString AlakarteRunner::libraryPath() const
|
||||||
|
{
|
||||||
|
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation).replace(QStringLiteral("krunner_alakarte"), QStringLiteral("alakarte"))
|
||||||
|
+ QStringLiteral("/library.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<AlakarteRunner::GameInfo> AlakarteRunner::loadGames() const
|
||||||
|
{
|
||||||
|
QList<GameInfo> games;
|
||||||
|
|
||||||
|
QFile file(libraryPath());
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||||
|
if (!doc.isArray()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray array = doc.array();
|
||||||
|
for (const QJsonValue &value : array) {
|
||||||
|
QJsonObject obj = value.toObject();
|
||||||
|
|
||||||
|
// Skip hidden games
|
||||||
|
if (obj[QStringLiteral("hidden")].toBool()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameInfo info;
|
||||||
|
info.id = obj[QStringLiteral("id")].toString();
|
||||||
|
info.name = obj[QStringLiteral("name")].toString();
|
||||||
|
info.developer = obj[QStringLiteral("developer")].toString();
|
||||||
|
info.platform = obj[QStringLiteral("platform")].toString();
|
||||||
|
info.launchCommand = obj[QStringLiteral("launchCommand")].toString();
|
||||||
|
info.coverPath = obj[QStringLiteral("coverUrl")].toString();
|
||||||
|
|
||||||
|
if (!info.name.isEmpty() && !info.launchCommand.isEmpty()) {
|
||||||
|
games.append(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AlakarteRunner::match(KRunner::RunnerContext &context)
|
||||||
|
{
|
||||||
|
const QString query = context.query();
|
||||||
|
|
||||||
|
if (query.length() < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString searchTerm = query;
|
||||||
|
bool explicitGameSearch = false;
|
||||||
|
|
||||||
|
// Check for "game " prefix
|
||||||
|
if (query.startsWith(QLatin1String("game "), Qt::CaseInsensitive)) {
|
||||||
|
searchTerm = query.mid(5).trimmed();
|
||||||
|
explicitGameSearch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<GameInfo> games = loadGames();
|
||||||
|
|
||||||
|
for (const GameInfo &game : games) {
|
||||||
|
bool matches = game.name.contains(searchTerm, Qt::CaseInsensitive) || game.developer.contains(searchTerm, Qt::CaseInsensitive);
|
||||||
|
|
||||||
|
if (!matches && !explicitGameSearch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
KRunner::QueryMatch match(this);
|
||||||
|
match.setText(game.name);
|
||||||
|
|
||||||
|
QString subtext;
|
||||||
|
if (!game.developer.isEmpty()) {
|
||||||
|
subtext = game.developer;
|
||||||
|
}
|
||||||
|
if (!game.platform.isEmpty()) {
|
||||||
|
if (!subtext.isEmpty()) {
|
||||||
|
subtext += QStringLiteral(" • ");
|
||||||
|
}
|
||||||
|
subtext += game.platform;
|
||||||
|
}
|
||||||
|
match.setSubtext(subtext);
|
||||||
|
|
||||||
|
match.setIconName(QStringLiteral("applications-games"));
|
||||||
|
match.setId(game.id);
|
||||||
|
match.setData(game.launchCommand);
|
||||||
|
|
||||||
|
// Calculate relevance
|
||||||
|
qreal relevance = 0.7;
|
||||||
|
if (game.name.startsWith(searchTerm, Qt::CaseInsensitive)) {
|
||||||
|
relevance = 0.9;
|
||||||
|
} else if (game.name.contains(searchTerm, Qt::CaseInsensitive)) {
|
||||||
|
relevance = 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitGameSearch) {
|
||||||
|
relevance += 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
match.setRelevance(relevance);
|
||||||
|
match.setCategoryRelevance(KRunner::QueryMatch::CategoryRelevance::Moderate);
|
||||||
|
|
||||||
|
context.addMatch(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AlakarteRunner::run(const KRunner::RunnerContext &context, const KRunner::QueryMatch &match)
|
||||||
|
{
|
||||||
|
Q_UNUSED(context)
|
||||||
|
|
||||||
|
QString command = match.data().toString();
|
||||||
|
if (command.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Steam URLs
|
||||||
|
if (command.startsWith(QLatin1String("steam://"))) {
|
||||||
|
QProcess::startDetached(QStringLiteral("xdg-open"), {command});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other commands
|
||||||
|
QStringList args = QProcess::splitCommand(command);
|
||||||
|
if (!args.isEmpty()) {
|
||||||
|
QString program = args.takeFirst();
|
||||||
|
QProcess::startDetached(program, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#include "alakarterunner.moc"
|
||||||
31
src/krunner/alakarterunner.h
Normal file
31
src/krunner/alakarterunner.h
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <KRunner/AbstractRunner>
|
||||||
|
|
||||||
|
class AlakarteRunner : public KRunner::AbstractRunner
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
AlakarteRunner(QObject *parent, const KPluginMetaData &metaData);
|
||||||
|
~AlakarteRunner() override;
|
||||||
|
|
||||||
|
void match(KRunner::RunnerContext &context) override;
|
||||||
|
void run(const KRunner::RunnerContext &context, const KRunner::QueryMatch &match) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct GameInfo {
|
||||||
|
QString id;
|
||||||
|
QString name;
|
||||||
|
QString developer;
|
||||||
|
QString platform;
|
||||||
|
QString launchCommand;
|
||||||
|
QString coverPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
QList<GameInfo> loadGames() const;
|
||||||
|
QString libraryPath() const;
|
||||||
|
};
|
||||||
18
src/krunner/plasma-runner-alakarte.json
Normal file
18
src/krunner/plasma-runner-alakarte.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"KPlugin": {
|
||||||
|
"Authors": [
|
||||||
|
{
|
||||||
|
"Name": "A-La-Karte Contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Category": "Services",
|
||||||
|
"Description": "Search and launch games from A-La-Karte library",
|
||||||
|
"Icon": "applications-games",
|
||||||
|
"Id": "org.kde.alakarte.runner",
|
||||||
|
"License": "GPL-3.0-or-later",
|
||||||
|
"Name": "A-La-Karte Games",
|
||||||
|
"Version": "0.1.0"
|
||||||
|
},
|
||||||
|
"X-Plasma-API": "C++",
|
||||||
|
"X-Plasma-API-Minimum-Version": "6.0"
|
||||||
|
}
|
||||||
149
src/legendaryimporter.cpp
Normal file
149
src/legendaryimporter.cpp
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "legendaryimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
LegendaryImporter::LegendaryImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LegendaryImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("Legendary");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LegendaryImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("legendary");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LegendaryImporter::getLegendaryConfigPath() const
|
||||||
|
{
|
||||||
|
QStringList paths = {
|
||||||
|
expandPath(QStringLiteral("~/.config/legendary")),
|
||||||
|
expandPath(QStringLiteral("~/.var/app/com.github.derrod.legendary/.config/legendary")),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &path : paths) {
|
||||||
|
if (directoryExists(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LegendaryImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
QString configPath = getLegendaryConfigPath();
|
||||||
|
if (configPath.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QFile::exists(configPath + QStringLiteral("/installed.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> LegendaryImporter::parseInstalledGames() const
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
QString configPath = getLegendaryConfigPath();
|
||||||
|
if (configPath.isEmpty()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString installedPath = configPath + QStringLiteral("/installed.json");
|
||||||
|
QFile installedFile(installedPath);
|
||||||
|
|
||||||
|
if (!installedFile.open(QIODevice::ReadOnly)) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll());
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject installed = doc.object();
|
||||||
|
|
||||||
|
// Also try to load metadata for additional info
|
||||||
|
QString metadataPath = configPath + QStringLiteral("/metadata");
|
||||||
|
|
||||||
|
for (auto it = installed.begin(); it != installed.end(); ++it) {
|
||||||
|
QString appName = it.key();
|
||||||
|
QJsonObject gameData = it.value().toObject();
|
||||||
|
|
||||||
|
QString title = gameData[QStringLiteral("title")].toString();
|
||||||
|
QString installPath = gameData[QStringLiteral("install_path")].toString();
|
||||||
|
QString executable = gameData[QStringLiteral("executable")].toString();
|
||||||
|
|
||||||
|
if (title.isEmpty() || appName.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString gameId = QStringLiteral("legendary_%1").arg(appName.toLower().replace(QStringLiteral(" "), QStringLiteral("_")));
|
||||||
|
|
||||||
|
Game *game = new Game(gameId, title);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(platformId());
|
||||||
|
|
||||||
|
// Use legendary CLI to launch
|
||||||
|
QString launchCmd = QStringLiteral("legendary launch %1").arg(appName);
|
||||||
|
game->setLaunchCommand(launchCmd);
|
||||||
|
|
||||||
|
if (!installPath.isEmpty()) {
|
||||||
|
game->setWorkingDirectory(installPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get additional metadata
|
||||||
|
QString gameMetaPath = metadataPath + QStringLiteral("/") + appName + QStringLiteral(".json");
|
||||||
|
QFile metaFile(gameMetaPath);
|
||||||
|
if (metaFile.open(QIODevice::ReadOnly)) {
|
||||||
|
QJsonDocument metaDoc = QJsonDocument::fromJson(metaFile.readAll());
|
||||||
|
if (metaDoc.isObject()) {
|
||||||
|
QJsonObject meta = metaDoc.object();
|
||||||
|
QJsonObject metadata = meta[QStringLiteral("metadata")].toObject();
|
||||||
|
|
||||||
|
QString developer = metadata[QStringLiteral("developer")].toString();
|
||||||
|
if (!developer.isEmpty()) {
|
||||||
|
game->setDeveloper(developer);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString description = metadata[QStringLiteral("description")].toString();
|
||||||
|
if (!description.isEmpty()) {
|
||||||
|
game->setDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get cover image
|
||||||
|
QJsonArray keyImages = metadata[QStringLiteral("keyImages")].toArray();
|
||||||
|
for (const QJsonValue &img : keyImages) {
|
||||||
|
QJsonObject imgObj = img.toObject();
|
||||||
|
QString type = imgObj[QStringLiteral("type")].toString();
|
||||||
|
if (type == QStringLiteral("DieselGameBoxTall") || type == QStringLiteral("Thumbnail")) {
|
||||||
|
QString url = imgObj[QStringLiteral("url")].toString();
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
game->setCoverUrl(QUrl(url));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> LegendaryImporter::importGames()
|
||||||
|
{
|
||||||
|
return parseInstalledGames();
|
||||||
|
}
|
||||||
23
src/legendaryimporter.h
Normal file
23
src/legendaryimporter.h
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class LegendaryImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit LegendaryImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString getLegendaryConfigPath() const;
|
||||||
|
QList<Game *> parseInstalledGames() const;
|
||||||
|
};
|
||||||
211
src/lutrisimporter.cpp
Normal file
211
src/lutrisimporter.cpp
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "lutrisimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
LutrisImporter::LutrisImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LutrisImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("Lutris");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LutrisImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("lutris");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LutrisImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
return !findExecutable(QStringLiteral("lutris")).isEmpty() || directoryExists(findLutrisDataPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LutrisImporter::findLutrisDataPath() const
|
||||||
|
{
|
||||||
|
QStringList possiblePaths = {expandPath(QStringLiteral("~/.local/share/lutris")), expandPath(QStringLiteral("~/.var/app/net.lutris.Lutris/data/lutris"))};
|
||||||
|
|
||||||
|
for (const QString &path : possiblePaths) {
|
||||||
|
if (directoryExists(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LutrisImporter::findLutrisCachePath() const
|
||||||
|
{
|
||||||
|
QStringList possiblePaths = {expandPath(QStringLiteral("~/.cache/lutris")), expandPath(QStringLiteral("~/.var/app/net.lutris.Lutris/cache/lutris"))};
|
||||||
|
|
||||||
|
for (const QString &path : possiblePaths) {
|
||||||
|
if (directoryExists(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QUrl LutrisImporter::findCoverImage(const QString &slug) const
|
||||||
|
{
|
||||||
|
QString cachePath = findLutrisCachePath();
|
||||||
|
if (cachePath.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check coverart directory
|
||||||
|
QString coverartDir = cachePath + QStringLiteral("/coverart");
|
||||||
|
QStringList extensions = {QStringLiteral(".jpg"), QStringLiteral(".png"), QStringLiteral(".webp")};
|
||||||
|
|
||||||
|
for (const QString &ext : extensions) {
|
||||||
|
QString coverPath = coverartDir + QStringLiteral("/") + slug + ext;
|
||||||
|
if (QFile::exists(coverPath)) {
|
||||||
|
return QUrl::fromLocalFile(coverPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check banners directory as fallback
|
||||||
|
QString bannersDir = cachePath + QStringLiteral("/banners");
|
||||||
|
for (const QString &ext : extensions) {
|
||||||
|
QString bannerPath = bannersDir + QStringLiteral("/") + slug + ext;
|
||||||
|
if (QFile::exists(bannerPath)) {
|
||||||
|
return QUrl::fromLocalFile(bannerPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> LutrisImporter::importGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
// Try using lutris CLI first
|
||||||
|
QString lutrisPath = findExecutable(QStringLiteral("lutris"));
|
||||||
|
if (!lutrisPath.isEmpty()) {
|
||||||
|
QProcess process;
|
||||||
|
process.start(lutrisPath, {QStringLiteral("-lo"), QStringLiteral("--json")});
|
||||||
|
|
||||||
|
if (process.waitForFinished(30000)) {
|
||||||
|
QByteArray output = process.readAllStandardOutput();
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(output);
|
||||||
|
|
||||||
|
if (doc.isArray()) {
|
||||||
|
QJsonArray gamesArray = doc.array();
|
||||||
|
int current = 0;
|
||||||
|
int total = gamesArray.count();
|
||||||
|
|
||||||
|
for (const QJsonValue &value : gamesArray) {
|
||||||
|
if (!value.isObject())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
QJsonObject obj = value.toObject();
|
||||||
|
QString id = obj[QStringLiteral("id")].toString();
|
||||||
|
QString name = obj[QStringLiteral("name")].toString();
|
||||||
|
QString slug = obj[QStringLiteral("slug")].toString();
|
||||||
|
QString runner = obj[QStringLiteral("runner")].toString();
|
||||||
|
QString directory = obj[QStringLiteral("directory")].toString();
|
||||||
|
|
||||||
|
if (name.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Game *game = new Game(QStringLiteral("lutris-%1").arg(slug.isEmpty() ? id : slug), name);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(slug.isEmpty() ? id : slug);
|
||||||
|
game->setLaunchCommand(QStringLiteral("lutris lutris:rungameid/%1").arg(id));
|
||||||
|
game->setWorkingDirectory(directory);
|
||||||
|
game->setInstalled(true);
|
||||||
|
|
||||||
|
// Find cover
|
||||||
|
QUrl coverUrl = findCoverImage(slug);
|
||||||
|
if (coverUrl.isValid()) {
|
||||||
|
game->setCoverUrl(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If CLI didn't work, try reading the database directly
|
||||||
|
if (games.isEmpty()) {
|
||||||
|
QString dataPath = findLutrisDataPath();
|
||||||
|
if (dataPath.isEmpty()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString gamesDir = dataPath + QStringLiteral("/games");
|
||||||
|
QDir dir(gamesDir);
|
||||||
|
|
||||||
|
if (!dir.exists()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList yamlFiles = dir.entryList({QStringLiteral("*.yml")}, QDir::Files);
|
||||||
|
int current = 0;
|
||||||
|
int total = yamlFiles.count();
|
||||||
|
|
||||||
|
for (const QString &file : yamlFiles) {
|
||||||
|
QFile gameFile(gamesDir + QStringLiteral("/") + file);
|
||||||
|
if (!gameFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple YAML parsing for game config
|
||||||
|
QString content = QString::fromUtf8(gameFile.readAll());
|
||||||
|
QString name, slug, runner;
|
||||||
|
|
||||||
|
QStringList lines = content.split(QLatin1Char('\n'));
|
||||||
|
for (const QString &line : lines) {
|
||||||
|
if (line.startsWith(QLatin1String("name:"))) {
|
||||||
|
name = line.mid(5).trimmed();
|
||||||
|
} else if (line.startsWith(QLatin1String("slug:"))) {
|
||||||
|
slug = line.mid(5).trimmed();
|
||||||
|
} else if (line.startsWith(QLatin1String("runner:"))) {
|
||||||
|
runner = line.mid(7).trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
// Use filename without extension
|
||||||
|
name = QFileInfo(file).baseName();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug.isEmpty()) {
|
||||||
|
slug = QFileInfo(file).baseName();
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = new Game(QStringLiteral("lutris-%1").arg(slug), name);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(slug);
|
||||||
|
game->setLaunchCommand(QStringLiteral("lutris lutris:rungame/%1").arg(slug));
|
||||||
|
game->setInstalled(true);
|
||||||
|
|
||||||
|
QUrl coverUrl = findCoverImage(slug);
|
||||||
|
if (coverUrl.isValid()) {
|
||||||
|
game->setCoverUrl(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
25
src/lutrisimporter.h
Normal file
25
src/lutrisimporter.h
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class LutrisImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit LutrisImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString findLutrisDataPath() const;
|
||||||
|
QString findLutrisCachePath() const;
|
||||||
|
Game *parseGameFromDatabase(const QVariantMap &record);
|
||||||
|
QUrl findCoverImage(const QString &slug) const;
|
||||||
|
};
|
||||||
88
src/main.cpp
Normal file
88
src/main.cpp
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include <QCommandLineParser>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QQmlApplicationEngine>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QQuickStyle>
|
||||||
|
|
||||||
|
#include <KAboutData>
|
||||||
|
#include <KCrash>
|
||||||
|
#include <KDBusService>
|
||||||
|
#include <KLocalizedContext>
|
||||||
|
#include <KLocalizedString>
|
||||||
|
|
||||||
|
#include "alakarte-version.h"
|
||||||
|
#include "app.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include "game.h"
|
||||||
|
#include "gamepadmanager.h"
|
||||||
|
#include "gamelauncher.h"
|
||||||
|
#include "gamemodel.h"
|
||||||
|
#include "gamesortfiltermodel.h"
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
QGuiApplication app(argc, argv);
|
||||||
|
QGuiApplication::setDesktopFileName(QStringLiteral("org.kde.alakarte"));
|
||||||
|
|
||||||
|
KLocalizedString::setApplicationDomain("alakarte");
|
||||||
|
|
||||||
|
KAboutData aboutData(QStringLiteral("alakarte"),
|
||||||
|
i18n("A-La-Karte"),
|
||||||
|
QStringLiteral(ALAKARTE_VERSION_STRING),
|
||||||
|
i18n("A unified game launcher for KDE Plasma"),
|
||||||
|
KAboutLicense::GPL_V3,
|
||||||
|
i18n("© 2026 A-La-Karte Contributors"),
|
||||||
|
QString(),
|
||||||
|
QString());
|
||||||
|
|
||||||
|
aboutData.addAuthor(i18n("A-La-Karte Team"), i18n("Developer"), QString());
|
||||||
|
|
||||||
|
aboutData.setDesktopFileName(QStringLiteral("org.kde.alakarte"));
|
||||||
|
aboutData.setProductName(QByteArrayLiteral("alakarte"));
|
||||||
|
|
||||||
|
KAboutData::setApplicationData(aboutData);
|
||||||
|
const QIcon fallbackIcon(QStringLiteral(":/icons/sc-apps-org.kde.alakarte.svg"));
|
||||||
|
QGuiApplication::setWindowIcon(QIcon::fromTheme(QStringLiteral("org.kde.alakarte"), fallbackIcon));
|
||||||
|
|
||||||
|
KCrash::initialize();
|
||||||
|
|
||||||
|
QCommandLineParser parser;
|
||||||
|
aboutData.setupCommandLine(&parser);
|
||||||
|
parser.process(app);
|
||||||
|
aboutData.processCommandLine(&parser);
|
||||||
|
|
||||||
|
KDBusService service(KDBusService::Unique);
|
||||||
|
|
||||||
|
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
|
||||||
|
QQuickStyle::setStyle(QStringLiteral("org.kde.desktop"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QQmlApplicationEngine engine;
|
||||||
|
|
||||||
|
qmlRegisterSingletonType<App>("org.kde.alakarte", 1, 0, "App", [](QQmlEngine *engine, QJSEngine *) -> QObject * {
|
||||||
|
Q_UNUSED(engine)
|
||||||
|
return App::instance();
|
||||||
|
});
|
||||||
|
|
||||||
|
qmlRegisterSingletonType<GamepadManager>("org.kde.alakarte", 1, 0, "GamepadManager", &GamepadManager::create);
|
||||||
|
|
||||||
|
qmlRegisterType<GameModel>("org.kde.alakarte", 1, 0, "GameModel");
|
||||||
|
qmlRegisterType<GameSortFilterModel>("org.kde.alakarte", 1, 0, "GameSortFilterModel");
|
||||||
|
qmlRegisterUncreatableType<Game>("org.kde.alakarte", 1, 0, "Game", QStringLiteral("Game objects are created by GameModel"));
|
||||||
|
qmlRegisterType<GameLauncher>("org.kde.alakarte", 1, 0, "GameLauncher");
|
||||||
|
qmlRegisterType<Config>("org.kde.alakarte", 1, 0, "Config");
|
||||||
|
|
||||||
|
engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
|
||||||
|
|
||||||
|
engine.loadFromModule("org.kde.alakarte", "Main");
|
||||||
|
|
||||||
|
if (engine.rootObjects().isEmpty()) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
46
src/platformimporter.cpp
Normal file
46
src/platformimporter.cpp
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
PlatformImporter::PlatformImporter(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString PlatformImporter::findExecutable(const QString &name) const
|
||||||
|
{
|
||||||
|
return QStandardPaths::findExecutable(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PlatformImporter::directoryExists(const QString &path) const
|
||||||
|
{
|
||||||
|
return QDir(expandPath(path)).exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString PlatformImporter::expandPath(const QString &path) const
|
||||||
|
{
|
||||||
|
QString result = path;
|
||||||
|
|
||||||
|
// Expand ~ to home directory
|
||||||
|
if (result.startsWith(QLatin1Char('~'))) {
|
||||||
|
result.replace(0, 1, QDir::homePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand environment variables
|
||||||
|
static QRegularExpression envVarRegex(QStringLiteral("\\$\\{?([A-Za-z_][A-Za-z0-9_]*)\\}?"));
|
||||||
|
QRegularExpressionMatchIterator it = envVarRegex.globalMatch(result);
|
||||||
|
|
||||||
|
while (it.hasNext()) {
|
||||||
|
QRegularExpressionMatch match = it.next();
|
||||||
|
QString varName = match.captured(1);
|
||||||
|
QString varValue = QString::fromLocal8Bit(qgetenv(varName.toLocal8Bit().constData()));
|
||||||
|
result.replace(match.captured(0), varValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
32
src/platformimporter.h
Normal file
32
src/platformimporter.h
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
|
#include "game.h"
|
||||||
|
|
||||||
|
class PlatformImporter : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit PlatformImporter(QObject *parent = nullptr);
|
||||||
|
virtual ~PlatformImporter() = default;
|
||||||
|
|
||||||
|
virtual QString platformName() const = 0;
|
||||||
|
virtual QString platformId() const = 0;
|
||||||
|
virtual bool isAvailable() const = 0;
|
||||||
|
virtual QList<Game *> importGames() = 0;
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void importProgress(int current, int total);
|
||||||
|
void importError(const QString &error);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
QString findExecutable(const QString &name) const;
|
||||||
|
bool directoryExists(const QString &path) const;
|
||||||
|
QString expandPath(const QString &path) const;
|
||||||
|
};
|
||||||
334
src/qml/GameCard.qml
Normal file
334
src/qml/GameCard.qml
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Effects
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.alakarte
|
||||||
|
|
||||||
|
FocusScope {
|
||||||
|
id: gameCard
|
||||||
|
|
||||||
|
property var game
|
||||||
|
property bool showPlayButton: true
|
||||||
|
property bool focused: activeFocus
|
||||||
|
|
||||||
|
readonly property bool isTouchDevice: Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
|
||||||
|
readonly property int adaptiveFocusRingWidth: 1
|
||||||
|
readonly property real adaptiveHoverScale: 1.015
|
||||||
|
|
||||||
|
readonly property bool useAnimatedCover: App.config.animatedCovers
|
||||||
|
&& game
|
||||||
|
&& game.coverUrl
|
||||||
|
&& game.coverUrl.toString().toLowerCase().endsWith(".gif")
|
||||||
|
|
||||||
|
readonly property int coverStatus: useAnimatedCover ? animatedCover.status : staticCover.status
|
||||||
|
|
||||||
|
signal clicked()
|
||||||
|
signal doubleClicked()
|
||||||
|
signal playClicked()
|
||||||
|
|
||||||
|
Kirigami.ShadowedRectangle {
|
||||||
|
id: cardBackground
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
color: Kirigami.Theme.backgroundColor
|
||||||
|
|
||||||
|
shadow {
|
||||||
|
size: (hoverHandler.hovered || gameCard.focused) ? Kirigami.Units.mediumSpacing : Kirigami.Units.smallSpacing
|
||||||
|
color: (hoverHandler.hovered || gameCard.focused) ? Qt.rgba(0, 0, 0, 0.30) : Qt.rgba(0, 0, 0, 0.16)
|
||||||
|
}
|
||||||
|
|
||||||
|
border.width: gameCard.focused ? gameCard.adaptiveFocusRingWidth : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on shadow.size {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
scale: (hoverHandler.hovered || gameCard.focused) ? gameCard.adaptiveHoverScale : 1.0
|
||||||
|
Behavior on scale {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover image
|
||||||
|
Image {
|
||||||
|
id: staticCover
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Kirigami.Units.smallSpacing
|
||||||
|
source: game ? game.coverUrl : ""
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
visible: !gameCard.useAnimatedCover
|
||||||
|
smooth: true
|
||||||
|
mipmap: App.config.highQualityImages
|
||||||
|
sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: MultiEffect {
|
||||||
|
maskEnabled: true
|
||||||
|
maskSource: ShaderEffectSource {
|
||||||
|
sourceItem: Rectangle {
|
||||||
|
width: staticCover.width
|
||||||
|
height: staticCover.height
|
||||||
|
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedImage {
|
||||||
|
id: animatedCover
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Kirigami.Units.smallSpacing
|
||||||
|
source: game ? game.coverUrl : ""
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
playing: true
|
||||||
|
visible: gameCard.useAnimatedCover
|
||||||
|
smooth: true
|
||||||
|
mipmap: App.config.highQualityImages
|
||||||
|
sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: MultiEffect {
|
||||||
|
maskEnabled: true
|
||||||
|
maskSource: ShaderEffectSource {
|
||||||
|
sourceItem: Rectangle {
|
||||||
|
width: animatedCover.width
|
||||||
|
height: animatedCover.height
|
||||||
|
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder when no cover
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Kirigami.Units.smallSpacing
|
||||||
|
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
|
||||||
|
color: Kirigami.Theme.alternateBackgroundColor
|
||||||
|
visible: gameCard.coverStatus !== Image.Ready
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
source: "applications-games"
|
||||||
|
width: parent.width * 0.4
|
||||||
|
height: width
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gradient overlay for text
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: Kirigami.Units.smallSpacing
|
||||||
|
height: parent.height * 0.4
|
||||||
|
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: "transparent" }
|
||||||
|
GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.5) }
|
||||||
|
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.85) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game title
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: Kirigami.Units.mediumSpacing
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: game ? game.name : ""
|
||||||
|
font.bold: true
|
||||||
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize
|
||||||
|
color: "white"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
maximumLineCount: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
visible: App.config.showPlatformBadges
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
implicitWidth: platformLabel.implicitWidth + Kirigami.Units.largeSpacing
|
||||||
|
implicitHeight: platformLabel.implicitHeight + Kirigami.Units.smallSpacing
|
||||||
|
Layout.preferredWidth: implicitWidth
|
||||||
|
Layout.preferredHeight: implicitHeight
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: getPlatformColor(game ? game.platform : "")
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
id: platformLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: getPlatformDisplayName(game ? game.platform : "")
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
color: "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorite indicator
|
||||||
|
Kirigami.Icon {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Kirigami.Units.mediumSpacing
|
||||||
|
width: Kirigami.Units.iconSizes.medium
|
||||||
|
height: width
|
||||||
|
source: "bookmark-new"
|
||||||
|
color: Kirigami.Theme.positiveTextColor
|
||||||
|
visible: game && game.favorite
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: MultiEffect {
|
||||||
|
shadowEnabled: true
|
||||||
|
shadowColor: Qt.rgba(0, 0, 0, 0.5)
|
||||||
|
shadowBlur: 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running indicator
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Kirigami.Units.mediumSpacing
|
||||||
|
width: Kirigami.Units.iconSizes.small
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: Kirigami.Theme.positiveTextColor
|
||||||
|
visible: game && game.running
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: game && game.running
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.3; duration: 800 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 800 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play button overlay
|
||||||
|
Rectangle {
|
||||||
|
id: playOverlay
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Kirigami.Units.smallSpacing
|
||||||
|
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.6)
|
||||||
|
opacity: (hoverHandler.hovered || gameCard.focused) && showPlayButton ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Kirigami.Units.iconSizes.huge
|
||||||
|
height: width
|
||||||
|
source: "media-playback-start"
|
||||||
|
color: "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hoverHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: {
|
||||||
|
if (App.config.coverLaunchesGame) {
|
||||||
|
gameCard.playClicked()
|
||||||
|
} else {
|
||||||
|
gameCard.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
onDoubleTapped: {
|
||||||
|
gameCard.doubleClicked()
|
||||||
|
gameCard.playClicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
TapHandler {
|
||||||
|
acceptedButtons: Qt.RightButton
|
||||||
|
onTapped: contextMenu.popup()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Menu {
|
||||||
|
id: contextMenu
|
||||||
|
|
||||||
|
QQC2.MenuItem {
|
||||||
|
text: i18n("Play")
|
||||||
|
icon.name: "media-playback-start"
|
||||||
|
onTriggered: gameCard.playClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.MenuSeparator {}
|
||||||
|
|
||||||
|
QQC2.MenuItem {
|
||||||
|
text: game && game.favorite ? i18n("Remove from Favorites") : i18n("Add to Favorites")
|
||||||
|
icon.name: game && game.favorite ? "bookmark-remove" : "bookmark-new"
|
||||||
|
onTriggered: if (game) game.favorite = !game.favorite
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.MenuItem {
|
||||||
|
text: game && game.hidden ? i18n("Show in Library") : i18n("Hide from Library")
|
||||||
|
icon.name: game && game.hidden ? "view-visible" : "view-hidden"
|
||||||
|
onTriggered: if (game) game.hidden = !game.hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.MenuSeparator {}
|
||||||
|
|
||||||
|
QQC2.MenuItem {
|
||||||
|
text: i18n("View Details")
|
||||||
|
icon.name: "documentation"
|
||||||
|
onTriggered: gameCard.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformColor(platform) {
|
||||||
|
if (!platform) return Kirigami.Theme.highlightColor
|
||||||
|
if (platform.includes("Steam")) return "#1b2838"
|
||||||
|
if (platform.includes("Lutris")) return "#ff9800"
|
||||||
|
if (platform.includes("Epic")) return "#0078f2"
|
||||||
|
if (platform.includes("GOG")) return "#86328a"
|
||||||
|
if (platform.includes("Amazon")) return "#ff9900"
|
||||||
|
return Kirigami.Theme.highlightColor
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformDisplayName(platform) {
|
||||||
|
if (!platform) return ""
|
||||||
|
if (platform.includes("Steam")) return "Steam"
|
||||||
|
if (platform.includes("Lutris")) return "Lutris"
|
||||||
|
if (platform.includes("Epic")) return "Epic"
|
||||||
|
if (platform.includes("GOG")) return "GOG"
|
||||||
|
if (platform.includes("Amazon")) return "Amazon"
|
||||||
|
return platform
|
||||||
|
}
|
||||||
|
}
|
||||||
539
src/qml/GameDetailsSheet.qml
Normal file
539
src/qml/GameDetailsSheet.qml
Normal file
|
|
@ -0,0 +1,539 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Effects
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.alakarte
|
||||||
|
|
||||||
|
Kirigami.OverlaySheet {
|
||||||
|
id: detailsSheet
|
||||||
|
|
||||||
|
property var game
|
||||||
|
|
||||||
|
readonly property bool isMobile: Kirigami.Settings.isMobile
|
||||||
|
readonly property bool isNarrowScreen: applicationWindow() && applicationWindow().width < Kirigami.Units.gridUnit * 30
|
||||||
|
readonly property bool useCompactLayout: isMobile || isNarrowScreen
|
||||||
|
readonly property bool isTouchDevice: Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
|
||||||
|
|
||||||
|
readonly property int actionIconButtonSize: isTouchDevice ? Math.round(Kirigami.Units.gridUnit * 2.5) : Math.round(Kirigami.Units.gridUnit * 2)
|
||||||
|
|
||||||
|
readonly property bool useAnimatedCover: App.config.animatedCovers
|
||||||
|
&& game
|
||||||
|
&& game.coverUrl
|
||||||
|
&& game.coverUrl.toString().toLowerCase().endsWith(".gif")
|
||||||
|
|
||||||
|
implicitWidth: {
|
||||||
|
if (isMobile) return applicationWindow().width
|
||||||
|
if (isNarrowScreen) return applicationWindow().width - Kirigami.Units.largeSpacing * 2
|
||||||
|
return Math.min(applicationWindow().width - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 50)
|
||||||
|
}
|
||||||
|
implicitHeight: {
|
||||||
|
if (isMobile) return applicationWindow().height
|
||||||
|
return Math.min(applicationWindow().height - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 42)
|
||||||
|
}
|
||||||
|
|
||||||
|
signal launchRequested()
|
||||||
|
signal editRequested()
|
||||||
|
signal removeRequested()
|
||||||
|
|
||||||
|
title: game ? game.name : ""
|
||||||
|
|
||||||
|
onOpened: playButton.forceActiveFocus()
|
||||||
|
|
||||||
|
function isDescendant(item, ancestor) {
|
||||||
|
let p = item
|
||||||
|
while (p) {
|
||||||
|
if (p === ancestor) return true
|
||||||
|
p = p.parent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNextInChain(forward) {
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
|
||||||
|
let next = w.activeFocusItem
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
next = next.nextItemInFocusChain(forward)
|
||||||
|
if (!next) return
|
||||||
|
if (detailsSheet.isDescendant(next, detailsSheet)) {
|
||||||
|
next.forceActiveFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateLeft() {
|
||||||
|
if (!detailsSheet.opened) return
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
if (!detailsSheet.isDescendant(w.activeFocusItem, detailsSheet)) return
|
||||||
|
detailsSheet.focusNextInChain(false)
|
||||||
|
}
|
||||||
|
function onNavigateRight() {
|
||||||
|
if (!detailsSheet.opened) return
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
if (!detailsSheet.isDescendant(w.activeFocusItem, detailsSheet)) return
|
||||||
|
detailsSheet.focusNextInChain(true)
|
||||||
|
}
|
||||||
|
function onNavigateUp() {
|
||||||
|
if (!detailsSheet.opened) return
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
if (!detailsSheet.isDescendant(w.activeFocusItem, detailsSheet)) return
|
||||||
|
detailsSheet.focusNextInChain(false)
|
||||||
|
}
|
||||||
|
function onNavigateDown() {
|
||||||
|
if (!detailsSheet.opened) return
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
if (!detailsSheet.isDescendant(w.activeFocusItem, detailsSheet)) return
|
||||||
|
detailsSheet.focusNextInChain(true)
|
||||||
|
}
|
||||||
|
function onSelectPressed() {
|
||||||
|
if (!detailsSheet.opened) return
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
if (!detailsSheet.isDescendant(w.activeFocusItem, detailsSheet)) return
|
||||||
|
if (w.activeFocusItem.clicked) {
|
||||||
|
w.activeFocusItem.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Kirigami.ShadowedRectangle {
|
||||||
|
implicitHeight: Math.max(
|
||||||
|
detailsSheet.useCompactLayout ? Kirigami.Units.gridUnit * 10 : Kirigami.Units.gridUnit * 13,
|
||||||
|
headerContent.implicitHeight
|
||||||
|
) + Kirigami.Units.largeSpacing * 2
|
||||||
|
implicitWidth: detailsSheet.useCompactLayout ? detailsSheet.implicitWidth : Kirigami.Units.gridUnit * 34
|
||||||
|
|
||||||
|
radius: Kirigami.Units.mediumSpacing
|
||||||
|
clip: true
|
||||||
|
color: Kirigami.Theme.backgroundColor
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: blurredBackground
|
||||||
|
anchors.fill: parent
|
||||||
|
source: game ? game.coverUrl : ""
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
opacity: 0.3
|
||||||
|
smooth: true
|
||||||
|
mipmap: App.config.highQualityImages
|
||||||
|
sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: MultiEffect {
|
||||||
|
blurEnabled: true
|
||||||
|
blur: 1.0
|
||||||
|
blurMax: 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.3) }
|
||||||
|
GradientStop { position: 1.0; color: Kirigami.Theme.backgroundColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: headerContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: Kirigami.Units.largeSpacing
|
||||||
|
spacing: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
Kirigami.ShadowedRectangle {
|
||||||
|
Layout.preferredWidth: detailsSheet.useCompactLayout ? Kirigami.Units.gridUnit * 6 : Kirigami.Units.gridUnit * 8
|
||||||
|
Layout.preferredHeight: detailsSheet.useCompactLayout ? Kirigami.Units.gridUnit * 8.5 : Kirigami.Units.gridUnit * 11
|
||||||
|
radius: Kirigami.Units.mediumSpacing
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
shadow {
|
||||||
|
size: Kirigami.Units.mediumSpacing
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.35)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: staticCover
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 1
|
||||||
|
source: game ? game.coverUrl : ""
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
visible: !detailsSheet.useAnimatedCover
|
||||||
|
smooth: true
|
||||||
|
mipmap: App.config.highQualityImages
|
||||||
|
sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: MultiEffect {
|
||||||
|
maskEnabled: true
|
||||||
|
maskSource: ShaderEffectSource {
|
||||||
|
sourceItem: Rectangle {
|
||||||
|
width: staticCover.width
|
||||||
|
height: staticCover.height
|
||||||
|
radius: Kirigami.Units.mediumSpacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedImage {
|
||||||
|
id: animatedCover
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 1
|
||||||
|
source: game ? game.coverUrl : ""
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
playing: true
|
||||||
|
visible: detailsSheet.useAnimatedCover
|
||||||
|
smooth: true
|
||||||
|
mipmap: App.config.highQualityImages
|
||||||
|
sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1))
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: MultiEffect {
|
||||||
|
maskEnabled: true
|
||||||
|
maskSource: ShaderEffectSource {
|
||||||
|
sourceItem: Rectangle {
|
||||||
|
width: animatedCover.width
|
||||||
|
height: animatedCover.height
|
||||||
|
radius: Kirigami.Units.mediumSpacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Kirigami.Theme.backgroundColor
|
||||||
|
visible: detailsSheet.useAnimatedCover ? (animatedCover.status !== Image.Ready) : (staticCover.status !== Image.Ready)
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
source: "applications-games"
|
||||||
|
width: parent.width * 0.4
|
||||||
|
height: width
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
Kirigami.Heading {
|
||||||
|
text: game ? game.name : ""
|
||||||
|
level: 1
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
maximumLineCount: 2
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
implicitWidth: platformLabel.implicitWidth + Kirigami.Units.largeSpacing
|
||||||
|
implicitHeight: platformLabel.implicitHeight + Kirigami.Units.smallSpacing
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: getPlatformColor(game ? game.platform : "")
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
id: platformLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: getPlatformDisplayName(game ? game.platform : "")
|
||||||
|
color: "white"
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
source: "bookmark-new"
|
||||||
|
visible: game && game.favorite
|
||||||
|
implicitWidth: Kirigami.Units.iconSizes.small
|
||||||
|
implicitHeight: Kirigami.Units.iconSizes.small
|
||||||
|
color: Kirigami.Theme.positiveTextColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.preferredHeight: Kirigami.Units.smallSpacing }
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
columns: 2
|
||||||
|
columnSpacing: Kirigami.Units.mediumSpacing
|
||||||
|
rowSpacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: i18n("Play time:")
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
QQC2.Label {
|
||||||
|
text: game ? game.playTimeFormatted() : ""
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: i18n("Last played:")
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
QQC2.Label {
|
||||||
|
text: game ? game.lastPlayedFormatted() : ""
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: i18n("Added:")
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
QQC2.Label {
|
||||||
|
text: game ? game.dateAddedFormatted() : ""
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
id: playButton
|
||||||
|
text: game && game.running ? i18n("Running...") : i18n("Play")
|
||||||
|
icon.name: "media-playback-start"
|
||||||
|
enabled: game && !game.running
|
||||||
|
highlighted: true
|
||||||
|
focus: true
|
||||||
|
|
||||||
|
onClicked: detailsSheet.launchRequested()
|
||||||
|
|
||||||
|
Keys.onReturnPressed: clicked()
|
||||||
|
Keys.onEnterPressed: clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
icon.name: game && game.favorite ? "bookmark-remove" : "bookmark-new"
|
||||||
|
text: game && game.favorite ? i18n("Unfavorite") : i18n("Favorite")
|
||||||
|
display: QQC2.AbstractButton.IconOnly
|
||||||
|
implicitWidth: detailsSheet.actionIconButtonSize
|
||||||
|
implicitHeight: detailsSheet.actionIconButtonSize
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
hoverEnabled: true
|
||||||
|
flat: true
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: parent.activeFocus ? 1 : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ToolTip.text: text
|
||||||
|
QQC2.ToolTip.visible: hovered
|
||||||
|
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
|
|
||||||
|
onClicked: if (game) game.favorite = !game.favorite
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
icon.name: game && game.hidden ? "view-visible" : "view-hidden"
|
||||||
|
text: game && game.hidden ? i18n("Unhide") : i18n("Hide")
|
||||||
|
display: QQC2.AbstractButton.IconOnly
|
||||||
|
implicitWidth: detailsSheet.actionIconButtonSize
|
||||||
|
implicitHeight: detailsSheet.actionIconButtonSize
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
hoverEnabled: true
|
||||||
|
flat: true
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: parent.activeFocus ? 1 : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ToolTip.text: text
|
||||||
|
QQC2.ToolTip.visible: hovered
|
||||||
|
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
|
|
||||||
|
onClicked: if (game) {
|
||||||
|
game.hidden = !game.hidden
|
||||||
|
App.saveLibrary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
icon.name: "document-edit"
|
||||||
|
text: i18n("Edit")
|
||||||
|
display: QQC2.AbstractButton.IconOnly
|
||||||
|
implicitWidth: detailsSheet.actionIconButtonSize
|
||||||
|
implicitHeight: detailsSheet.actionIconButtonSize
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
hoverEnabled: true
|
||||||
|
flat: true
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: parent.activeFocus ? 1 : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ToolTip.text: text
|
||||||
|
QQC2.ToolTip.visible: hovered
|
||||||
|
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
|
|
||||||
|
onClicked: detailsSheet.editRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
icon.name: "edit-delete"
|
||||||
|
text: i18n("Remove")
|
||||||
|
display: QQC2.AbstractButton.IconOnly
|
||||||
|
implicitWidth: detailsSheet.actionIconButtonSize
|
||||||
|
implicitHeight: detailsSheet.actionIconButtonSize
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
hoverEnabled: true
|
||||||
|
flat: true
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: parent.activeFocus ? 1 : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ToolTip.text: text
|
||||||
|
QQC2.ToolTip.visible: hovered
|
||||||
|
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
|
|
||||||
|
onClicked: detailsSheet.removeRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: Kirigami.Units.largeSpacing
|
||||||
|
implicitWidth: Kirigami.Units.gridUnit * 30
|
||||||
|
|
||||||
|
Kirigami.FormLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: game ? game.developer : ""
|
||||||
|
visible: game && game.developer.length > 0
|
||||||
|
Kirigami.FormData.label: i18n("Developer:")
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: game ? game.publisher : ""
|
||||||
|
visible: game && game.publisher.length > 0
|
||||||
|
Kirigami.FormData.label: i18n("Publisher:")
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: game ? game.launchCommand : ""
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
Kirigami.FormData.label: i18n("Launch command:")
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: game ? game.workingDirectory : i18n("Not specified")
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
visible: game && game.workingDirectory.length > 0
|
||||||
|
Kirigami.FormData.label: i18n("Install path:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformColor(platform) {
|
||||||
|
if (!platform) return Kirigami.Theme.highlightColor
|
||||||
|
if (platform.includes("Steam")) return "#1b2838"
|
||||||
|
if (platform.includes("Lutris")) return "#ff9800"
|
||||||
|
if (platform.includes("Epic")) return "#0078f2"
|
||||||
|
if (platform.includes("GOG")) return "#86328a"
|
||||||
|
if (platform.includes("Amazon")) return "#ff9900"
|
||||||
|
return Kirigami.Theme.highlightColor
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformDisplayName(platform) {
|
||||||
|
if (!platform) return ""
|
||||||
|
if (platform.includes("Steam")) return "Steam"
|
||||||
|
if (platform.includes("Lutris")) return "Lutris"
|
||||||
|
if (platform.includes("Epic")) return "Epic"
|
||||||
|
if (platform.includes("GOG")) return "GOG"
|
||||||
|
if (platform.includes("Amazon")) return "Amazon"
|
||||||
|
return platform
|
||||||
|
}
|
||||||
|
}
|
||||||
258
src/qml/GameEditDialog.qml
Normal file
258
src/qml/GameEditDialog.qml
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.kirigamiaddons.formcard as FormCard
|
||||||
|
import org.kde.alakarte
|
||||||
|
|
||||||
|
Kirigami.Dialog {
|
||||||
|
id: dialog
|
||||||
|
|
||||||
|
property var game: null
|
||||||
|
property bool isEditing: game !== null
|
||||||
|
|
||||||
|
signal gameCreated(var game)
|
||||||
|
signal gameUpdated(var game)
|
||||||
|
|
||||||
|
title: isEditing ? i18n("Edit Game") : i18n("Add New Game")
|
||||||
|
standardButtons: Kirigami.Dialog.NoButton
|
||||||
|
|
||||||
|
width: Math.min(parent.width - Kirigami.Units.gridUnit * 4, Kirigami.Units.gridUnit * 30)
|
||||||
|
|
||||||
|
customFooterActions: [
|
||||||
|
Kirigami.Action {
|
||||||
|
text: isEditing ? i18n("Apply") : i18n("Add")
|
||||||
|
icon.name: isEditing ? "dialog-ok-apply" : "list-add"
|
||||||
|
enabled: nameField.text.trim() !== "" && executableField.text.trim() !== ""
|
||||||
|
onTriggered: {
|
||||||
|
if (isEditing) {
|
||||||
|
game.name = nameField.text.trim()
|
||||||
|
game.developer = developerField.text.trim()
|
||||||
|
game.launchCommand = executableField.text.trim()
|
||||||
|
game.workingDirectory = workingDirField.text.trim()
|
||||||
|
if (selectedCoverPath !== "") {
|
||||||
|
App.setCoverFromFile(game, selectedCoverPath)
|
||||||
|
}
|
||||||
|
App.saveLibrary()
|
||||||
|
gameUpdated(game)
|
||||||
|
} else {
|
||||||
|
let newGame = App.createGame(nameField.text.trim(), executableField.text.trim())
|
||||||
|
if (newGame) {
|
||||||
|
newGame.developer = developerField.text.trim()
|
||||||
|
newGame.workingDirectory = workingDirField.text.trim()
|
||||||
|
if (selectedCoverPath !== "") {
|
||||||
|
App.setCoverFromFile(newGame, selectedCoverPath)
|
||||||
|
}
|
||||||
|
App.saveLibrary()
|
||||||
|
gameCreated(newGame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Kirigami.Action {
|
||||||
|
text: i18n("Cancel")
|
||||||
|
icon.name: "dialog-cancel"
|
||||||
|
onTriggered: dialog.close()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
property string selectedCoverPath: ""
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
FormCard.FormTextFieldDelegate {
|
||||||
|
id: nameField
|
||||||
|
label: i18n("Name")
|
||||||
|
text: isEditing && game ? game.name : ""
|
||||||
|
placeholderText: i18n("Game title")
|
||||||
|
onAccepted: developerField.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormTextFieldDelegate {
|
||||||
|
id: developerField
|
||||||
|
label: i18n("Developer")
|
||||||
|
text: isEditing && game ? (game.developer || "") : ""
|
||||||
|
placeholderText: i18n("Optional")
|
||||||
|
onAccepted: executableField.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormTextFieldDelegate {
|
||||||
|
id: executableField
|
||||||
|
label: i18n("Executable")
|
||||||
|
text: isEditing && game ? game.launchCommand : ""
|
||||||
|
placeholderText: i18n("/path/to/game or command")
|
||||||
|
onAccepted: if (nameField.text.trim() !== "" && text.trim() !== "") {
|
||||||
|
dialog.customFooterActions[0].trigger()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Browse...")
|
||||||
|
icon.name: "document-open"
|
||||||
|
onClicked: fileDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormTextFieldDelegate {
|
||||||
|
id: workingDirField
|
||||||
|
label: i18n("Working Directory")
|
||||||
|
text: isEditing && game ? (game.workingDirectory || "") : ""
|
||||||
|
placeholderText: i18n("Optional")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
title: i18n("Cover Art")
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Kirigami.Units.gridUnit * 8
|
||||||
|
Layout.margins: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
||||||
|
Layout.preferredHeight: Kirigami.Units.gridUnit * 7
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: Kirigami.Theme.alternateBackgroundColor
|
||||||
|
border.color: Kirigami.Theme.disabledTextColor
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 2
|
||||||
|
source: selectedCoverPath !== "" ? "file://" + selectedCoverPath :
|
||||||
|
(isEditing && game && game.coverUrl.toString() !== "" ? game.coverUrl : "")
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
source: "image-x-generic"
|
||||||
|
width: Kirigami.Units.iconSizes.large
|
||||||
|
height: width
|
||||||
|
visible: parent.status !== Image.Ready
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
text: i18n("Choose Cover...")
|
||||||
|
icon.name: "document-open"
|
||||||
|
onClicked: coverDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
text: i18n("Fetch from SteamGridDB")
|
||||||
|
icon.name: "download"
|
||||||
|
enabled: App.steamGridDB.enabled && App.steamGridDB.apiKey.length > 0 &&
|
||||||
|
nameField.text.trim() !== "" && !App.steamGridDB.busy
|
||||||
|
visible: App.steamGridDB.enabled
|
||||||
|
onClicked: {
|
||||||
|
if (isEditing && game) {
|
||||||
|
App.steamGridDB.fetchCover(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
text: i18n("Clear Cover")
|
||||||
|
icon.name: "edit-clear"
|
||||||
|
visible: selectedCoverPath !== "" || (isEditing && game && game.coverUrl.toString() !== "")
|
||||||
|
onClicked: {
|
||||||
|
selectedCoverPath = ""
|
||||||
|
if (isEditing && game) {
|
||||||
|
game.coverUrl = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
title: i18n("Help")
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormTextDelegate {
|
||||||
|
text: i18n("To launch an executable, enter its full path:\n\"/path/to/game\"\n\nTo open a file with the default application:\nxdg-open \"/path/to/file\"\n\nIf the path contains spaces, wrap it in quotes.")
|
||||||
|
textItem.wrapMode: Text.WordWrap
|
||||||
|
textItem.font: Kirigami.Theme.smallFont
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: fileDialog
|
||||||
|
title: i18n("Select Executable")
|
||||||
|
fileMode: FileDialog.OpenFile
|
||||||
|
onAccepted: {
|
||||||
|
let path = selectedFile.toString().replace("file://", "")
|
||||||
|
if (path.includes(" ")) {
|
||||||
|
executableField.text = "\"" + path + "\""
|
||||||
|
} else {
|
||||||
|
executableField.text = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: coverDialog
|
||||||
|
title: i18n("Select Cover Image")
|
||||||
|
fileMode: FileDialog.OpenFile
|
||||||
|
nameFilters: [i18n("Image files (*.png *.jpg *.jpeg *.webp)"), i18n("All files (*)")]
|
||||||
|
onAccepted: {
|
||||||
|
selectedCoverPath = selectedFile.toString().replace("file://", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCoverImage(game) {
|
||||||
|
if (selectedCoverPath === "") return
|
||||||
|
|
||||||
|
// Copy cover to app data directory
|
||||||
|
let coversPath = StandardPaths.writableLocation(StandardPaths.AppDataLocation) + "/covers"
|
||||||
|
let fileName = game.id + ".jpg"
|
||||||
|
let destPath = coversPath + "/" + fileName
|
||||||
|
|
||||||
|
// Use App to copy the file and set cover URL
|
||||||
|
App.setCoverFromFile(game, selectedCoverPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: {
|
||||||
|
nameField.forceActiveFocus()
|
||||||
|
selectedCoverPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
216
src/qml/LibraryView.qml
Normal file
216
src/qml/LibraryView.qml
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.alakarte
|
||||||
|
import "components"
|
||||||
|
|
||||||
|
FocusScope {
|
||||||
|
id: libraryRoot
|
||||||
|
|
||||||
|
property string filterSource: "all"
|
||||||
|
property bool searchActive: false
|
||||||
|
property int focusedIndex: 0
|
||||||
|
|
||||||
|
property int adaptiveCardSize: App.config.gridSize
|
||||||
|
property bool isTouchDevice: false
|
||||||
|
|
||||||
|
signal gameSelected(var game)
|
||||||
|
signal gameLaunched(var game)
|
||||||
|
|
||||||
|
function focusSearch() {
|
||||||
|
searchField.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFocus() {
|
||||||
|
if (libraryRoot.searchActive) {
|
||||||
|
libraryRoot.focusSearch()
|
||||||
|
} else {
|
||||||
|
gameGrid.forceActiveFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchActiveChanged: {
|
||||||
|
if (!libraryRoot.searchActive) {
|
||||||
|
Qt.callLater(function() {
|
||||||
|
gameGrid.forceActiveFocus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 0
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
SearchHeader {
|
||||||
|
id: searchHeader
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: libraryRoot.searchActive
|
||||||
|
|
||||||
|
searchField: searchField
|
||||||
|
|
||||||
|
onSearchChanged: function(text) {
|
||||||
|
proxyModel.filterText = text
|
||||||
|
}
|
||||||
|
|
||||||
|
onSortChanged: function(mode) {
|
||||||
|
proxyModel.sortMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.SearchField {
|
||||||
|
id: searchField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: i18n("Search games...")
|
||||||
|
|
||||||
|
onTextChanged: proxyModel.filterText = text
|
||||||
|
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
text = ""
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (w && w.hasOwnProperty("searchActive")) {
|
||||||
|
w.searchActive = false
|
||||||
|
} else {
|
||||||
|
libraryRoot.searchActive = false
|
||||||
|
}
|
||||||
|
libraryRoot.restoreFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onDownPressed: gameGrid.forceActiveFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GameGridView {
|
||||||
|
id: gameGrid
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
cardSize: libraryRoot.adaptiveCardSize
|
||||||
|
|
||||||
|
model: GameSortFilterModel {
|
||||||
|
id: proxyModel
|
||||||
|
sourceModel: App.gameModel
|
||||||
|
showHidden: libraryRoot.filterSource === "hidden"
|
||||||
|
favoritesOnly: libraryRoot.filterSource === "favorites"
|
||||||
|
filterSource: {
|
||||||
|
if (libraryRoot.filterSource === "all") return ""
|
||||||
|
if (libraryRoot.filterSource === "favorites") return ""
|
||||||
|
if (libraryRoot.filterSource === "hidden") return ""
|
||||||
|
return libraryRoot.filterSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
width: gameGrid.cellWidth
|
||||||
|
height: gameGrid.cellHeight
|
||||||
|
|
||||||
|
function clicked() {
|
||||||
|
card.clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCard {
|
||||||
|
id: card
|
||||||
|
width: gameGrid.cardSize
|
||||||
|
height: Math.round(gameGrid.cardSize * 1.4)
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
game: model.gameObject
|
||||||
|
focused: gameGrid.currentIndex === index && gameGrid.activeFocus
|
||||||
|
|
||||||
|
onClicked: libraryRoot.gameSelected(model.gameObject)
|
||||||
|
onDoubleClicked: libraryRoot.gameLaunched(model.gameObject)
|
||||||
|
onPlayClicked: libraryRoot.gameLaunched(model.gameObject)
|
||||||
|
|
||||||
|
Keys.onReturnPressed: libraryRoot.gameSelected(model.gameObject)
|
||||||
|
Keys.onEnterPressed: libraryRoot.gameSelected(model.gameObject)
|
||||||
|
Keys.onSpacePressed: libraryRoot.gameLaunched(model.gameObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: function(event) {
|
||||||
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||||
|
let game = proxyModel.get(currentIndex)
|
||||||
|
if (game) {
|
||||||
|
libraryRoot.gameSelected(game)
|
||||||
|
}
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Space) {
|
||||||
|
let game = proxyModel.get(currentIndex)
|
||||||
|
if (game) {
|
||||||
|
libraryRoot.gameLaunched(game)
|
||||||
|
}
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EmptyState {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: proxyModel.count === 0 && !App.importing
|
||||||
|
|
||||||
|
icon: proxyModel.filterText.length > 0 ? "edit-find" : "applications-games"
|
||||||
|
title: proxyModel.filterText.length > 0 ?
|
||||||
|
i18n("No games found") : i18n("Your library is empty")
|
||||||
|
description: proxyModel.filterText.length > 0 ?
|
||||||
|
i18n("Try adjusting your search") :
|
||||||
|
i18n("Import games to get started")
|
||||||
|
|
||||||
|
actionText: proxyModel.filterText.length > 0 ? "" : i18n("Import Games")
|
||||||
|
onActionTriggered: App.importAllGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.BusyIndicator {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
running: App.importing
|
||||||
|
visible: App.importing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ToolBar {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: proxyModel.count > 0
|
||||||
|
|
||||||
|
leftPadding: 0
|
||||||
|
rightPadding: 0
|
||||||
|
topPadding: Kirigami.Units.smallSpacing
|
||||||
|
bottomPadding: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: i18np("%1 game", "%1 games", proxyModel.count)
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
QQC2.Slider {
|
||||||
|
id: sizeSlider
|
||||||
|
from: 120
|
||||||
|
to: 280
|
||||||
|
stepSize: 20
|
||||||
|
value: App.config.gridSize
|
||||||
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 8
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
onMoved: App.config.gridSize = value
|
||||||
|
|
||||||
|
QQC2.ToolTip {
|
||||||
|
parent: sizeSlider.handle
|
||||||
|
visible: sizeSlider.pressed
|
||||||
|
text: Math.round(sizeSlider.value) + " px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
835
src/qml/Main.qml
Normal file
835
src/qml/Main.qml
Normal file
|
|
@ -0,0 +1,835 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.kirigamiaddons.formcard as FormCard
|
||||||
|
import org.kde.alakarte
|
||||||
|
import "components"
|
||||||
|
|
||||||
|
Kirigami.ApplicationWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: i18n("A-La-Karte")
|
||||||
|
minimumWidth: Kirigami.Units.gridUnit * 25
|
||||||
|
minimumHeight: Kirigami.Units.gridUnit * 20
|
||||||
|
width: Kirigami.Units.gridUnit * 55
|
||||||
|
height: Kirigami.Units.gridUnit * 40
|
||||||
|
|
||||||
|
property var selectedGame: null
|
||||||
|
property string currentSource: "all"
|
||||||
|
property bool searchActive: false
|
||||||
|
|
||||||
|
function closeTopmost() {
|
||||||
|
if (gameEditDialog.visible) {
|
||||||
|
gameEditDialog.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (detailsSheet.opened) {
|
||||||
|
detailsSheet.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (importSheet.opened) {
|
||||||
|
importSheet.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (settingsSheet.opened) {
|
||||||
|
settingsSheet.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (sidebar.modal && sidebar.opened) {
|
||||||
|
sidebar.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (root.pageStack.layers.depth > 1) {
|
||||||
|
root.pageStack.layers.pop()
|
||||||
|
Qt.callLater(function() { libraryView.restoreFocus() })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (searchActive) {
|
||||||
|
searchActive = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function canOpenSettings() {
|
||||||
|
return !gameEditDialog.visible
|
||||||
|
&& !detailsSheet.opened
|
||||||
|
&& !importSheet.opened
|
||||||
|
&& !sidebar.opened
|
||||||
|
&& root.pageStack.layers.depth <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property bool isMobile: Kirigami.Settings.isMobile
|
||||||
|
readonly property bool isTablet: Kirigami.Settings.tabletMode && !Kirigami.Settings.isMobile
|
||||||
|
readonly property bool isTouchDevice: Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
|
||||||
|
readonly property bool isNarrowScreen: width < Kirigami.Units.gridUnit * 30
|
||||||
|
|
||||||
|
readonly property int adaptiveCardSize: App.config.gridSize
|
||||||
|
|
||||||
|
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
|
||||||
|
pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+F"
|
||||||
|
onActivated: {
|
||||||
|
searchActive = true
|
||||||
|
Qt.callLater(function() { libraryView.focusSearch() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+I"
|
||||||
|
onActivated: importSheet.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+N"
|
||||||
|
onActivated: {
|
||||||
|
gameEditDialog.game = null
|
||||||
|
gameEditDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: StandardKey.Preferences
|
||||||
|
onActivated: settingsSheet.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Escape"
|
||||||
|
onActivated: root.closeTopmost()
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: "F5"
|
||||||
|
onActivated: App.importAllGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+H"
|
||||||
|
onActivated: {
|
||||||
|
if (root.currentSource === "hidden") {
|
||||||
|
root.currentSource = "all"
|
||||||
|
} else {
|
||||||
|
root.currentSource = "hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+D"
|
||||||
|
onActivated: {
|
||||||
|
if (root.selectedGame) {
|
||||||
|
detailsSheet.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onBackPressed() {
|
||||||
|
root.closeTopmost()
|
||||||
|
}
|
||||||
|
function onMenuPressed() {
|
||||||
|
if (!settingsSheet.opened) {
|
||||||
|
if (root.canOpenSettings()) {
|
||||||
|
settingsSheet.open()
|
||||||
|
} else {
|
||||||
|
root.closeTopmost()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
settingsSheet.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onSearchPressed() {
|
||||||
|
searchActive = true
|
||||||
|
Qt.callLater(function() { libraryView.focusSearch() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root.pageStack.layers
|
||||||
|
function onDepthChanged() {
|
||||||
|
if (root.pageStack.layers.depth <= 1) {
|
||||||
|
Qt.callLater(function() { libraryView.restoreFocus() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: aboutPageComponent
|
||||||
|
FormCard.AboutPage {
|
||||||
|
title: i18n("About")
|
||||||
|
aboutData: ({
|
||||||
|
"displayName": i18n("A-La-Karte"),
|
||||||
|
"componentName": "alakarte",
|
||||||
|
"shortDescription": i18n("A unified game launcher for KDE Plasma"),
|
||||||
|
"homepage": "",
|
||||||
|
"bugAddress": "",
|
||||||
|
"version": Qt.application.version,
|
||||||
|
"otherText": "",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": i18n("A-La-Karte Contributors"),
|
||||||
|
"task": i18n("Contributors"),
|
||||||
|
"emailAddress": "",
|
||||||
|
"webAddress": "",
|
||||||
|
"ocsUsername": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"credits": [],
|
||||||
|
"translators": [],
|
||||||
|
"licenses": [
|
||||||
|
{
|
||||||
|
"name": i18n("GNU General Public License v3.0 or later"),
|
||||||
|
"text": "",
|
||||||
|
"spdx": "GPL-3.0-or-later"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"copyrightStatement": i18n("© 2026 A-La-Karte Contributors"),
|
||||||
|
"desktopFileName": "org.kde.alakarte"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalDrawer: Kirigami.OverlayDrawer {
|
||||||
|
id: sidebar
|
||||||
|
edge: Qt.LeftEdge
|
||||||
|
modal: root.isMobile || root.isNarrowScreen
|
||||||
|
handleClosedIcon.source: "application-menu"
|
||||||
|
handleOpenIcon.source: "go-previous"
|
||||||
|
handleVisible: root.isMobile || root.isNarrowScreen
|
||||||
|
|
||||||
|
width: {
|
||||||
|
if (root.isMobile) return Math.min(root.width * 0.85, Kirigami.Units.gridUnit * 20)
|
||||||
|
if (root.isTablet) return Kirigami.Units.gridUnit * 16
|
||||||
|
return Kirigami.Units.gridUnit * 14
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding {
|
||||||
|
target: sidebar
|
||||||
|
property: "drawerOpen"
|
||||||
|
value: true
|
||||||
|
when: !sidebar.modal
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: sidebarView.focusList()
|
||||||
|
onClosed: Qt.callLater(function() { libraryView.restoreFocus() })
|
||||||
|
|
||||||
|
leftPadding: 0
|
||||||
|
rightPadding: 0
|
||||||
|
topPadding: 0
|
||||||
|
bottomPadding: 0
|
||||||
|
|
||||||
|
contentItem: ColumnLayout {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Kirigami.AbstractApplicationHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
IconWithResourceFallback {
|
||||||
|
primary: "org.kde.alakarte"
|
||||||
|
secondary: ""
|
||||||
|
resourceFallback: Qt.resolvedUrl("icons/app/org.kde.alakarte.svg")
|
||||||
|
mask: false
|
||||||
|
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
|
||||||
|
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.Heading {
|
||||||
|
text: i18n("A-La-Karte")
|
||||||
|
level: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SidebarView {
|
||||||
|
id: sidebarView
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
onSourceSelected: function(source) {
|
||||||
|
root.currentSource = source
|
||||||
|
if (sidebar.modal) {
|
||||||
|
sidebar.close()
|
||||||
|
Qt.callLater(function() { libraryView.restoreFocus() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSettingsRequested: {
|
||||||
|
if (sidebar.modal) {
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
settingsSheet.open()
|
||||||
|
}
|
||||||
|
onImportRequested: {
|
||||||
|
if (sidebar.modal) {
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
importSheet.open()
|
||||||
|
}
|
||||||
|
onAboutRequested: {
|
||||||
|
if (sidebar.modal) {
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
root.pageStack.layers.push(aboutPageComponent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageStack.initialPage: Kirigami.Page {
|
||||||
|
id: mainPage
|
||||||
|
title: sidebarView.currentSourceName
|
||||||
|
|
||||||
|
padding: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
actions: [
|
||||||
|
Kirigami.Action {
|
||||||
|
icon.name: "search"
|
||||||
|
text: i18n("Search")
|
||||||
|
checkable: true
|
||||||
|
checked: root.searchActive
|
||||||
|
onTriggered: {
|
||||||
|
root.searchActive = !root.searchActive
|
||||||
|
if (root.searchActive) {
|
||||||
|
Qt.callLater(function() { libraryView.focusSearch() })
|
||||||
|
} else {
|
||||||
|
libraryView.restoreFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Kirigami.Action {
|
||||||
|
icon.name: "list-add"
|
||||||
|
text: i18n("Add Game")
|
||||||
|
onTriggered: {
|
||||||
|
gameEditDialog.game = null
|
||||||
|
gameEditDialog.open()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Kirigami.Action {
|
||||||
|
icon.name: "document-import"
|
||||||
|
text: i18n("Import Games")
|
||||||
|
onTriggered: importSheet.open()
|
||||||
|
},
|
||||||
|
Kirigami.Action {
|
||||||
|
icon.name: "configure"
|
||||||
|
text: i18n("Settings")
|
||||||
|
onTriggered: settingsSheet.open()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
LibraryView {
|
||||||
|
id: libraryView
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
filterSource: root.currentSource
|
||||||
|
searchActive: root.searchActive
|
||||||
|
|
||||||
|
adaptiveCardSize: root.adaptiveCardSize
|
||||||
|
isTouchDevice: root.isTouchDevice
|
||||||
|
|
||||||
|
onGameSelected: function(game) {
|
||||||
|
root.selectedGame = game
|
||||||
|
detailsSheet.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
onGameLaunched: function(game) {
|
||||||
|
App.launcher.launchGame(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GameDetailsSheet {
|
||||||
|
id: detailsSheet
|
||||||
|
game: root.selectedGame
|
||||||
|
|
||||||
|
onLaunchRequested: {
|
||||||
|
App.launcher.launchGame(root.selectedGame)
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditRequested: {
|
||||||
|
detailsSheet.close()
|
||||||
|
gameEditDialog.game = root.selectedGame
|
||||||
|
gameEditDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemoveRequested: {
|
||||||
|
if (root.selectedGame) {
|
||||||
|
let gameId = root.selectedGame.id
|
||||||
|
let gameName = root.selectedGame.name
|
||||||
|
App.removeGame(root.selectedGame)
|
||||||
|
detailsSheet.close()
|
||||||
|
root.selectedGame = null
|
||||||
|
showPassiveNotification(
|
||||||
|
i18n("%1 removed", gameName),
|
||||||
|
"long",
|
||||||
|
i18n("Undo"),
|
||||||
|
function() { App.restoreGame(gameId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClosed: {
|
||||||
|
libraryView.restoreFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.OverlaySheet {
|
||||||
|
id: importSheet
|
||||||
|
title: i18n("Import Games")
|
||||||
|
|
||||||
|
implicitWidth: {
|
||||||
|
if (root.isMobile) return applicationWindow().width
|
||||||
|
if (root.isNarrowScreen) return applicationWindow().width - Kirigami.Units.largeSpacing * 2
|
||||||
|
return Math.min(applicationWindow().width - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 32)
|
||||||
|
}
|
||||||
|
implicitHeight: {
|
||||||
|
if (root.isMobile) return applicationWindow().height
|
||||||
|
return Math.min(applicationWindow().height - Kirigami.Units.gridUnit * 2, importContent.implicitHeight + Kirigami.Units.gridUnit * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: steamImportButton.forceActiveFocus()
|
||||||
|
onClosed: libraryView.restoreFocus()
|
||||||
|
|
||||||
|
function isDescendant(item, ancestor) {
|
||||||
|
let p = item
|
||||||
|
while (p) {
|
||||||
|
if (p === ancestor) return true
|
||||||
|
p = p.parent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNextInChain(forward) {
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
let next = w.activeFocusItem
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
next = next.nextItemInFocusChain(forward)
|
||||||
|
if (!next) return
|
||||||
|
if (importSheet.isDescendant(next, importContent)) {
|
||||||
|
next.forceActiveFocus()
|
||||||
|
importScroll.ensureItemVisible(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateFocused() {
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
let item = w.activeFocusItem
|
||||||
|
if (typeof item.toggle === "function") {
|
||||||
|
item.toggle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.hasOwnProperty("checked")) {
|
||||||
|
item.checked = !item.checked
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof item.clicked === "function") {
|
||||||
|
item.clicked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateUp() {
|
||||||
|
if (!importSheet.opened || importScroll.activeFocus) return
|
||||||
|
importSheet.focusNextInChain(false)
|
||||||
|
}
|
||||||
|
function onNavigateDown() {
|
||||||
|
if (!importSheet.opened || importScroll.activeFocus) return
|
||||||
|
importSheet.focusNextInChain(true)
|
||||||
|
}
|
||||||
|
function onSelectPressed() {
|
||||||
|
if (!importSheet.opened || importScroll.activeFocus) return
|
||||||
|
importSheet.activateFocused()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: QQC2.ScrollView {
|
||||||
|
id: importScroll
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
leftPadding: Kirigami.Units.largeSpacing
|
||||||
|
rightPadding: Kirigami.Units.largeSpacing
|
||||||
|
topPadding: Kirigami.Units.largeSpacing
|
||||||
|
bottomPadding: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
|
||||||
|
|
||||||
|
function ensureItemVisible(item) {
|
||||||
|
if (!item || !importScroll.contentItem || !importScroll.contentItem.contentItem) return
|
||||||
|
let flick = importScroll.contentItem
|
||||||
|
let content = flick.contentItem
|
||||||
|
let p = item.mapToItem(content, 0, 0)
|
||||||
|
let itemTop = p.y
|
||||||
|
let itemBottom = p.y + item.height
|
||||||
|
let top = flick.contentY
|
||||||
|
let bottom = flick.contentY + flick.height
|
||||||
|
if (itemTop < top) {
|
||||||
|
flick.contentY = Math.max(0, itemTop)
|
||||||
|
} else if (itemBottom > bottom) {
|
||||||
|
flick.contentY = Math.max(0, itemBottom - flick.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollBy(delta) {
|
||||||
|
if (!importScroll.contentItem) return
|
||||||
|
let maxY = Math.max(0, importScroll.contentItem.contentHeight - importScroll.contentItem.height)
|
||||||
|
importScroll.contentItem.contentY = Math.max(0, Math.min(maxY, importScroll.contentItem.contentY + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateUp() { if (importScroll.activeFocus) importScroll.scrollBy(-Kirigami.Units.gridUnit * 2) }
|
||||||
|
function onNavigateDown() { if (importScroll.activeFocus) importScroll.scrollBy(Kirigami.Units.gridUnit * 2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: importContent
|
||||||
|
width: importScroll.availableWidth
|
||||||
|
spacing: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
Kirigami.InlineMessage {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
type: Kirigami.MessageType.Information
|
||||||
|
text: App.importStatus
|
||||||
|
visible: App.importing
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
id: steamImportButton
|
||||||
|
text: i18n("Steam")
|
||||||
|
description: i18n("Import from Steam library")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "com.valvesoftware.Steam"
|
||||||
|
secondary: "steam"
|
||||||
|
resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg")
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromSteam()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Lutris")
|
||||||
|
description: i18n("Import from Lutris")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "lutris"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromLutris()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Heroic")
|
||||||
|
description: i18n("Epic, GOG, Amazon games")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "com.heroicgameslauncher.hgl"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromHeroic()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Desktop Entries")
|
||||||
|
description: i18n("Games from system .desktop files")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "user-desktop"
|
||||||
|
secondary: "computer"
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromDesktop()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Bottles")
|
||||||
|
description: i18n("Wine applications from Bottles")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "com.usebottles.bottles"
|
||||||
|
secondary: "application-x-executable"
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromBottles()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Flatpak Games")
|
||||||
|
description: i18n("Games installed via Flatpak")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "flatpak-discover"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromFlatpak()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("itch.io")
|
||||||
|
description: i18n("Games from itch.io app")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "io.itch.itch"
|
||||||
|
secondary: "itch"
|
||||||
|
resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg")
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromItch()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Legendary")
|
||||||
|
description: i18n("Epic Games via Legendary CLI")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "legendary"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromLegendary()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("RetroArch")
|
||||||
|
description: i18n("Games from RetroArch playlists")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "org.libretro.RetroArch"
|
||||||
|
secondary: "retroarch"
|
||||||
|
resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg")
|
||||||
|
}
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importFromRetroArch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.alignment: Qt.AlignRight
|
||||||
|
spacing: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
text: i18n("Import All")
|
||||||
|
icon.name: "document-import"
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importAllGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
text: i18n("Close")
|
||||||
|
onClicked: importSheet.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.OverlaySheet {
|
||||||
|
id: settingsSheet
|
||||||
|
title: i18n("Settings")
|
||||||
|
|
||||||
|
implicitWidth: {
|
||||||
|
if (root.isMobile) return applicationWindow().width
|
||||||
|
if (root.isNarrowScreen) return applicationWindow().width - Kirigami.Units.largeSpacing * 2
|
||||||
|
return Math.min(applicationWindow().width - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 30)
|
||||||
|
}
|
||||||
|
implicitHeight: {
|
||||||
|
if (root.isMobile) return applicationWindow().height
|
||||||
|
return Math.min(applicationWindow().height - Kirigami.Units.gridUnit * 2, settingsContent.implicitHeight + Kirigami.Units.gridUnit * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: settingsContent.focusFirstControl()
|
||||||
|
onClosed: libraryView.restoreFocus()
|
||||||
|
|
||||||
|
function isDescendant(item, ancestor) {
|
||||||
|
let p = item
|
||||||
|
while (p) {
|
||||||
|
if (p === ancestor) return true
|
||||||
|
p = p.parent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNextInChain(forward) {
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
let next = w.activeFocusItem
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
next = next.nextItemInFocusChain(forward)
|
||||||
|
if (!next) return
|
||||||
|
if (settingsSheet.isDescendant(next, settingsContent)) {
|
||||||
|
next.forceActiveFocus()
|
||||||
|
settingsScroll.ensureItemVisible(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateFocused() {
|
||||||
|
let w = applicationWindow()
|
||||||
|
if (!w || !w.activeFocusItem) return
|
||||||
|
let item = w.activeFocusItem
|
||||||
|
if (typeof item.toggle === "function") {
|
||||||
|
item.toggle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.hasOwnProperty("checked")) {
|
||||||
|
item.checked = !item.checked
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof item.clicked === "function") {
|
||||||
|
item.clicked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateUp() {
|
||||||
|
if (!settingsSheet.opened || settingsScroll.activeFocus) return
|
||||||
|
settingsSheet.focusNextInChain(false)
|
||||||
|
}
|
||||||
|
function onNavigateDown() {
|
||||||
|
if (!settingsSheet.opened || settingsScroll.activeFocus) return
|
||||||
|
settingsSheet.focusNextInChain(true)
|
||||||
|
}
|
||||||
|
function onSelectPressed() {
|
||||||
|
if (!settingsSheet.opened || settingsScroll.activeFocus) return
|
||||||
|
settingsSheet.activateFocused()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: QQC2.ScrollView {
|
||||||
|
id: settingsScroll
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
leftPadding: Kirigami.Units.largeSpacing
|
||||||
|
rightPadding: Kirigami.Units.largeSpacing
|
||||||
|
topPadding: Kirigami.Units.largeSpacing
|
||||||
|
bottomPadding: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
|
||||||
|
|
||||||
|
function ensureItemVisible(item) {
|
||||||
|
if (!item || !settingsScroll.contentItem || !settingsScroll.contentItem.contentItem) return
|
||||||
|
let flick = settingsScroll.contentItem
|
||||||
|
let content = flick.contentItem
|
||||||
|
let p = item.mapToItem(content, 0, 0)
|
||||||
|
let itemTop = p.y
|
||||||
|
let itemBottom = p.y + item.height
|
||||||
|
let top = flick.contentY
|
||||||
|
let bottom = flick.contentY + flick.height
|
||||||
|
if (itemTop < top) {
|
||||||
|
flick.contentY = Math.max(0, itemTop)
|
||||||
|
} else if (itemBottom > bottom) {
|
||||||
|
flick.contentY = Math.max(0, itemBottom - flick.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollBy(delta) {
|
||||||
|
if (!settingsScroll.contentItem) return
|
||||||
|
let maxY = Math.max(0, settingsScroll.contentItem.contentHeight - settingsScroll.contentItem.height)
|
||||||
|
settingsScroll.contentItem.contentY = Math.max(0, Math.min(maxY, settingsScroll.contentItem.contentY + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateUp() { if (settingsScroll.activeFocus) settingsScroll.scrollBy(-Kirigami.Units.gridUnit * 2) }
|
||||||
|
function onNavigateDown() { if (settingsScroll.activeFocus) settingsScroll.scrollBy(Kirigami.Units.gridUnit * 2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: settingsContent
|
||||||
|
width: settingsScroll.availableWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GameEditDialog {
|
||||||
|
id: gameEditDialog
|
||||||
|
parent: root.overlay
|
||||||
|
|
||||||
|
onClosed: libraryView.restoreFocus()
|
||||||
|
|
||||||
|
onGameCreated: function(game) {
|
||||||
|
showPassiveNotification(i18n("Added %1", game.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
onGameUpdated: function(game) {
|
||||||
|
showPassiveNotification(i18n("Updated %1", game.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: App
|
||||||
|
|
||||||
|
function onImportCompleted(count) {
|
||||||
|
showPassiveNotification(i18np("Imported %1 game", "Imported %1 games", count))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportError(error) {
|
||||||
|
showPassiveNotification(i18n("Import error: %1", error), "long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: App.launcher
|
||||||
|
|
||||||
|
function onGameStarted(game) {
|
||||||
|
showPassiveNotification(i18n("Launching %1...", game.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGameError(game, error) {
|
||||||
|
showPassiveNotification(i18n("Error launching %1: %2", game.name, error), "long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (App.gameModel.count === 0) {
|
||||||
|
importSheet.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
346
src/qml/SettingsPage.qml
Normal file
346
src/qml/SettingsPage.qml
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.kirigamiaddons.formcard as FormCard
|
||||||
|
import org.kde.alakarte
|
||||||
|
import "components"
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: settingsPage
|
||||||
|
spacing: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
function focusFirstControl() {
|
||||||
|
showPlatformBadgesDelegate.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
title: i18n("Appearance")
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
id: showPlatformBadgesDelegate
|
||||||
|
text: i18n("Show platform badges")
|
||||||
|
description: i18n("Display platform icons on game cards")
|
||||||
|
checked: App.config.showPlatformBadges
|
||||||
|
onToggled: App.config.showPlatformBadges = checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
title: i18n("Import Sources")
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Steam")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "com.valvesoftware.Steam"
|
||||||
|
secondary: "steam"
|
||||||
|
resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg")
|
||||||
|
}
|
||||||
|
checked: App.config.importSteam
|
||||||
|
onToggled: App.config.importSteam = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Lutris")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "lutris"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
checked: App.config.importLutris
|
||||||
|
onToggled: App.config.importLutris = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Heroic Games Launcher")
|
||||||
|
description: i18n("Epic, GOG, and Amazon games")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "com.heroicgameslauncher.hgl"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
checked: App.config.importHeroic
|
||||||
|
onToggled: App.config.importHeroic = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Desktop Entries")
|
||||||
|
description: i18n("Games from .desktop files")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "user-desktop"
|
||||||
|
secondary: "computer"
|
||||||
|
}
|
||||||
|
checked: App.config.importDesktop
|
||||||
|
onToggled: App.config.importDesktop = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Bottles")
|
||||||
|
description: i18n("Wine applications")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "com.usebottles.bottles"
|
||||||
|
secondary: "application-x-executable"
|
||||||
|
}
|
||||||
|
checked: App.config.importBottles
|
||||||
|
onToggled: App.config.importBottles = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Flatpak")
|
||||||
|
description: i18n("Flatpak game applications")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "flatpak-discover"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
checked: App.config.importFlatpak
|
||||||
|
onToggled: App.config.importFlatpak = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("itch.io")
|
||||||
|
description: i18n("Games from itch.io app")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "io.itch.itch"
|
||||||
|
secondary: "itch"
|
||||||
|
resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg")
|
||||||
|
}
|
||||||
|
checked: App.config.importItch
|
||||||
|
onToggled: App.config.importItch = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Legendary")
|
||||||
|
description: i18n("Epic Games via Legendary CLI")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "legendary"
|
||||||
|
secondary: "applications-games"
|
||||||
|
}
|
||||||
|
checked: App.config.importLegendary
|
||||||
|
onToggled: App.config.importLegendary = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("RetroArch")
|
||||||
|
description: i18n("Games from RetroArch playlists")
|
||||||
|
icon.name: ""
|
||||||
|
leading: IconWithResourceFallback {
|
||||||
|
primary: "org.libretro.RetroArch"
|
||||||
|
secondary: "retroarch"
|
||||||
|
resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg")
|
||||||
|
}
|
||||||
|
checked: App.config.importRetroArch
|
||||||
|
onToggled: App.config.importRetroArch = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Auto-import on startup")
|
||||||
|
description: i18n("Scan for new games when launching")
|
||||||
|
checked: App.config.autoImportOnStartup
|
||||||
|
onToggled: App.config.autoImportOnStartup = checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
title: i18n("Behavior")
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Exit after launching game")
|
||||||
|
description: i18n("Close A-La-Karte when a game starts")
|
||||||
|
checked: App.config.exitAfterLaunch
|
||||||
|
onToggled: App.config.exitAfterLaunch = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Click cover to launch")
|
||||||
|
description: i18n("Clicking the cover launches the game instead of showing details")
|
||||||
|
checked: App.config.coverLaunchesGame
|
||||||
|
onToggled: App.config.coverLaunchesGame = checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
title: i18n("Cover Art")
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("High quality images")
|
||||||
|
description: i18n("Sharper covers at the cost of memory")
|
||||||
|
checked: App.config.highQualityImages
|
||||||
|
onToggled: App.config.highQualityImages = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Animated covers")
|
||||||
|
description: i18n("Play animated cover art when available")
|
||||||
|
checked: App.config.animatedCovers
|
||||||
|
onToggled: App.config.animatedCovers = checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
title: i18n("SteamGridDB")
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Enable SteamGridDB")
|
||||||
|
description: i18n("Automatically download cover art")
|
||||||
|
checked: App.steamGridDB.enabled
|
||||||
|
onToggled: App.steamGridDB.enabled = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormTextFieldDelegate {
|
||||||
|
label: i18n("SteamGridDB API Key")
|
||||||
|
text: App.steamGridDB.apiKey
|
||||||
|
placeholderText: i18n("Enter your API key")
|
||||||
|
echoMode: TextInput.Password
|
||||||
|
enabled: App.steamGridDB.enabled
|
||||||
|
onTextChanged: App.steamGridDB.apiKey = text
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormCheckDelegate {
|
||||||
|
text: i18n("Prefer SteamGridDB covers")
|
||||||
|
description: i18n("Replace existing covers with SteamGridDB art")
|
||||||
|
checked: App.steamGridDB.preferSteamGridDB
|
||||||
|
enabled: App.steamGridDB.enabled
|
||||||
|
onToggled: App.steamGridDB.preferSteamGridDB = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Fetch All Covers")
|
||||||
|
description: App.steamGridDB.busy ? i18n("Fetching...") : i18n("Download covers for all games")
|
||||||
|
icon.name: "download"
|
||||||
|
enabled: App.steamGridDB.enabled && App.steamGridDB.apiKey.length > 0 && !App.steamGridDB.busy
|
||||||
|
onClicked: App.steamGridDB.fetchAllCovers()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormTextDelegate {
|
||||||
|
text: i18n("Get a free API key at steamgriddb.com")
|
||||||
|
textItem.font: Kirigami.Theme.smallFont
|
||||||
|
textItem.color: Kirigami.Theme.linkColor
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: Qt.openUrlExternally("https://www.steamgriddb.com/profile/preferences/api")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormHeader {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
title: i18n("Library")
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormCard {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Import All Games")
|
||||||
|
description: i18n("Scan all enabled sources")
|
||||||
|
icon.name: "document-import"
|
||||||
|
enabled: !App.importing
|
||||||
|
onClicked: App.importAllGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Remove Missing Games")
|
||||||
|
description: i18n("Remove games whose executables no longer exist")
|
||||||
|
icon.name: "edit-delete"
|
||||||
|
onClicked: App.removeMissingGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Clear Library")
|
||||||
|
description: i18n("Remove all games")
|
||||||
|
icon.name: "edit-clear-all"
|
||||||
|
onClicked: clearConfirmDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
FormCard.FormDelegateSeparator {}
|
||||||
|
|
||||||
|
FormCard.FormButtonDelegate {
|
||||||
|
text: i18n("Reset Application")
|
||||||
|
description: i18n("Clear library and reset all settings to defaults")
|
||||||
|
icon.name: "edit-reset"
|
||||||
|
onClicked: resetConfirmDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.PromptDialog {
|
||||||
|
id: clearConfirmDialog
|
||||||
|
title: i18n("Clear Library")
|
||||||
|
subtitle: i18n("Are you sure you want to remove all games?")
|
||||||
|
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
|
||||||
|
onAccepted: App.clearLibrary()
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.PromptDialog {
|
||||||
|
id: resetConfirmDialog
|
||||||
|
title: i18n("Reset Application")
|
||||||
|
subtitle: i18n("This will remove all games and reset all settings to defaults. Are you sure?")
|
||||||
|
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
|
||||||
|
onAccepted: {
|
||||||
|
App.clearLibrary()
|
||||||
|
App.config.resetToDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/qml/SidebarView.qml
Normal file
371
src/qml/SidebarView.qml
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.alakarte
|
||||||
|
import "components"
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: sidebarRoot
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
property string currentSource: "all"
|
||||||
|
property string currentSourceName: i18n("All Games")
|
||||||
|
|
||||||
|
readonly property int adaptiveFocusRingWidth: 1
|
||||||
|
|
||||||
|
signal sourceSelected(string source)
|
||||||
|
signal hiddenGamesRequested()
|
||||||
|
signal settingsRequested()
|
||||||
|
signal importRequested()
|
||||||
|
signal aboutRequested()
|
||||||
|
|
||||||
|
function focusList() {
|
||||||
|
sourceList.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateCurrentItem() {
|
||||||
|
if (sourceList.currentItem && sourceList.currentItem.clicked) {
|
||||||
|
sourceList.currentItem.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconInfoForPlatform(platformName) {
|
||||||
|
let p = (platformName || "").toLowerCase()
|
||||||
|
|
||||||
|
// Prefer icon-theme names to stay consistent with the user's Plasma theme.
|
||||||
|
if (p.includes("steam")) return { source: "com.valvesoftware.Steam", fallback: "steam", resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg") }
|
||||||
|
if (p.includes("itch")) return { source: "io.itch.itch", fallback: "itch", resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg") }
|
||||||
|
if (p.includes("retroarch")) return { source: "org.libretro.RetroArch", fallback: "retroarch", resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg") }
|
||||||
|
if (p.includes("lutris")) return { source: "lutris", fallback: "applications-games" }
|
||||||
|
if (p.includes("heroic")) return { source: "com.heroicgameslauncher.hgl", fallback: "heroic" }
|
||||||
|
if (p.includes("bottles")) return { source: "com.usebottles.bottles", fallback: "application-x-executable" }
|
||||||
|
if (p.includes("flatpak")) return { source: "flatpak-discover", fallback: "applications-games" }
|
||||||
|
if (p.includes("desktop")) return { source: "user-desktop", fallback: "computer" }
|
||||||
|
if (p.includes("legendary")) return { source: "legendary", fallback: "applications-games" }
|
||||||
|
if (p.includes("manual")) return { source: "applications-other", fallback: "applications-games" }
|
||||||
|
if (p.includes("other")) return { source: "applications-other", fallback: "applications-games" }
|
||||||
|
|
||||||
|
return { source: "applications-games", fallback: "applications-games" }
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ScrollView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: sourceList
|
||||||
|
model: sourceModel
|
||||||
|
currentIndex: 0
|
||||||
|
|
||||||
|
leftMargin: Kirigami.Units.largeSpacing
|
||||||
|
rightMargin: Kirigami.Units.largeSpacing
|
||||||
|
topMargin: Kirigami.Units.smallSpacing
|
||||||
|
bottomMargin: Kirigami.Units.smallSpacing
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
keyNavigationEnabled: true
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateUp() { if (sourceList.activeFocus) sourceList.decrementCurrentIndex() }
|
||||||
|
function onNavigateDown() { if (sourceList.activeFocus) sourceList.incrementCurrentIndex() }
|
||||||
|
function onSelectPressed() { if (sourceList.activeFocus) sidebarRoot.activateCurrentItem() }
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: QQC2.ItemDelegate {
|
||||||
|
id: sourceDelegate
|
||||||
|
width: ListView.view.width - ListView.view.leftMargin - ListView.view.rightMargin
|
||||||
|
highlighted: ListView.isCurrentItem
|
||||||
|
|
||||||
|
hoverEnabled: true
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: sourceDelegate.highlighted ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: sourceDelegate.highlighted ? 0.18 : (sourceDelegate.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: (sourceList.activeFocus && ListView.isCurrentItem) ? sidebarRoot.adaptiveFocusRingWidth : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leftPadding: Kirigami.Units.mediumSpacing
|
||||||
|
rightPadding: Kirigami.Units.mediumSpacing
|
||||||
|
topPadding: Kirigami.Units.smallSpacing
|
||||||
|
bottomPadding: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
IconWithResourceFallback {
|
||||||
|
primary: model.icon
|
||||||
|
secondary: model.fallbackIcon ? model.fallbackIcon : "applications-games"
|
||||||
|
resourceFallback: model.resourceFallback ? model.resourceFallback : ""
|
||||||
|
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: model.name
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: model.count
|
||||||
|
visible: model.count > 0
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
sourceList.currentIndex = index
|
||||||
|
sidebarRoot.currentSource = model.sourceId
|
||||||
|
sidebarRoot.currentSourceName = model.name
|
||||||
|
sidebarRoot.sourceSelected(model.sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: clicked()
|
||||||
|
Keys.onEnterPressed: clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
section.property: "section"
|
||||||
|
section.delegate: Kirigami.ListSectionHeader {
|
||||||
|
width: ListView.view.width - ListView.view.leftMargin - ListView.view.rightMargin
|
||||||
|
text: section
|
||||||
|
leftPadding: Kirigami.Units.mediumSpacing
|
||||||
|
rightPadding: Kirigami.Units.mediumSpacing
|
||||||
|
topPadding: Kirigami.Units.smallSpacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.Separator {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: Kirigami.Units.largeSpacing
|
||||||
|
Layout.rightMargin: Kirigami.Units.largeSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ItemDelegate {
|
||||||
|
id: importAction
|
||||||
|
Layout.fillWidth: true
|
||||||
|
leftPadding: Kirigami.Units.largeSpacing
|
||||||
|
rightPadding: Kirigami.Units.largeSpacing
|
||||||
|
hoverEnabled: true
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: parent.activeFocus ? sidebarRoot.adaptiveFocusRingWidth : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icon.name: "document-import"
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
text: i18n("Import Games")
|
||||||
|
onClicked: sidebarRoot.importRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ItemDelegate {
|
||||||
|
id: settingsAction
|
||||||
|
Layout.fillWidth: true
|
||||||
|
leftPadding: Kirigami.Units.largeSpacing
|
||||||
|
rightPadding: Kirigami.Units.largeSpacing
|
||||||
|
hoverEnabled: true
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: parent.activeFocus ? sidebarRoot.adaptiveFocusRingWidth : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icon.name: "configure"
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
text: i18n("Settings")
|
||||||
|
onClicked: sidebarRoot.settingsRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ItemDelegate {
|
||||||
|
id: aboutAction
|
||||||
|
Layout.fillWidth: true
|
||||||
|
leftPadding: Kirigami.Units.largeSpacing
|
||||||
|
rightPadding: Kirigami.Units.largeSpacing
|
||||||
|
hoverEnabled: true
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Kirigami.Units.smallSpacing
|
||||||
|
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
|
||||||
|
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
|
||||||
|
border.width: parent.activeFocus ? sidebarRoot.adaptiveFocusRingWidth : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on border.width {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icon.name: "help-about"
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
text: i18n("About")
|
||||||
|
onClicked: sidebarRoot.aboutRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateUp() {
|
||||||
|
if (aboutAction.activeFocus) {
|
||||||
|
settingsAction.forceActiveFocus()
|
||||||
|
} else if (settingsAction.activeFocus) {
|
||||||
|
importAction.forceActiveFocus()
|
||||||
|
} else if (importAction.activeFocus) {
|
||||||
|
sourceList.forceActiveFocus()
|
||||||
|
sourceList.currentIndex = Math.max(0, sourceList.count - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onNavigateDown() {
|
||||||
|
if (importAction.activeFocus) {
|
||||||
|
settingsAction.forceActiveFocus()
|
||||||
|
} else if (settingsAction.activeFocus) {
|
||||||
|
aboutAction.forceActiveFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onSelectPressed() {
|
||||||
|
if (importAction.activeFocus) {
|
||||||
|
importAction.clicked()
|
||||||
|
} else if (settingsAction.activeFocus) {
|
||||||
|
settingsAction.clicked()
|
||||||
|
} else if (aboutAction.activeFocus) {
|
||||||
|
aboutAction.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListModel {
|
||||||
|
id: sourceModel
|
||||||
|
|
||||||
|
Component.onCompleted: refresh()
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
clear()
|
||||||
|
|
||||||
|
let allCount = 0
|
||||||
|
let hiddenCount = 0
|
||||||
|
let favoritesCount = 0
|
||||||
|
let sources = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < App.gameModel.rowCount(); i++) {
|
||||||
|
let game = App.gameModel.gameAt(i)
|
||||||
|
if (game) {
|
||||||
|
if (game.hidden) {
|
||||||
|
hiddenCount++
|
||||||
|
} else {
|
||||||
|
allCount++
|
||||||
|
if (game.favorite) {
|
||||||
|
favoritesCount++
|
||||||
|
}
|
||||||
|
let platform = game.platform
|
||||||
|
if (!sources[platform]) {
|
||||||
|
sources[platform] = 0
|
||||||
|
}
|
||||||
|
sources[platform]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
append({
|
||||||
|
name: i18n("All Games"),
|
||||||
|
sourceId: "all",
|
||||||
|
icon: "view-list-icons",
|
||||||
|
count: allCount,
|
||||||
|
section: i18n("Library")
|
||||||
|
})
|
||||||
|
|
||||||
|
append({
|
||||||
|
name: i18n("Favorites"),
|
||||||
|
sourceId: "favorites",
|
||||||
|
icon: "bookmark-new",
|
||||||
|
count: favoritesCount,
|
||||||
|
section: i18n("Library")
|
||||||
|
})
|
||||||
|
|
||||||
|
append({
|
||||||
|
name: i18n("Hidden"),
|
||||||
|
sourceId: "hidden",
|
||||||
|
icon: "view-hidden",
|
||||||
|
count: hiddenCount,
|
||||||
|
section: i18n("Library")
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let platform in sources) {
|
||||||
|
let iconInfo = sidebarRoot.iconInfoForPlatform(platform)
|
||||||
|
|
||||||
|
append({
|
||||||
|
name: platform,
|
||||||
|
sourceId: platform,
|
||||||
|
icon: iconInfo.source,
|
||||||
|
fallbackIcon: iconInfo.fallback,
|
||||||
|
resourceFallback: iconInfo.resourceFallback ? iconInfo.resourceFallback : "",
|
||||||
|
count: sources[platform],
|
||||||
|
section: i18n("Sources")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: App.gameModel
|
||||||
|
function onCountChanged() {
|
||||||
|
sourceModel.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: App
|
||||||
|
function onImportCompleted() {
|
||||||
|
sourceModel.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/qml/components/EmptyState.qml
Normal file
54
src/qml/components/EmptyState.qml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: emptyState
|
||||||
|
|
||||||
|
property string icon: "applications-games"
|
||||||
|
property string title: ""
|
||||||
|
property string description: ""
|
||||||
|
property string actionText: ""
|
||||||
|
|
||||||
|
signal actionTriggered()
|
||||||
|
|
||||||
|
spacing: Kirigami.Units.largeSpacing
|
||||||
|
width: parent ? Math.min(parent.width - Kirigami.Units.gridUnit * 4, Kirigami.Units.gridUnit * 20) : Kirigami.Units.gridUnit * 20
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
source: emptyState.icon
|
||||||
|
Layout.preferredWidth: Kirigami.Units.iconSizes.enormous
|
||||||
|
Layout.preferredHeight: Kirigami.Units.iconSizes.enormous
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.Heading {
|
||||||
|
text: emptyState.title
|
||||||
|
level: 2
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Label {
|
||||||
|
text: emptyState.description
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
color: Kirigami.Theme.disabledTextColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: text.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.Button {
|
||||||
|
text: emptyState.actionText
|
||||||
|
icon.name: "document-import"
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: emptyState.actionText.length > 0
|
||||||
|
onClicked: emptyState.actionTriggered()
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/qml/components/FocusableCard.qml
Normal file
53
src/qml/components/FocusableCard.qml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
|
||||||
|
FocusScope {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias contentItem: container.contentItem
|
||||||
|
property bool highlighted: activeFocus
|
||||||
|
|
||||||
|
signal activated()
|
||||||
|
|
||||||
|
Kirigami.ShadowedRectangle {
|
||||||
|
id: container
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
radius: Kirigami.Units.mediumSpacing
|
||||||
|
color: Kirigami.Theme.backgroundColor
|
||||||
|
|
||||||
|
border.width: root.highlighted ? 2 : 0
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
|
||||||
|
shadow {
|
||||||
|
size: root.highlighted ? Kirigami.Units.largeSpacing : Kirigami.Units.smallSpacing
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
scale: root.highlighted ? 1.02 : 1.0
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on shadow.size {
|
||||||
|
NumberAnimation { duration: Kirigami.Units.shortDuration }
|
||||||
|
}
|
||||||
|
|
||||||
|
property Item contentItem
|
||||||
|
|
||||||
|
data: [contentItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: root.activated()
|
||||||
|
Keys.onEnterPressed: root.activated()
|
||||||
|
Keys.onSpacePressed: root.activated()
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: ""
|
||||||
|
Accessible.onPressAction: root.activated()
|
||||||
|
}
|
||||||
73
src/qml/components/GameGridView.qml
Normal file
73
src/qml/components/GameGridView.qml
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.alakarte
|
||||||
|
|
||||||
|
GridView {
|
||||||
|
id: gridView
|
||||||
|
|
||||||
|
property int cardSize: App.config.gridSize
|
||||||
|
|
||||||
|
readonly property int cellPadding: cardSize < 180 ? Kirigami.Units.mediumSpacing : Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
cellWidth: cardSize + cellPadding
|
||||||
|
cellHeight: Math.round(cardSize * 1.4) + cellPadding
|
||||||
|
|
||||||
|
clip: true
|
||||||
|
focus: true
|
||||||
|
keyNavigationEnabled: true
|
||||||
|
keyNavigationWraps: false
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
highlightMoveDuration: Kirigami.Units.shortDuration
|
||||||
|
highlightFollowsCurrentItem: true
|
||||||
|
|
||||||
|
Keys.onUpPressed: navigateUp()
|
||||||
|
Keys.onDownPressed: navigateDown()
|
||||||
|
Keys.onLeftPressed: navigateLeft()
|
||||||
|
Keys.onRightPressed: navigateRight()
|
||||||
|
|
||||||
|
function navigateUp() {
|
||||||
|
if (currentIndex >= columns) {
|
||||||
|
currentIndex -= columns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateDown() {
|
||||||
|
if (currentIndex + columns < count) {
|
||||||
|
currentIndex += columns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateLeft() {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
currentIndex--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateRight() {
|
||||||
|
if (currentIndex < count - 1) {
|
||||||
|
currentIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: GamepadManager
|
||||||
|
function onNavigateUp() { if (gridView.activeFocus) gridView.navigateUp() }
|
||||||
|
function onNavigateDown() { if (gridView.activeFocus) gridView.navigateDown() }
|
||||||
|
function onNavigateLeft() { if (gridView.activeFocus) gridView.navigateLeft() }
|
||||||
|
function onNavigateRight() { if (gridView.activeFocus) gridView.navigateRight() }
|
||||||
|
function onSelectPressed() {
|
||||||
|
if (gridView.activeFocus && gridView.currentItem) {
|
||||||
|
gridView.currentItem.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property int columns: Math.max(1, Math.floor(width / cellWidth))
|
||||||
|
|
||||||
|
QQC2.ScrollBar.vertical: QQC2.ScrollBar {}
|
||||||
|
}
|
||||||
32
src/qml/components/IconWithResourceFallback.qml
Normal file
32
src/qml/components/IconWithResourceFallback.qml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import QtQuick
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.kirigami.primitives as KirigamiPrimitives
|
||||||
|
|
||||||
|
Kirigami.Icon {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string primary: ""
|
||||||
|
property string secondary: ""
|
||||||
|
property url resourceFallback
|
||||||
|
|
||||||
|
property bool mask: true
|
||||||
|
|
||||||
|
property bool forceResourceFallback: false
|
||||||
|
|
||||||
|
isMask: mask
|
||||||
|
source: forceResourceFallback ? resourceFallback : primary
|
||||||
|
fallback: secondary
|
||||||
|
|
||||||
|
implicitWidth: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
implicitHeight: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
|
||||||
|
onPrimaryChanged: forceResourceFallback = false
|
||||||
|
onSecondaryChanged: forceResourceFallback = false
|
||||||
|
onResourceFallbackChanged: forceResourceFallback = false
|
||||||
|
|
||||||
|
onStatusChanged: {
|
||||||
|
if (!forceResourceFallback && status === KirigamiPrimitives.Icon.Error && resourceFallback && resourceFallback.toString().length > 0) {
|
||||||
|
forceResourceFallback = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/qml/components/SearchHeader.qml
Normal file
83
src/qml/components/SearchHeader.qml
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import org.kde.alakarte
|
||||||
|
|
||||||
|
QQC2.ToolBar {
|
||||||
|
id: searchHeader
|
||||||
|
|
||||||
|
property alias searchField: searchFieldContainer.data
|
||||||
|
property int currentSortMode: 0
|
||||||
|
|
||||||
|
signal searchChanged(string text)
|
||||||
|
signal sortChanged(int mode)
|
||||||
|
|
||||||
|
leftPadding: 0
|
||||||
|
rightPadding: 0
|
||||||
|
topPadding: Kirigami.Units.smallSpacing
|
||||||
|
bottomPadding: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: Kirigami.Units.mediumSpacing
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: searchFieldContainer
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: children.length > 0 ? children[0].implicitHeight : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.ToolButton {
|
||||||
|
icon.name: "view-sort"
|
||||||
|
text: i18n("Sort")
|
||||||
|
display: QQC2.AbstractButton.IconOnly
|
||||||
|
|
||||||
|
hoverEnabled: true
|
||||||
|
icon.width: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
icon.height: Kirigami.Units.iconSizes.smallMedium
|
||||||
|
|
||||||
|
QQC2.ToolTip.text: text
|
||||||
|
QQC2.ToolTip.visible: hovered
|
||||||
|
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||||
|
|
||||||
|
onClicked: sortMenu.open()
|
||||||
|
|
||||||
|
QQC2.Menu {
|
||||||
|
id: sortMenu
|
||||||
|
|
||||||
|
QQC2.MenuItem {
|
||||||
|
text: i18n("Last Played")
|
||||||
|
checkable: true
|
||||||
|
checked: searchHeader.currentSortMode === 0
|
||||||
|
onTriggered: {
|
||||||
|
searchHeader.currentSortMode = 0
|
||||||
|
searchHeader.sortChanged(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.MenuItem {
|
||||||
|
text: i18n("Name")
|
||||||
|
checkable: true
|
||||||
|
checked: searchHeader.currentSortMode === 1
|
||||||
|
onTriggered: {
|
||||||
|
searchHeader.currentSortMode = 1
|
||||||
|
searchHeader.sortChanged(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.MenuItem {
|
||||||
|
text: i18n("Play Time")
|
||||||
|
checkable: true
|
||||||
|
checked: searchHeader.currentSortMode === 2
|
||||||
|
onTriggered: {
|
||||||
|
searchHeader.currentSortMode = 2
|
||||||
|
searchHeader.sortChanged(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/qml/icons/app/org.kde.alakarte.svg
Normal file
30
src/qml/icons/app/org.kde.alakarte.svg
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_d_1_26)">
|
||||||
|
<rect x="106" y="61" width="300" height="384" fill="#2AA0A8"/>
|
||||||
|
</g>
|
||||||
|
<rect x="133" y="148" width="245" height="29" rx="6" fill="#D9D9D9"/>
|
||||||
|
<rect x="133" y="237" width="94" height="39" rx="6" fill="#D9D9D9"/>
|
||||||
|
<rect x="133" y="87" width="245" height="39" rx="6" fill="#D9D9D9"/>
|
||||||
|
<rect x="133" y="298" width="245" height="32" rx="6" fill="#D9D9D9"/>
|
||||||
|
<path d="M310 358H202C193.163 358 186 365.163 186 374V402C186 410.837 193.163 418 202 418H310C318.837 418 326 410.837 326 402V374C326 365.163 318.837 358 310 358Z" stroke="#D9D9D9" stroke-width="4"/>
|
||||||
|
<path d="M216 372V404ZM200 388H232Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M216 372V404M200 388H232" stroke="#D9D9D9" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M286 383C288.761 383 291 380.761 291 378C291 375.239 288.761 373 286 373C283.239 373 281 375.239 281 378C281 380.761 283.239 383 286 383Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M306 383C308.761 383 311 380.761 311 378C311 375.239 308.761 373 306 373C303.239 373 301 375.239 301 378C301 380.761 303.239 383 306 383Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M296 403C298.761 403 301 400.761 301 398C301 395.239 298.761 393 296 393C293.239 393 291 395.239 291 398C291 400.761 293.239 403 296 403Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M253 384H249C248.448 384 248 384.448 248 385V386C248 386.552 248.448 387 249 387H253C253.552 387 254 386.552 254 386V385C254 384.448 253.552 384 253 384Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M263 384H259C258.448 384 258 384.448 258 385V386C258 386.552 258.448 387 259 387H263C263.552 387 264 386.552 264 386V385C264 384.448 263.552 384 263 384Z" fill="#D9D9D9"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_d_1_26" x="98" y="57" width="316" height="400" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feMorphology radius="4" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_1_26"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="2"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_26"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_26" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
1
src/qml/icons/brand/itchdotio-symbolic.svg
Normal file
1
src/qml/icons/brand/itchdotio-symbolic.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Itch.io</title><path d="M3.13 1.338C2.08 1.96.02 4.328 0 4.95v1.03c0 1.303 1.22 2.45 2.325 2.45 1.33 0 2.436-1.102 2.436-2.41 0 1.308 1.07 2.41 2.4 2.41 1.328 0 2.362-1.102 2.362-2.41 0 1.308 1.137 2.41 2.466 2.41h.024c1.33 0 2.466-1.102 2.466-2.41 0 1.308 1.034 2.41 2.363 2.41 1.33 0 2.4-1.102 2.4-2.41 0 1.308 1.106 2.41 2.435 2.41C22.78 8.43 24 7.282 24 5.98V4.95c-.02-.62-2.082-2.99-3.13-3.612-3.253-.114-5.508-.134-8.87-.133-3.362 0-7.945.053-8.87.133zm6.376 6.477a2.74 2.74 0 0 1-.468.602c-.5.49-1.19.795-1.947.795a2.786 2.786 0 0 1-1.95-.795c-.182-.178-.32-.37-.446-.59-.127.222-.303.412-.486.59a2.788 2.788 0 0 1-1.95.795c-.092 0-.187-.025-.264-.052-.107 1.113-.152 2.176-.168 2.95v.005l-.006 1.167c.02 2.334-.23 7.564 1.03 8.85 1.952.454 5.545.662 9.15.663 3.605 0 7.198-.21 9.15-.664 1.26-1.284 1.01-6.514 1.03-8.848l-.006-1.167v-.004c-.016-.775-.06-1.838-.168-2.95-.077.026-.172.052-.263.052a2.788 2.788 0 0 1-1.95-.795c-.184-.178-.36-.368-.486-.59-.127.22-.265.412-.447.59a2.786 2.786 0 0 1-1.95.794c-.76 0-1.446-.303-1.948-.793a2.74 2.74 0 0 1-.468-.602 2.738 2.738 0 0 1-.463.602 2.787 2.787 0 0 1-1.95.794h-.16a2.787 2.787 0 0 1-1.95-.793 2.738 2.738 0 0 1-.464-.602zm-2.004 2.59v.002c.795.002 1.5 0 2.373.953.687-.072 1.406-.108 2.125-.107.72 0 1.438.035 2.125.107.873-.953 1.578-.95 2.372-.953.376 0 1.876 0 2.92 2.934l1.123 4.028c.832 2.995-.266 3.068-1.636 3.07-2.03-.075-3.156-1.55-3.156-3.025-1.124.184-2.436.276-3.748.277-1.312 0-2.624-.093-3.748-.277 0 1.475-1.125 2.95-3.156 3.026-1.37-.004-2.468-.077-1.636-3.072l1.122-4.027c1.045-2.934 2.545-2.934 2.92-2.934zM12 12.714c-.002.002-2.14 1.964-2.523 2.662l1.4-.056v1.22c0 .056.56.033 1.123.007.562.026 1.124.05 1.124-.008v-1.22l1.4.055C14.138 14.677 12 12.713 12 12.713z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
1
src/qml/icons/brand/retroarch-symbolic.svg
Normal file
1
src/qml/icons/brand/retroarch-symbolic.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>RetroArch</title><path d="M6.84 5.76L8.4 7.68H5.28l-.72 2.88H2.64l.72-2.88H1.44L0 13.44h3.84l-.48 1.92h3.36L4.2 18.24h2.82l2.34-2.88h5.28l2.34 2.88h2.82l-2.52-2.88h3.36l-.48-1.92H24l-1.44-5.76h-1.92l.72 2.88h-1.92l-.72-2.88H15.6l1.56-1.92h-2.04l-1.68 1.92h-2.88L8.88 5.76zm.24 3.84H9v1.92H7.08zm7.925 0h1.92v1.92h-1.92Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 407 B |
1
src/qml/icons/brand/steam-symbolic.svg
Normal file
1
src/qml/icons/brand/steam-symbolic.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Steam</title><path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
139
src/retroarchimporter.cpp
Normal file
139
src/retroarchimporter.cpp
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "retroarchimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
RetroArchImporter::RetroArchImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString RetroArchImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("RetroArch");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString RetroArchImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("retroarch");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString RetroArchImporter::getRetroArchConfigPath() const
|
||||||
|
{
|
||||||
|
QStringList paths = {
|
||||||
|
expandPath(QStringLiteral("~/.config/retroarch")),
|
||||||
|
expandPath(QStringLiteral("~/.var/app/org.libretro.RetroArch/config/retroarch")),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &path : paths) {
|
||||||
|
if (directoryExists(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString RetroArchImporter::getPlaylistsPath() const
|
||||||
|
{
|
||||||
|
QString configPath = getRetroArchConfigPath();
|
||||||
|
if (configPath.isEmpty()) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath + QStringLiteral("/playlists");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RetroArchImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
QString playlistsPath = getPlaylistsPath();
|
||||||
|
return !playlistsPath.isEmpty() && directoryExists(playlistsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> RetroArchImporter::parsePlaylist(const QString &playlistPath) const
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
|
||||||
|
QFile file(playlistPath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
QJsonArray items = root[QStringLiteral("items")].toArray();
|
||||||
|
QString defaultCorePath = root[QStringLiteral("default_core_path")].toString();
|
||||||
|
|
||||||
|
QFileInfo playlistInfo(playlistPath);
|
||||||
|
QString playlistName = playlistInfo.baseName();
|
||||||
|
|
||||||
|
for (const QJsonValue &item : items) {
|
||||||
|
QJsonObject gameObj = item.toObject();
|
||||||
|
|
||||||
|
QString label = gameObj[QStringLiteral("label")].toString();
|
||||||
|
QString path = gameObj[QStringLiteral("path")].toString();
|
||||||
|
QString corePath = gameObj[QStringLiteral("core_path")].toString();
|
||||||
|
|
||||||
|
if (label.isEmpty() || path.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default core if not specified
|
||||||
|
if (corePath.isEmpty() || corePath == QStringLiteral("DETECT")) {
|
||||||
|
corePath = defaultCorePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if still no core
|
||||||
|
if (corePath.isEmpty() || corePath == QStringLiteral("DETECT")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique ID from path hash
|
||||||
|
QString gameId = QStringLiteral("retroarch_%1").arg(QString::number(qHash(path), 16));
|
||||||
|
|
||||||
|
Game *game = new Game(gameId, label);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(platformId());
|
||||||
|
game->setDeveloper(playlistName);
|
||||||
|
|
||||||
|
// Build RetroArch launch command
|
||||||
|
QString launchCmd = QStringLiteral("retroarch -L \"%1\" \"%2\"").arg(corePath, path);
|
||||||
|
game->setLaunchCommand(launchCmd);
|
||||||
|
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> RetroArchImporter::importGames()
|
||||||
|
{
|
||||||
|
QList<Game *> allGames;
|
||||||
|
|
||||||
|
QString playlistsPath = getPlaylistsPath();
|
||||||
|
if (playlistsPath.isEmpty()) {
|
||||||
|
return allGames;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir playlistsDir(playlistsPath);
|
||||||
|
QStringList playlists = playlistsDir.entryList(QStringList() << QStringLiteral("*.lpl"), QDir::Files);
|
||||||
|
|
||||||
|
for (const QString &playlist : playlists) {
|
||||||
|
QString fullPath = playlistsDir.absoluteFilePath(playlist);
|
||||||
|
QList<Game *> games = parsePlaylist(fullPath);
|
||||||
|
allGames.append(games);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allGames;
|
||||||
|
}
|
||||||
24
src/retroarchimporter.h
Normal file
24
src/retroarchimporter.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class RetroArchImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit RetroArchImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString getRetroArchConfigPath() const;
|
||||||
|
QString getPlaylistsPath() const;
|
||||||
|
QList<Game *> parsePlaylist(const QString &playlistPath) const;
|
||||||
|
};
|
||||||
342
src/steamgriddb.cpp
Normal file
342
src/steamgriddb.cpp
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "steamgriddb.h"
|
||||||
|
#include "app.h"
|
||||||
|
#include "game.h"
|
||||||
|
|
||||||
|
#include <KConfigGroup>
|
||||||
|
#include <KSharedConfig>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
static const QString STEAMGRIDDB_API_BASE = QStringLiteral("https://www.steamgriddb.com/api/v2");
|
||||||
|
|
||||||
|
SteamGridDB::SteamGridDB(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_networkManager(new QNetworkAccessManager(this))
|
||||||
|
{
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SteamGridDB::apiKey() const
|
||||||
|
{
|
||||||
|
return m_apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::setApiKey(const QString &key)
|
||||||
|
{
|
||||||
|
if (m_apiKey != key) {
|
||||||
|
m_apiKey = key;
|
||||||
|
saveSettings();
|
||||||
|
Q_EMIT apiKeyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SteamGridDB::enabled() const
|
||||||
|
{
|
||||||
|
return m_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::setEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_enabled != enabled) {
|
||||||
|
m_enabled = enabled;
|
||||||
|
saveSettings();
|
||||||
|
Q_EMIT enabledChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SteamGridDB::preferSteamGridDB() const
|
||||||
|
{
|
||||||
|
return m_preferSteamGridDB;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::setPreferSteamGridDB(bool prefer)
|
||||||
|
{
|
||||||
|
if (m_preferSteamGridDB != prefer) {
|
||||||
|
m_preferSteamGridDB = prefer;
|
||||||
|
saveSettings();
|
||||||
|
Q_EMIT preferSteamGridDBChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SteamGridDB::busy() const
|
||||||
|
{
|
||||||
|
return m_busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::fetchCover(Game *game)
|
||||||
|
{
|
||||||
|
if (!game || m_apiKey.isEmpty() || !m_enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if game already has a cover and we don't prefer SteamGridDB
|
||||||
|
if (game->coverUrl().isValid() && !m_preferSteamGridDB) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_pendingGames.append(game);
|
||||||
|
|
||||||
|
if (!m_busy) {
|
||||||
|
m_totalGames = m_pendingGames.count();
|
||||||
|
m_processedGames = 0;
|
||||||
|
processNextGame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::fetchAllCovers()
|
||||||
|
{
|
||||||
|
if (m_apiKey.isEmpty() || !m_enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameModel *gameModel = App::instance()->gameModel();
|
||||||
|
for (int i = 0; i < gameModel->rowCount(); ++i) {
|
||||||
|
Game *game = gameModel->gameAt(i);
|
||||||
|
if (game && (!game->coverUrl().isValid() || m_preferSteamGridDB)) {
|
||||||
|
m_pendingGames.append(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_pendingGames.isEmpty() && !m_busy) {
|
||||||
|
m_totalGames = m_pendingGames.count();
|
||||||
|
m_processedGames = 0;
|
||||||
|
processNextGame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::processNextGame()
|
||||||
|
{
|
||||||
|
if (m_pendingGames.isEmpty()) {
|
||||||
|
m_busy = false;
|
||||||
|
m_currentGame = nullptr;
|
||||||
|
Q_EMIT busyChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_busy = true;
|
||||||
|
Q_EMIT busyChanged();
|
||||||
|
|
||||||
|
m_currentGame = m_pendingGames.takeFirst();
|
||||||
|
searchGame(m_currentGame);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::searchGame(Game *game)
|
||||||
|
{
|
||||||
|
QString encodedName = QString::fromUtf8(QUrl::toPercentEncoding(game->name()));
|
||||||
|
QUrl url(STEAMGRIDDB_API_BASE + QStringLiteral("/search/autocomplete/") + encodedName);
|
||||||
|
|
||||||
|
QNetworkRequest request(url);
|
||||||
|
request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(m_apiKey).toUtf8());
|
||||||
|
|
||||||
|
QNetworkReply *reply = m_networkManager->get(request);
|
||||||
|
reply->setProperty("game", QVariant::fromValue(game));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, &SteamGridDB::onSearchFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::onSearchFinished()
|
||||||
|
{
|
||||||
|
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
|
||||||
|
if (!reply) {
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply->deleteLater();
|
||||||
|
|
||||||
|
Game *game = reply->property("game").value<Game *>();
|
||||||
|
if (!game) {
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
Q_EMIT fetchError(game, reply->errorString());
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
|
||||||
|
if (!root[QStringLiteral("success")].toBool()) {
|
||||||
|
Q_EMIT fetchError(game, QStringLiteral("Search failed"));
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray data = root[QStringLiteral("data")].toArray();
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
Q_EMIT fetchError(game, QStringLiteral("No results found"));
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first result
|
||||||
|
QJsonObject firstResult = data.first().toObject();
|
||||||
|
int gameId = firstResult[QStringLiteral("id")].toInt();
|
||||||
|
|
||||||
|
fetchGrids(game, gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::fetchGrids(Game *game, int gameId)
|
||||||
|
{
|
||||||
|
// Request vertical grids (600x900) for game covers
|
||||||
|
QUrl url(STEAMGRIDDB_API_BASE + QStringLiteral("/grids/game/%1").arg(gameId));
|
||||||
|
QUrlQuery query;
|
||||||
|
query.addQueryItem(QStringLiteral("dimensions"), QStringLiteral("600x900"));
|
||||||
|
query.addQueryItem(QStringLiteral("types"), QStringLiteral("static"));
|
||||||
|
url.setQuery(query);
|
||||||
|
|
||||||
|
QNetworkRequest request(url);
|
||||||
|
request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(m_apiKey).toUtf8());
|
||||||
|
|
||||||
|
QNetworkReply *reply = m_networkManager->get(request);
|
||||||
|
reply->setProperty("game", QVariant::fromValue(game));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, &SteamGridDB::onGridsFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::onGridsFinished()
|
||||||
|
{
|
||||||
|
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
|
||||||
|
if (!reply) {
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply->deleteLater();
|
||||||
|
|
||||||
|
Game *game = reply->property("game").value<Game *>();
|
||||||
|
if (!game) {
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
Q_EMIT fetchError(game, reply->errorString());
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
|
||||||
|
if (!root[QStringLiteral("success")].toBool()) {
|
||||||
|
Q_EMIT fetchError(game, QStringLiteral("Failed to fetch grids"));
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray data = root[QStringLiteral("data")].toArray();
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
Q_EMIT fetchError(game, QStringLiteral("No covers available"));
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first (highest rated) result
|
||||||
|
QJsonObject firstGrid = data.first().toObject();
|
||||||
|
QString imageUrl = firstGrid[QStringLiteral("url")].toString();
|
||||||
|
|
||||||
|
downloadImage(game, QUrl(imageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::downloadImage(Game *game, const QUrl &imageUrl)
|
||||||
|
{
|
||||||
|
QNetworkRequest request(imageUrl);
|
||||||
|
QNetworkReply *reply = m_networkManager->get(request);
|
||||||
|
reply->setProperty("game", QVariant::fromValue(game));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, &SteamGridDB::onImageDownloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::onImageDownloaded()
|
||||||
|
{
|
||||||
|
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
|
||||||
|
if (!reply) {
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply->deleteLater();
|
||||||
|
|
||||||
|
Game *game = reply->property("game").value<Game *>();
|
||||||
|
if (!game) {
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
Q_EMIT fetchError(game, reply->errorString());
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save image to covers directory
|
||||||
|
QString coversPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/covers");
|
||||||
|
QDir dir(coversPath);
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkpath(coversPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString fileName = game->id() + QStringLiteral(".jpg");
|
||||||
|
QString filePath = coversPath + QStringLiteral("/") + fileName;
|
||||||
|
|
||||||
|
QFile file(filePath);
|
||||||
|
if (file.open(QIODevice::WriteOnly)) {
|
||||||
|
file.write(reply->readAll());
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
QUrl localUrl = QUrl::fromLocalFile(filePath);
|
||||||
|
game->setCoverUrl(localUrl);
|
||||||
|
App::instance()->saveLibrary();
|
||||||
|
|
||||||
|
Q_EMIT coverFetched(game, localUrl);
|
||||||
|
} else {
|
||||||
|
Q_EMIT fetchError(game, QStringLiteral("Failed to save cover image"));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_processedGames++;
|
||||||
|
Q_EMIT fetchProgress(m_processedGames, m_totalGames);
|
||||||
|
processNextGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::saveSettings()
|
||||||
|
{
|
||||||
|
const auto config = KSharedConfig::openConfig(QStringLiteral("alakarterc"));
|
||||||
|
KConfigGroup group(config, QStringLiteral("SteamGridDB"));
|
||||||
|
group.writeEntry(QStringLiteral("apiKey"), m_apiKey);
|
||||||
|
group.writeEntry(QStringLiteral("enabled"), m_enabled);
|
||||||
|
group.writeEntry(QStringLiteral("preferSteamGridDB"), m_preferSteamGridDB);
|
||||||
|
config->sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SteamGridDB::loadSettings()
|
||||||
|
{
|
||||||
|
const auto config = KSharedConfig::openConfig(QStringLiteral("alakarterc"));
|
||||||
|
const KConfigGroup group(config, QStringLiteral("SteamGridDB"));
|
||||||
|
m_apiKey = group.readEntry(QStringLiteral("apiKey"), QString());
|
||||||
|
m_enabled = group.readEntry(QStringLiteral("enabled"), false);
|
||||||
|
m_preferSteamGridDB = group.readEntry(QStringLiteral("preferSteamGridDB"), false);
|
||||||
|
}
|
||||||
69
src/steamgriddb.h
Normal file
69
src/steamgriddb.h
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
class Game;
|
||||||
|
|
||||||
|
class SteamGridDB : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString apiKey READ apiKey WRITE setApiKey NOTIFY apiKeyChanged)
|
||||||
|
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged)
|
||||||
|
Q_PROPERTY(bool preferSteamGridDB READ preferSteamGridDB WRITE setPreferSteamGridDB NOTIFY preferSteamGridDBChanged)
|
||||||
|
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SteamGridDB(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString apiKey() const;
|
||||||
|
void setApiKey(const QString &key);
|
||||||
|
|
||||||
|
bool enabled() const;
|
||||||
|
void setEnabled(bool enabled);
|
||||||
|
|
||||||
|
bool preferSteamGridDB() const;
|
||||||
|
void setPreferSteamGridDB(bool prefer);
|
||||||
|
|
||||||
|
bool busy() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE void fetchCover(Game *game);
|
||||||
|
Q_INVOKABLE void fetchAllCovers();
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void apiKeyChanged();
|
||||||
|
void enabledChanged();
|
||||||
|
void preferSteamGridDBChanged();
|
||||||
|
void busyChanged();
|
||||||
|
void coverFetched(Game *game, const QUrl &coverUrl);
|
||||||
|
void fetchError(Game *game, const QString &error);
|
||||||
|
void fetchProgress(int current, int total);
|
||||||
|
|
||||||
|
private Q_SLOTS:
|
||||||
|
void onSearchFinished();
|
||||||
|
void onGridsFinished();
|
||||||
|
void onImageDownloaded();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void searchGame(Game *game);
|
||||||
|
void fetchGrids(Game *game, int gameId);
|
||||||
|
void downloadImage(Game *game, const QUrl &imageUrl);
|
||||||
|
void saveSettings();
|
||||||
|
void loadSettings();
|
||||||
|
void processNextGame();
|
||||||
|
|
||||||
|
QNetworkAccessManager *m_networkManager;
|
||||||
|
QString m_apiKey;
|
||||||
|
bool m_enabled = false;
|
||||||
|
bool m_preferSteamGridDB = false;
|
||||||
|
bool m_busy = false;
|
||||||
|
|
||||||
|
QList<Game *> m_pendingGames;
|
||||||
|
Game *m_currentGame = nullptr;
|
||||||
|
int m_totalGames = 0;
|
||||||
|
int m_processedGames = 0;
|
||||||
|
};
|
||||||
225
src/steamimporter.cpp
Normal file
225
src/steamimporter.cpp
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#include "steamimporter.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QTextStream>
|
||||||
|
|
||||||
|
SteamImporter::SteamImporter(QObject *parent)
|
||||||
|
: PlatformImporter(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SteamImporter::platformName() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("Steam");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SteamImporter::platformId() const
|
||||||
|
{
|
||||||
|
return QStringLiteral("steam");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SteamImporter::isAvailable() const
|
||||||
|
{
|
||||||
|
return !findSteamPath().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SteamImporter::findSteamPath() const
|
||||||
|
{
|
||||||
|
// Common Steam installation paths on Linux
|
||||||
|
QStringList possiblePaths = {expandPath(QStringLiteral("~/.steam/steam")),
|
||||||
|
expandPath(QStringLiteral("~/.local/share/Steam")),
|
||||||
|
expandPath(QStringLiteral("~/.var/app/com.valvesoftware.Steam/.steam/steam")),
|
||||||
|
expandPath(QStringLiteral("~/.var/app/com.valvesoftware.Steam/.local/share/Steam")),
|
||||||
|
QStringLiteral("/usr/share/steam"),
|
||||||
|
QStringLiteral("/usr/local/share/steam")};
|
||||||
|
|
||||||
|
for (const QString &path : possiblePaths) {
|
||||||
|
if (directoryExists(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SteamImporter::steamPath() const
|
||||||
|
{
|
||||||
|
return findSteamPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList SteamImporter::findLibraryFolders() const
|
||||||
|
{
|
||||||
|
QStringList folders;
|
||||||
|
QString steamDir = findSteamPath();
|
||||||
|
|
||||||
|
if (steamDir.isEmpty()) {
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add main Steam directory
|
||||||
|
QString steamApps = steamDir + QStringLiteral("/steamapps");
|
||||||
|
if (directoryExists(steamApps)) {
|
||||||
|
folders.append(steamApps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse libraryfolders.vdf
|
||||||
|
QString libraryFoldersPath = steamApps + QStringLiteral("/libraryfolders.vdf");
|
||||||
|
QFile file(libraryFoldersPath);
|
||||||
|
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream in(&file);
|
||||||
|
QString content = in.readAll();
|
||||||
|
|
||||||
|
// Simple VDF parser for library folders
|
||||||
|
static QRegularExpression pathRegex(QStringLiteral("\"path\"\\s+\"([^\"]+)\""));
|
||||||
|
QRegularExpressionMatchIterator it = pathRegex.globalMatch(content);
|
||||||
|
|
||||||
|
while (it.hasNext()) {
|
||||||
|
QRegularExpressionMatch match = it.next();
|
||||||
|
QString path = match.captured(1);
|
||||||
|
QString appsPath = path + QStringLiteral("/steamapps");
|
||||||
|
|
||||||
|
if (directoryExists(appsPath) && !folders.contains(appsPath)) {
|
||||||
|
folders.append(appsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *SteamImporter::parseAppManifest(const QString &path)
|
||||||
|
{
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream in(&file);
|
||||||
|
QString content = in.readAll();
|
||||||
|
|
||||||
|
// Simple VDF parser
|
||||||
|
auto getValue = [&content](const QString &key) -> QString {
|
||||||
|
QRegularExpression regex(QStringLiteral("\"%1\"\\s+\"([^\"]+)\"").arg(key));
|
||||||
|
QRegularExpressionMatch match = regex.match(content);
|
||||||
|
return match.hasMatch() ? match.captured(1) : QString();
|
||||||
|
};
|
||||||
|
|
||||||
|
QString appId = getValue(QStringLiteral("appid"));
|
||||||
|
QString name = getValue(QStringLiteral("name"));
|
||||||
|
QString installDir = getValue(QStringLiteral("installdir"));
|
||||||
|
|
||||||
|
if (appId.isEmpty() || name.isEmpty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip tools and other non-game content
|
||||||
|
QString stateFlags = getValue(QStringLiteral("StateFlags"));
|
||||||
|
if (stateFlags == QLatin1String("2")) {
|
||||||
|
// Only partially installed
|
||||||
|
}
|
||||||
|
|
||||||
|
Game *game = new Game(QStringLiteral("steam-%1").arg(appId), name);
|
||||||
|
game->setPlatform(platformName());
|
||||||
|
game->setPlatformId(appId);
|
||||||
|
game->setLaunchCommand(QStringLiteral("steam://rungameid/%1").arg(appId));
|
||||||
|
|
||||||
|
// Find cover image
|
||||||
|
QUrl coverUrl = findCoverImage(appId);
|
||||||
|
if (coverUrl.isValid()) {
|
||||||
|
game->setCoverUrl(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set installation directory
|
||||||
|
QFileInfo manifestInfo(path);
|
||||||
|
QString gameDir = manifestInfo.absolutePath() + QStringLiteral("/common/") + installDir;
|
||||||
|
game->setWorkingDirectory(gameDir);
|
||||||
|
|
||||||
|
game->setInstalled(true);
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
QUrl SteamImporter::findCoverImage(const QString &appId) const
|
||||||
|
{
|
||||||
|
QString steamDir = findSteamPath();
|
||||||
|
if (steamDir.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local cache first
|
||||||
|
QStringList coverNames = {QStringLiteral("library_600x900.jpg"),
|
||||||
|
QStringLiteral("library_600x900_2x.jpg"),
|
||||||
|
QStringLiteral("header.jpg"),
|
||||||
|
QStringLiteral("capsule_616x353.jpg")};
|
||||||
|
|
||||||
|
QString cacheDir = steamDir + QStringLiteral("/appcache/librarycache");
|
||||||
|
for (const QString &coverName : coverNames) {
|
||||||
|
QString coverPath = cacheDir + QStringLiteral("/%1_%2").arg(appId, coverName);
|
||||||
|
if (QFile::exists(coverPath)) {
|
||||||
|
return QUrl::fromLocalFile(coverPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check alternative grid path
|
||||||
|
QString gridDir = steamDir + QStringLiteral("/userdata");
|
||||||
|
QDir userDataDir(gridDir);
|
||||||
|
QStringList userDirs = userDataDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||||
|
|
||||||
|
for (const QString &userDir : userDirs) {
|
||||||
|
QString gridPath = gridDir + QStringLiteral("/%1/config/grid").arg(userDir);
|
||||||
|
QDir grid(gridPath);
|
||||||
|
|
||||||
|
if (grid.exists()) {
|
||||||
|
QStringList filters = {appId + QStringLiteral("p.*"), appId + QStringLiteral(".*")};
|
||||||
|
QStringList images = grid.entryList(filters, QDir::Files);
|
||||||
|
|
||||||
|
if (!images.isEmpty()) {
|
||||||
|
return QUrl::fromLocalFile(gridPath + QStringLiteral("/") + images.first());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return Steam CDN URL as fallback
|
||||||
|
return QUrl(QStringLiteral("https://steamcdn-a.akamaihd.net/steam/apps/%1/library_600x900_2x.jpg").arg(appId));
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Game *> SteamImporter::importGames()
|
||||||
|
{
|
||||||
|
QList<Game *> games;
|
||||||
|
QStringList libraryFolders = findLibraryFolders();
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
int current = 0;
|
||||||
|
|
||||||
|
// Count manifests first
|
||||||
|
for (const QString &folder : libraryFolders) {
|
||||||
|
QDir dir(folder);
|
||||||
|
total += dir.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QString &folder : libraryFolders) {
|
||||||
|
QDir dir(folder);
|
||||||
|
QStringList manifests = dir.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files);
|
||||||
|
|
||||||
|
for (const QString &manifest : manifests) {
|
||||||
|
Game *game = parseAppManifest(folder + QStringLiteral("/") + manifest);
|
||||||
|
if (game) {
|
||||||
|
games.append(game);
|
||||||
|
}
|
||||||
|
current++;
|
||||||
|
Q_EMIT importProgress(current, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
26
src/steamimporter.h
Normal file
26
src/steamimporter.h
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "platformimporter.h"
|
||||||
|
|
||||||
|
class SteamImporter : public PlatformImporter
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SteamImporter(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString platformName() const override;
|
||||||
|
QString platformId() const override;
|
||||||
|
bool isAvailable() const override;
|
||||||
|
QList<Game *> importGames() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString findSteamPath() const;
|
||||||
|
QStringList findLibraryFolders() const;
|
||||||
|
Game *parseAppManifest(const QString &path);
|
||||||
|
QUrl findCoverImage(const QString &appId) const;
|
||||||
|
QString steamPath() const;
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue