From 747b02035abb0fc96a76e60b6d6795fafc2a53b5 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 18 Jan 2026 13:13:07 +0100 Subject: [PATCH] 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. --- .clang-format | 95 ++ .gitignore | 10 + .kde-builder.yaml | 23 + CMakeLists.txt | 82 ++ HACKING.md | 85 ++ LICENSES/CC0-1.0.txt | 7 + LICENSES/GPL-3.0-or-later.txt | 24 + README.md | 228 +++++ icons/CMakeLists.txt | 9 + icons/sc-apps-org.kde.alakarte.svg | 30 + org.kde.alakarte.desktop | 16 + org.kde.alakarte.metainfo.xml | 94 ++ src/CMakeLists.txt | 102 +++ src/app.cpp | 685 ++++++++++++++ src/app.h | 83 ++ src/bottlesimporter.cpp | 128 +++ src/bottlesimporter.h | 23 + src/config.cpp | 369 ++++++++ src/config.h | 140 +++ src/desktopimporter.cpp | 196 ++++ src/desktopimporter.h | 24 + src/flatpakimporter.cpp | 170 ++++ src/flatpakimporter.h | 24 + src/game.cpp | 361 ++++++++ src/game.h | 140 +++ src/gamelauncher.cpp | 202 +++++ src/gamelauncher.h | 45 + src/gamemodel.cpp | 345 ++++++++ src/gamemodel.h | 108 +++ src/gamepadmanager.cpp | 232 +++++ src/gamepadmanager.h | 57 ++ src/gamesortfiltermodel.cpp | 174 ++++ src/gamesortfiltermodel.h | 71 ++ src/heroicimporter.cpp | 319 +++++++ src/heroicimporter.h | 26 + src/itchimporter.cpp | 128 +++ src/itchimporter.h | 23 + src/krunner/CMakeLists.txt | 20 + src/krunner/alakarterunner.cpp | 166 ++++ src/krunner/alakarterunner.h | 31 + src/krunner/plasma-runner-alakarte.json | 18 + src/legendaryimporter.cpp | 149 ++++ src/legendaryimporter.h | 23 + src/lutrisimporter.cpp | 211 +++++ src/lutrisimporter.h | 25 + src/main.cpp | 88 ++ src/platformimporter.cpp | 46 + src/platformimporter.h | 32 + src/qml/GameCard.qml | 334 +++++++ src/qml/GameDetailsSheet.qml | 539 +++++++++++ src/qml/GameEditDialog.qml | 258 ++++++ src/qml/LibraryView.qml | 216 +++++ src/qml/Main.qml | 835 ++++++++++++++++++ src/qml/SettingsPage.qml | 346 ++++++++ src/qml/SidebarView.qml | 371 ++++++++ src/qml/components/EmptyState.qml | 54 ++ src/qml/components/FocusableCard.qml | 53 ++ src/qml/components/GameGridView.qml | 73 ++ .../components/IconWithResourceFallback.qml | 32 + src/qml/components/SearchHeader.qml | 83 ++ src/qml/icons/app/org.kde.alakarte.svg | 30 + src/qml/icons/brand/itchdotio-symbolic.svg | 1 + src/qml/icons/brand/retroarch-symbolic.svg | 1 + src/qml/icons/brand/steam-symbolic.svg | 1 + src/retroarchimporter.cpp | 139 +++ src/retroarchimporter.h | 24 + src/steamgriddb.cpp | 342 +++++++ src/steamgriddb.h | 69 ++ src/steamimporter.cpp | 225 +++++ src/steamimporter.h | 26 + 70 files changed, 9739 insertions(+) create mode 100644 .clang-format create mode 100644 .gitignore create mode 100644 .kde-builder.yaml create mode 100644 CMakeLists.txt create mode 100644 HACKING.md create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 LICENSES/GPL-3.0-or-later.txt create mode 100644 README.md create mode 100644 icons/CMakeLists.txt create mode 100644 icons/sc-apps-org.kde.alakarte.svg create mode 100644 org.kde.alakarte.desktop create mode 100644 org.kde.alakarte.metainfo.xml create mode 100644 src/CMakeLists.txt create mode 100644 src/app.cpp create mode 100644 src/app.h create mode 100644 src/bottlesimporter.cpp create mode 100644 src/bottlesimporter.h create mode 100644 src/config.cpp create mode 100644 src/config.h create mode 100644 src/desktopimporter.cpp create mode 100644 src/desktopimporter.h create mode 100644 src/flatpakimporter.cpp create mode 100644 src/flatpakimporter.h create mode 100644 src/game.cpp create mode 100644 src/game.h create mode 100644 src/gamelauncher.cpp create mode 100644 src/gamelauncher.h create mode 100644 src/gamemodel.cpp create mode 100644 src/gamemodel.h create mode 100644 src/gamepadmanager.cpp create mode 100644 src/gamepadmanager.h create mode 100644 src/gamesortfiltermodel.cpp create mode 100644 src/gamesortfiltermodel.h create mode 100644 src/heroicimporter.cpp create mode 100644 src/heroicimporter.h create mode 100644 src/itchimporter.cpp create mode 100644 src/itchimporter.h create mode 100644 src/krunner/CMakeLists.txt create mode 100644 src/krunner/alakarterunner.cpp create mode 100644 src/krunner/alakarterunner.h create mode 100644 src/krunner/plasma-runner-alakarte.json create mode 100644 src/legendaryimporter.cpp create mode 100644 src/legendaryimporter.h create mode 100644 src/lutrisimporter.cpp create mode 100644 src/lutrisimporter.h create mode 100644 src/main.cpp create mode 100644 src/platformimporter.cpp create mode 100644 src/platformimporter.h create mode 100644 src/qml/GameCard.qml create mode 100644 src/qml/GameDetailsSheet.qml create mode 100644 src/qml/GameEditDialog.qml create mode 100644 src/qml/LibraryView.qml create mode 100644 src/qml/Main.qml create mode 100644 src/qml/SettingsPage.qml create mode 100644 src/qml/SidebarView.qml create mode 100644 src/qml/components/EmptyState.qml create mode 100644 src/qml/components/FocusableCard.qml create mode 100644 src/qml/components/GameGridView.qml create mode 100644 src/qml/components/IconWithResourceFallback.qml create mode 100644 src/qml/components/SearchHeader.qml create mode 100644 src/qml/icons/app/org.kde.alakarte.svg create mode 100644 src/qml/icons/brand/itchdotio-symbolic.svg create mode 100644 src/qml/icons/brand/retroarch-symbolic.svg create mode 100644 src/qml/icons/brand/steam-symbolic.svg create mode 100644 src/retroarchimporter.cpp create mode 100644 src/retroarchimporter.h create mode 100644 src/steamgriddb.cpp create mode 100644 src/steamgriddb.h create mode 100644 src/steamimporter.cpp create mode 100644 src/steamimporter.h diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..9b5ae0a --- /dev/null +++ b/.clang-format @@ -0,0 +1,95 @@ +--- +# SPDX-FileCopyrightText: 2019 Christoph Cullmann +# SPDX-FileCopyrightText: 2019 Gernot Gebhard +# +# 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> +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1558d92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/build/ +/build-*/ +/.cache/ +/compile_commands.json +/CMakeUserPresets.json +/.clangd +/.vscode/ +/.idea/ + +/FEATURE_GAP_ANALYSIS.md diff --git a/.kde-builder.yaml b/.kde-builder.yaml new file mode 100644 index 0000000..00a5e5f --- /dev/null +++ b/.kde-builder.yaml @@ -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} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a377abc --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 0000000..01ea248 --- /dev/null +++ b/HACKING.md @@ -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). diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..884dc8b --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -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 diff --git a/LICENSES/GPL-3.0-or-later.txt b/LICENSES/GPL-3.0-or-later.txt new file mode 100644 index 0000000..9333aab --- /dev/null +++ b/LICENSES/GPL-3.0-or-later.txt @@ -0,0 +1,24 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +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 . diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c684e3 --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# A-La-Karte + +[![KDE Frameworks 6](https://img.shields.io/badge/KDE%20Frameworks-6-blue)](https://develop.kde.org/frameworks/) +[![Qt 6](https://img.shields.io/badge/Qt-6.6+-green)](https://www.qt.io/) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](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) diff --git a/icons/CMakeLists.txt b/icons/CMakeLists.txt new file mode 100644 index 0000000..1c663d5 --- /dev/null +++ b/icons/CMakeLists.txt @@ -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} +) diff --git a/icons/sc-apps-org.kde.alakarte.svg b/icons/sc-apps-org.kde.alakarte.svg new file mode 100644 index 0000000..9ce6687 --- /dev/null +++ b/icons/sc-apps-org.kde.alakarte.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/org.kde.alakarte.desktop b/org.kde.alakarte.desktop new file mode 100644 index 0000000..8112890 --- /dev/null +++ b/org.kde.alakarte.desktop @@ -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 diff --git a/org.kde.alakarte.metainfo.xml b/org.kde.alakarte.metainfo.xml new file mode 100644 index 0000000..99f0e4a --- /dev/null +++ b/org.kde.alakarte.metainfo.xml @@ -0,0 +1,94 @@ + + + + + org.kde.alakarte + A-La-Karte + A unified game launcher for KDE Plasma + CC0-1.0 + GPL-3.0-or-later + + A-La-Karte Contributors + + + https://invent.kde.org/marcoa/a-la-karte + https://invent.kde.org/marcoa/a-la-karte/-/issues + https://invent.kde.org/marcoa/a-la-karte + + +

+ 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. +

+

Features:

+
    +
  • Import games from Steam, Lutris, Heroic, Bottles, Flatpak, itch.io, Legendary, RetroArch playlists, and desktop entries
  • +
  • Grid and list views
  • +
  • Search and filter your game library
  • +
  • Mark games as favorites
  • +
  • Show last played timestamps
  • +
  • Launch games directly from the app
  • +
  • SteamGridDB cover art integration and custom cover upload
  • +
  • KRunner integration for launching games from Plasma search
  • +
  • Adaptive layout for small screens
  • +
  • Native Plasma 6 integration
  • +
+
+ + org.kde.alakarte.desktop + + + alakarte + + + + 360 + + + + keyboard + pointing + touch + + + + Game + Qt + + + + games + launcher + steam + lutris + heroic + bottles + flatpak + itch + legendary + retroarch + steamgriddb + epic + gog + library + + + + + +

First public preview release of A-La-Karte

+
    +
  • Import games from Steam, Lutris, Heroic, Bottles, Flatpak, itch.io, Legendary, RetroArch playlists, and desktop entries
  • +
  • Grid and list view modes
  • +
  • Search and filtering
  • +
  • Favorites support
  • +
  • Game launching
  • +
+
+
+
+ + + +
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..2c02e91 --- /dev/null +++ b/src/CMakeLists.txt @@ -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}) diff --git a/src/app.cpp b/src/app.cpp new file mode 100644 index 0000000..c2f7da6 --- /dev/null +++ b/src/app.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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 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 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 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 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 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 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 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 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 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 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 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 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 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; +} diff --git a/src/app.h b/src/app.h new file mode 100644 index 0000000..de71a41 --- /dev/null +++ b/src/app.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include + +#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 m_removedGames; + + void setImporting(bool importing); + void setImportStatus(const QString &status); +}; diff --git a/src/bottlesimporter.cpp b/src/bottlesimporter.cpp new file mode 100644 index 0000000..8e19a24 --- /dev/null +++ b/src/bottlesimporter.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "bottlesimporter.h" + +#include +#include +#include +#include +#include + +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 BottlesImporter::parseBottle(const QString &bottlePath, const QString &bottleName) const +{ + QList 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 BottlesImporter::importGames() +{ + QList 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 bottleGames = parseBottle(bottlePath, bottleName); + games.append(bottleGames); + } + } + + return games; +} diff --git a/src/bottlesimporter.h b/src/bottlesimporter.h new file mode 100644 index 0000000..57b70a4 --- /dev/null +++ b/src/bottlesimporter.h @@ -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 importGames() override; + +private: + QStringList getBottlesPaths() const; + QList parseBottle(const QString &bottlePath, const QString &bottleName) const; +}; diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..e914254 --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "config.h" + +#include +#include + +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(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(generalGroup.readEntry(QStringLiteral("viewMode"), static_cast(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(); +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..854445d --- /dev/null +++ b/src/config.h @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include + +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; +}; diff --git a/src/desktopimporter.cpp b/src/desktopimporter.cpp new file mode 100644 index 0000000..3c0f504 --- /dev/null +++ b/src/desktopimporter.cpp @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "desktopimporter.h" + +#include +#include +#include +#include +#include + +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 DesktopImporter::importGames() +{ + QList 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; +} diff --git a/src/desktopimporter.h b/src/desktopimporter.h new file mode 100644 index 0000000..1024c83 --- /dev/null +++ b/src/desktopimporter.h @@ -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 importGames() override; + +private: + QStringList getDesktopFilePaths() const; + Game *parseDesktopFile(const QString &filePath) const; + bool isGameDesktopFile(const QString &filePath) const; +}; diff --git a/src/flatpakimporter.cpp b/src/flatpakimporter.cpp new file mode 100644 index 0000000..41a4322 --- /dev/null +++ b/src/flatpakimporter.cpp @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "flatpakimporter.h" + +#include +#include +#include +#include +#include + +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 FlatpakImporter::importGames() +{ + QList 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; +} diff --git a/src/flatpakimporter.h b/src/flatpakimporter.h new file mode 100644 index 0000000..4f80784 --- /dev/null +++ b/src/flatpakimporter.h @@ -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 importGames() override; + +private: + QStringList getFlatpakPaths() const; + bool isGameApp(const QString &desktopFilePath) const; + Game *parseDesktopFile(const QString &filePath, const QString &appId) const; +}; diff --git a/src/game.cpp b/src/game.cpp new file mode 100644 index 0000000..3c85c3e --- /dev/null +++ b/src/game.cpp @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#include "game.h" + +#include +#include +#include + +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); +} diff --git a/src/game.h b/src/game.h new file mode 100644 index 0000000..eed97c5 --- /dev/null +++ b/src/game.h @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include +#include +#include + +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; +}; diff --git a/src/gamelauncher.cpp b/src/gamelauncher.cpp new file mode 100644 index 0000000..b269fac --- /dev/null +++ b/src/gamelauncher.cpp @@ -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 +#include +#include +#include +#include + +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::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(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(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(); +} diff --git a/src/gamelauncher.h b/src/gamelauncher.h new file mode 100644 index 0000000..94e14a0 --- /dev/null +++ b/src/gamelauncher.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include + +#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 m_runningGames; + QMap m_processToGame; + + void cleanupProcess(QProcess *process); +}; diff --git a/src/gamemodel.cpp b/src/gamemodel.cpp new file mode 100644 index 0000000..0d6d6ea --- /dev/null +++ b/src/gamemodel.cpp @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#include "gamemodel.h" + +#include + +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 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; + }); +} diff --git a/src/gamemodel.h b/src/gamemodel.h new file mode 100644 index 0000000..88b09fc --- /dev/null +++ b/src/gamemodel.h @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#pragma once + +#include +#include +#include + +#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 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 m_games; + QList 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; +}; diff --git a/src/gamepadmanager.cpp b/src/gamepadmanager.cpp new file mode 100644 index 0000000..1d4d914 --- /dev/null +++ b/src/gamepadmanager.cpp @@ -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 +#include + +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 diff --git a/src/gamepadmanager.h b/src/gamepadmanager.h new file mode 100644 index 0000000..d0c1085 --- /dev/null +++ b/src/gamepadmanager.h @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#pragma once + +#include +#include +#include + +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); +}; diff --git a/src/gamesortfiltermodel.cpp b/src/gamesortfiltermodel.cpp new file mode 100644 index 0000000..a2a6c10 --- /dev/null +++ b/src/gamesortfiltermodel.cpp @@ -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(); +} diff --git a/src/gamesortfiltermodel.h b/src/gamesortfiltermodel.h new file mode 100644 index 0000000..700a80d --- /dev/null +++ b/src/gamesortfiltermodel.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#pragma once + +#include +#include + +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(); +}; diff --git a/src/heroicimporter.cpp b/src/heroicimporter.cpp new file mode 100644 index 0000000..23a6237 --- /dev/null +++ b/src/heroicimporter.cpp @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#include "heroicimporter.h" + +#include +#include +#include +#include +#include +#include + +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 HeroicImporter::importGames() +{ + QList games; + + games.append(importEpicGames()); + games.append(importGOGGames()); + games.append(importAmazonGames()); + + return games; +} + +QList HeroicImporter::importEpicGames() +{ + QList 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 HeroicImporter::importGOGGames() +{ + QList 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 HeroicImporter::importAmazonGames() +{ + QList 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; +} diff --git a/src/heroicimporter.h b/src/heroicimporter.h new file mode 100644 index 0000000..c4334f0 --- /dev/null +++ b/src/heroicimporter.h @@ -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 importGames() override; + +private: + QString findHeroicConfigPath() const; + QList importEpicGames(); + QList importGOGGames(); + QList importAmazonGames(); + QUrl findCoverImage(const QString &appName, const QString &store) const; +}; diff --git a/src/itchimporter.cpp b/src/itchimporter.cpp new file mode 100644 index 0000000..983e863 --- /dev/null +++ b/src/itchimporter.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "itchimporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 ItchImporter::parseItchDatabase(const QString &dbPath) const +{ + QList games; + + // Use a unique connection name + QString connectionName = QStringLiteral("itch_import_%1").arg(reinterpret_cast(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 ItchImporter::importGames() +{ + QString dbPath = getItchDatabasePath(); + if (dbPath.isEmpty()) { + return {}; + } + + return parseItchDatabase(dbPath); +} diff --git a/src/itchimporter.h b/src/itchimporter.h new file mode 100644 index 0000000..f9f95a7 --- /dev/null +++ b/src/itchimporter.h @@ -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 importGames() override; + +private: + QString getItchDatabasePath() const; + QList parseItchDatabase(const QString &dbPath) const; +}; diff --git a/src/krunner/CMakeLists.txt b/src/krunner/CMakeLists.txt new file mode 100644 index 0000000..fef94d5 --- /dev/null +++ b/src/krunner/CMakeLists.txt @@ -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() diff --git a/src/krunner/alakarterunner.cpp b/src/krunner/alakarterunner.cpp new file mode 100644 index 0000000..92d0104 --- /dev/null +++ b/src/krunner/alakarterunner.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "alakarterunner.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +K_PLUGIN_CLASS_WITH_JSON(AlakarteRunner, "plasma-runner-alakarte.json") + +AlakarteRunner::AlakarteRunner(QObject *parent, const KPluginMetaData &metaData) + : KRunner::AbstractRunner(parent, metaData) +{ + addSyntax(QStringLiteral("game "), i18n("Search and launch games from A-La-Karte library")); + addSyntax(QStringLiteral(""), 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::loadGames() const +{ + QList 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 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" diff --git a/src/krunner/alakarterunner.h b/src/krunner/alakarterunner.h new file mode 100644 index 0000000..ebe8c15 --- /dev/null +++ b/src/krunner/alakarterunner.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include + +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 loadGames() const; + QString libraryPath() const; +}; diff --git a/src/krunner/plasma-runner-alakarte.json b/src/krunner/plasma-runner-alakarte.json new file mode 100644 index 0000000..88ac515 --- /dev/null +++ b/src/krunner/plasma-runner-alakarte.json @@ -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" +} diff --git a/src/legendaryimporter.cpp b/src/legendaryimporter.cpp new file mode 100644 index 0000000..6a97765 --- /dev/null +++ b/src/legendaryimporter.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "legendaryimporter.h" + +#include +#include +#include +#include +#include + +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 LegendaryImporter::parseInstalledGames() const +{ + QList 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 LegendaryImporter::importGames() +{ + return parseInstalledGames(); +} diff --git a/src/legendaryimporter.h b/src/legendaryimporter.h new file mode 100644 index 0000000..d3e8fb9 --- /dev/null +++ b/src/legendaryimporter.h @@ -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 importGames() override; + +private: + QString getLegendaryConfigPath() const; + QList parseInstalledGames() const; +}; diff --git a/src/lutrisimporter.cpp b/src/lutrisimporter.cpp new file mode 100644 index 0000000..5961c87 --- /dev/null +++ b/src/lutrisimporter.cpp @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#include "lutrisimporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 LutrisImporter::importGames() +{ + QList 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; +} diff --git a/src/lutrisimporter.h b/src/lutrisimporter.h new file mode 100644 index 0000000..5a697b4 --- /dev/null +++ b/src/lutrisimporter.h @@ -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 importGames() override; + +private: + QString findLutrisDataPath() const; + QString findLutrisCachePath() const; + Game *parseGameFromDatabase(const QVariantMap &record); + QUrl findCoverImage(const QString &slug) const; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..f50b271 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#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("org.kde.alakarte", 1, 0, "App", [](QQmlEngine *engine, QJSEngine *) -> QObject * { + Q_UNUSED(engine) + return App::instance(); + }); + + qmlRegisterSingletonType("org.kde.alakarte", 1, 0, "GamepadManager", &GamepadManager::create); + + qmlRegisterType("org.kde.alakarte", 1, 0, "GameModel"); + qmlRegisterType("org.kde.alakarte", 1, 0, "GameSortFilterModel"); + qmlRegisterUncreatableType("org.kde.alakarte", 1, 0, "Game", QStringLiteral("Game objects are created by GameModel")); + qmlRegisterType("org.kde.alakarte", 1, 0, "GameLauncher"); + qmlRegisterType("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(); +} diff --git a/src/platformimporter.cpp b/src/platformimporter.cpp new file mode 100644 index 0000000..c3af30b --- /dev/null +++ b/src/platformimporter.cpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#include "platformimporter.h" + +#include +#include +#include + +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; +} diff --git a/src/platformimporter.h b/src/platformimporter.h new file mode 100644 index 0000000..cf4bd3d --- /dev/null +++ b/src/platformimporter.h @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#pragma once + +#include +#include + +#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 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; +}; diff --git a/src/qml/GameCard.qml b/src/qml/GameCard.qml new file mode 100644 index 0000000..b15adda --- /dev/null +++ b/src/qml/GameCard.qml @@ -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 + } +} diff --git a/src/qml/GameDetailsSheet.qml b/src/qml/GameDetailsSheet.qml new file mode 100644 index 0000000..3217b3e --- /dev/null +++ b/src/qml/GameDetailsSheet.qml @@ -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 + } +} diff --git a/src/qml/GameEditDialog.qml b/src/qml/GameEditDialog.qml new file mode 100644 index 0000000..fe4ec6e --- /dev/null +++ b/src/qml/GameEditDialog.qml @@ -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 = "" + } +} diff --git a/src/qml/LibraryView.qml b/src/qml/LibraryView.qml new file mode 100644 index 0000000..6d782d1 --- /dev/null +++ b/src/qml/LibraryView.qml @@ -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" + } + } + } + } + } + } +} diff --git a/src/qml/Main.qml b/src/qml/Main.qml new file mode 100644 index 0000000..5e17085 --- /dev/null +++ b/src/qml/Main.qml @@ -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() + } + } +} diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml new file mode 100644 index 0000000..22b82cb --- /dev/null +++ b/src/qml/SettingsPage.qml @@ -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() + } + } +} diff --git a/src/qml/SidebarView.qml b/src/qml/SidebarView.qml new file mode 100644 index 0000000..9fc8f98 --- /dev/null +++ b/src/qml/SidebarView.qml @@ -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() + } + } +} diff --git a/src/qml/components/EmptyState.qml b/src/qml/components/EmptyState.qml new file mode 100644 index 0000000..acd3214 --- /dev/null +++ b/src/qml/components/EmptyState.qml @@ -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() + } +} diff --git a/src/qml/components/FocusableCard.qml b/src/qml/components/FocusableCard.qml new file mode 100644 index 0000000..2f8a574 --- /dev/null +++ b/src/qml/components/FocusableCard.qml @@ -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() +} diff --git a/src/qml/components/GameGridView.qml b/src/qml/components/GameGridView.qml new file mode 100644 index 0000000..81d9aea --- /dev/null +++ b/src/qml/components/GameGridView.qml @@ -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 {} +} diff --git a/src/qml/components/IconWithResourceFallback.qml b/src/qml/components/IconWithResourceFallback.qml new file mode 100644 index 0000000..2911632 --- /dev/null +++ b/src/qml/components/IconWithResourceFallback.qml @@ -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 + } + } +} diff --git a/src/qml/components/SearchHeader.qml b/src/qml/components/SearchHeader.qml new file mode 100644 index 0000000..503d863 --- /dev/null +++ b/src/qml/components/SearchHeader.qml @@ -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) + } + } + } + } + } +} diff --git a/src/qml/icons/app/org.kde.alakarte.svg b/src/qml/icons/app/org.kde.alakarte.svg new file mode 100644 index 0000000..9ce6687 --- /dev/null +++ b/src/qml/icons/app/org.kde.alakarte.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/qml/icons/brand/itchdotio-symbolic.svg b/src/qml/icons/brand/itchdotio-symbolic.svg new file mode 100644 index 0000000..7a24504 --- /dev/null +++ b/src/qml/icons/brand/itchdotio-symbolic.svg @@ -0,0 +1 @@ +Itch.io diff --git a/src/qml/icons/brand/retroarch-symbolic.svg b/src/qml/icons/brand/retroarch-symbolic.svg new file mode 100644 index 0000000..cfc4397 --- /dev/null +++ b/src/qml/icons/brand/retroarch-symbolic.svg @@ -0,0 +1 @@ +RetroArch diff --git a/src/qml/icons/brand/steam-symbolic.svg b/src/qml/icons/brand/steam-symbolic.svg new file mode 100644 index 0000000..ef51f6e --- /dev/null +++ b/src/qml/icons/brand/steam-symbolic.svg @@ -0,0 +1 @@ +Steam diff --git a/src/retroarchimporter.cpp b/src/retroarchimporter.cpp new file mode 100644 index 0000000..f6c0a3b --- /dev/null +++ b/src/retroarchimporter.cpp @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "retroarchimporter.h" + +#include +#include +#include +#include +#include +#include + +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 RetroArchImporter::parsePlaylist(const QString &playlistPath) const +{ + QList 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 RetroArchImporter::importGames() +{ + QList 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 games = parsePlaylist(fullPath); + allGames.append(games); + } + + return allGames; +} diff --git a/src/retroarchimporter.h b/src/retroarchimporter.h new file mode 100644 index 0000000..813b55a --- /dev/null +++ b/src/retroarchimporter.h @@ -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 importGames() override; + +private: + QString getRetroArchConfigPath() const; + QString getPlaylistsPath() const; + QList parsePlaylist(const QString &playlistPath) const; +}; diff --git a/src/steamgriddb.cpp b/src/steamgriddb.cpp new file mode 100644 index 0000000..4281401 --- /dev/null +++ b/src/steamgriddb.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(sender()); + if (!reply) { + processNextGame(); + return; + } + + reply->deleteLater(); + + Game *game = reply->property("game").value(); + 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(sender()); + if (!reply) { + processNextGame(); + return; + } + + reply->deleteLater(); + + Game *game = reply->property("game").value(); + 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(sender()); + if (!reply) { + processNextGame(); + return; + } + + reply->deleteLater(); + + Game *game = reply->property("game").value(); + 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); +} diff --git a/src/steamgriddb.h b/src/steamgriddb.h new file mode 100644 index 0000000..72075b6 --- /dev/null +++ b/src/steamgriddb.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include + +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 m_pendingGames; + Game *m_currentGame = nullptr; + int m_totalGames = 0; + int m_processedGames = 0; +}; diff --git a/src/steamimporter.cpp b/src/steamimporter.cpp new file mode 100644 index 0000000..0367af0 --- /dev/null +++ b/src/steamimporter.cpp @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors + +#include "steamimporter.h" + +#include +#include +#include +#include +#include +#include + +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 SteamImporter::importGames() +{ + QList 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; +} diff --git a/src/steamimporter.h b/src/steamimporter.h new file mode 100644 index 0000000..c193604 --- /dev/null +++ b/src/steamimporter.h @@ -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 importGames() override; + +private: + QString findSteamPath() const; + QStringList findLibraryFolders() const; + Game *parseAppManifest(const QString &path); + QUrl findCoverImage(const QString &appId) const; + QString steamPath() const; +};